beadcyte 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/LICENSE +661 -0
  3. package/README.md +386 -0
  4. package/THIRD_PARTY_NOTICES.md +56 -0
  5. package/bin/beadcyte.mjs +60 -0
  6. package/package.json +77 -0
  7. package/src/changelog-cli.mjs +105 -0
  8. package/src/changelog.mjs +196 -0
  9. package/src/cli.mjs +577 -0
  10. package/src/estimator.mjs +314 -0
  11. package/src/format.mjs +22 -0
  12. package/src/history-walk.mjs +170 -0
  13. package/src/index.mjs +5 -0
  14. package/src/mutate.mjs +193 -0
  15. package/src/projects.mjs +120 -0
  16. package/src/provenance.mjs +75 -0
  17. package/src/review-hours.mjs +117 -0
  18. package/src/roster-path.mjs +24 -0
  19. package/src/scheduler.mjs +424 -0
  20. package/src/serve.mjs +411 -0
  21. package/src/server-state.mjs +105 -0
  22. package/src/ship.mjs +178 -0
  23. package/src/stage-waits.mjs +69 -0
  24. package/src/start.mjs +111 -0
  25. package/src/stop.mjs +66 -0
  26. package/src/velocity.mjs +209 -0
  27. package/src/web/App.vue +691 -0
  28. package/src/web/app.css +54 -0
  29. package/src/web/assets/favicon.svg +12 -0
  30. package/src/web/avatar.ts +53 -0
  31. package/src/web/bead-detail.ts +240 -0
  32. package/src/web/changelog-view.ts +41 -0
  33. package/src/web/components/BeadDrawer.vue +1883 -0
  34. package/src/web/components/BeadSubGraph.vue +326 -0
  35. package/src/web/components/BeadSubGraphOverlay.vue +192 -0
  36. package/src/web/components/BeadTooltip.vue +516 -0
  37. package/src/web/components/BeadcyteMark.vue +64 -0
  38. package/src/web/components/BeadsGantt.vue +2125 -0
  39. package/src/web/components/BeadsGrid.vue +468 -0
  40. package/src/web/components/BeadsIncytes.vue +567 -0
  41. package/src/web/components/BeadsMine.vue +325 -0
  42. package/src/web/components/BeadsTable.vue +335 -0
  43. package/src/web/components/ChangelogOverlay.vue +198 -0
  44. package/src/web/components/ContextMenu.vue +386 -0
  45. package/src/web/components/ControlsPanel.vue +476 -0
  46. package/src/web/components/CostTrend.vue +206 -0
  47. package/src/web/components/FilterPopover.vue +245 -0
  48. package/src/web/components/GroupProgress.vue +274 -0
  49. package/src/web/components/LoadMeter.vue +144 -0
  50. package/src/web/components/MineRow.vue +28 -0
  51. package/src/web/components/OptionsMenu.vue +825 -0
  52. package/src/web/components/PriorityChip.vue +105 -0
  53. package/src/web/components/ScoreStrip.vue +131 -0
  54. package/src/web/components/SearchPalette.vue +210 -0
  55. package/src/web/components/ShipTrend.vue +510 -0
  56. package/src/web/components/ShortcutsOverlay.vue +164 -0
  57. package/src/web/components/Term.vue +177 -0
  58. package/src/web/components/Toast.vue +50 -0
  59. package/src/web/components/TriageMeters.vue +426 -0
  60. package/src/web/components/TypeChip.vue +96 -0
  61. package/src/web/components/Walkthrough.vue +209 -0
  62. package/src/web/components/WhatIfPanel.vue +206 -0
  63. package/src/web/components/WipBullets.vue +191 -0
  64. package/src/web/components/filter-option.ts +9 -0
  65. package/src/web/composables/url-codec.ts +136 -0
  66. package/src/web/composables/useBeadTooltip.ts +148 -0
  67. package/src/web/composables/useKeyboard.ts +97 -0
  68. package/src/web/composables/useLiveRefresh.ts +69 -0
  69. package/src/web/composables/useTheme.ts +125 -0
  70. package/src/web/composables/useUrlState.ts +208 -0
  71. package/src/web/controls-scope.ts +83 -0
  72. package/src/web/cost.ts +251 -0
  73. package/src/web/dep-headings.ts +62 -0
  74. package/src/web/economics.ts +440 -0
  75. package/src/web/env.d.ts +85 -0
  76. package/src/web/frontier.ts +208 -0
  77. package/src/web/gantt-viewport.ts +99 -0
  78. package/src/web/highlights.ts +124 -0
  79. package/src/web/index.html +46 -0
  80. package/src/web/insights.ts +107 -0
  81. package/src/web/keybindings.ts +200 -0
  82. package/src/web/load-meter.ts +72 -0
  83. package/src/web/main.ts +20 -0
  84. package/src/web/markdown.ts +14 -0
  85. package/src/web/mine.ts +137 -0
  86. package/src/web/mutations.ts +21 -0
  87. package/src/web/person.ts +102 -0
  88. package/src/web/projects-text.ts +15 -0
  89. package/src/web/projects.ts +188 -0
  90. package/src/web/refresh.ts +47 -0
  91. package/src/web/search.ts +50 -0
  92. package/src/web/shortcuts.ts +113 -0
  93. package/src/web/status-filter.ts +48 -0
  94. package/src/web/store.ts +1378 -0
  95. package/src/web/style-audit.mjs +346 -0
  96. package/src/web/styles-alt.css +111 -0
  97. package/src/web/styles-ported.css +270 -0
  98. package/src/web/subgraph.ts +362 -0
  99. package/src/web/table.ts +201 -0
  100. package/src/web/theme.ts +88 -0
  101. package/src/web/tokens.css +168 -0
  102. package/src/web/triage.ts +914 -0
  103. package/src/web/view-model.ts +717 -0
  104. package/src/web/walkthrough.ts +133 -0
  105. package/src/web/watchlist.ts +47 -0
  106. package/src/web/whatif.ts +291 -0
  107. package/src/web/window.ts +73 -0
  108. package/src/web/wip.ts +83 -0
@@ -0,0 +1,133 @@
1
+ // walkthrough.ts — the first-run walkthrough's content and memory (bp-67g.48).
2
+ //
3
+ // A short tour of the things a first-time reader would not guess, in the
4
+ // order they would not guess them. Two rules shape it:
5
+ //
6
+ // NOTHING EXISTS ONLY HERE. Every step names where the same fact lives
7
+ // outside the tour (`alsoIn`) — the README, docs/model.md, a hover in the
8
+ // drawer — because a tour is dismissed once and then it is gone. The bar
9
+ // kinds come from view-model.ts's own notes, which the Gantt footer shows
10
+ // on hover, so the tour cannot describe kinds the code does not draw.
11
+ //
12
+ // NEVER UNASKED TWICE. Seen-ness is one localStorage key. Dismissing or
13
+ // finishing sets it; the options menu re-opens the tour on request. When
14
+ // storage is unreadable (a private window, blocked site data) the tour
15
+ // counts as seen, because showing it on every load is worse than not
16
+ // showing it — it is one click away in the options menu either way.
17
+ //
18
+ // Pure, so the content can be checked by a test for coverage and pointers.
19
+
20
+ import { BAR_KIND_NOTES, type BarKind } from "./view-model.ts";
21
+ import type { ViewMode } from "./composables/url-codec";
22
+
23
+ export const WALKTHROUGH_STORAGE_KEY = "beadcyte:walkthrough";
24
+
25
+ export interface WalkStep {
26
+ id: string;
27
+ title: string;
28
+ /** Plain sentences; the panel renders them as a paragraph. */
29
+ body: string;
30
+ /** Where else this is written down. Printed, so the reader can go there. */
31
+ alsoIn: string;
32
+ /** A view worth looking at while reading this step; offered, never forced. */
33
+ view?: ViewMode;
34
+ /** Render the bar-kind list under the body. */
35
+ kinds?: boolean;
36
+ }
37
+
38
+ export const WALKTHROUGH_STEPS: readonly WalkStep[] = [
39
+ {
40
+ id: "timeline",
41
+ title: "History and a projection on one timeline",
42
+ body:
43
+ "Left of the red rule is what happened: real started_at → closed_at bars, with a marker on the ones that shipped. Right of it is a projection. The bar kinds tell the two apart, and the footer under the Gantt counts them by kind:",
44
+ kinds: true,
45
+ alsoIn: "README › Gantt; the kind names in the Gantt footer explain themselves on hover",
46
+ view: "gantt",
47
+ },
48
+ {
49
+ id: "model",
50
+ title: "The future is a model, not a plan anyone made",
51
+ body:
52
+ "Every future bar comes from an estimator — the median of how long comparable shipped work actually took — fed to a greedy scheduler that gives each person their highest-priority ready bead within a calibrated work-in-progress cap. A hatched bar means the scheduler also guessed WHO does it. A finish that says “after” a date is a bound, not a projection: the plan ran past the window.",
53
+ alsoIn: "docs/model.md; hover Estimate, Starts, Projected finish and the inferred marker in the drawer",
54
+ view: "gantt",
55
+ },
56
+ {
57
+ id: "triage",
58
+ title: "Triage ranks the work by eight weighted signals",
59
+ body:
60
+ "The score is a weighted sum — PageRank and betweenness over the blocks graph, how many beads this one unblocks, priority, time to impact, urgency, risk and staleness — scaled so the largest value in your repo is 1. Open any bead and the drawer shows its score in the population, then the breakdown; every signal name explains what it measures.",
61
+ alsoIn: "docs/model.md › Triage; hover any signal name in the drawer",
62
+ },
63
+ {
64
+ id: "views",
65
+ title: "Five views, five questions",
66
+ body:
67
+ "Mine: what should I be doing. Gantt: when. Grid: browse the same set as cards. Table: compare and sort every derived number at once. Incytes: how the whole project is doing — ships per week and, beside them, what the weeks cost in seat hours and tokens, measured behind today and projected ahead (dulled) from the median cost of comparable shipped work; the only view that ignores the filters, and the controls dim to say so.",
68
+ alsoIn: "README › The tour",
69
+ view: "table",
70
+ },
71
+ {
72
+ id: "controls",
73
+ title: "Filters, window, highlight — and the keyboard",
74
+ body:
75
+ "Filters narrow which beads are in view; the window decides which dates; highlight paints blocked, over-cap and ready-now onto the Gantt and grid. A dimmed group is one the current view cannot act on. Press ? for the shortcuts: single letters, j and k walk the beads, and nothing fires while you are typing.",
76
+ alsoIn: "README › Keyboard; the ? overlay",
77
+ },
78
+ {
79
+ id: "done",
80
+ title: "That is the tour",
81
+ body:
82
+ "It will not appear again unless you ask: the options menu (gear, top right) re-opens it. Everything it said is also in the README, docs/model.md and the hovers.",
83
+ alsoIn: "Options › Walkthrough",
84
+ },
85
+ ];
86
+
87
+ /** The kinds in the order the timeline reads them, past to future. */
88
+ export const BAR_KIND_ORDER: readonly BarKind[] = [
89
+ "shipped",
90
+ "closedNoShip",
91
+ "inProgressLive",
92
+ "future",
93
+ "futureBacklog",
94
+ "unassigned",
95
+ "waitUnclaimed",
96
+ "waitReview",
97
+ ];
98
+
99
+ export function barKindNotes(): Array<{ kind: BarKind; note: string }> {
100
+ return BAR_KIND_ORDER.map((kind) => ({ kind, note: BAR_KIND_NOTES[kind] }));
101
+ }
102
+
103
+ interface StorageLike {
104
+ getItem(key: string): string | null;
105
+ setItem(key: string, value: string): void;
106
+ }
107
+
108
+ function storage(): StorageLike | null {
109
+ try {
110
+ return typeof localStorage !== "undefined" ? localStorage : null;
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ /** Has this browser seen (or dismissed) the tour? Unreadable storage counts as seen. */
117
+ export function walkthroughSeen(store: StorageLike | null = storage()): boolean {
118
+ if (!store) return true;
119
+ try {
120
+ return store.getItem(WALKTHROUGH_STORAGE_KEY) !== null;
121
+ } catch {
122
+ return true;
123
+ }
124
+ }
125
+
126
+ export function markWalkthroughSeen(store: StorageLike | null = storage(), at: Date = new Date()): void {
127
+ if (!store) return;
128
+ try {
129
+ store.setItem(WALKTHROUGH_STORAGE_KEY, at.toISOString());
130
+ } catch {
131
+ // Nothing to do: the tour stays re-openable and will show again next load.
132
+ }
133
+ }
@@ -0,0 +1,47 @@
1
+ // watchlist.ts — the personal watch list's storage format.
2
+ //
3
+ // Pure and localStorage-free so the format is testable; the store owns the
4
+ // reactive set and calls load/save at the edges.
5
+
6
+ /** Namespaced so it can't collide with the label-column width or anything
7
+ * else this app keeps per browser. */
8
+ export const WATCHLIST_STORAGE_KEY = "beadcyte:watchlist";
9
+
10
+ /**
11
+ * Parse a stored watch list. Anything malformed yields an empty list rather
12
+ * than throwing: this is a convenience feature, and a corrupt value must not
13
+ * take the app down on boot. Non-string entries and duplicates are dropped,
14
+ * and order is preserved so the hover list stays stable between sessions.
15
+ */
16
+ export function parseWatched(raw: string | null | undefined): string[] {
17
+ if (!raw) return [];
18
+ let parsed: unknown;
19
+ try {
20
+ parsed = JSON.parse(raw);
21
+ } catch {
22
+ return [];
23
+ }
24
+ if (!Array.isArray(parsed)) return [];
25
+ const out: string[] = [];
26
+ const seen = new Set<string>();
27
+ for (const v of parsed) {
28
+ if (typeof v !== "string") continue;
29
+ const id = v.trim();
30
+ if (!id || seen.has(id)) continue;
31
+ seen.add(id);
32
+ out.push(id);
33
+ }
34
+ return out;
35
+ }
36
+
37
+ export function serializeWatched(ids: readonly string[]): string {
38
+ return JSON.stringify(ids);
39
+ }
40
+
41
+ /**
42
+ * Add or remove an id, returning a new list. Newly watched ids go on the end
43
+ * so the list reads in the order things were added.
44
+ */
45
+ export function toggleWatched(ids: readonly string[], id: string): string[] {
46
+ return ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id];
47
+ }
@@ -0,0 +1,291 @@
1
+ // whatif.ts — the "diff bead" (bp-q56): a change set applied to a COPY of the
2
+ // beads and roster, re-scheduled by the same pipeline as the real plan, and
3
+ // diffed against it. Nothing here writes; confirming is the apply path
4
+ // (bp-2fq), and until then the change set exports as bd commands to paste.
5
+ //
6
+ // A what-if is a session, not a link: it is not carried in the URL.
7
+
8
+ import type { Bead, Roster } from "./store";
9
+ import type { Schedule } from "./view-model";
10
+ import type { Mutation } from "./mutations";
11
+
12
+ export type WhatIfChange =
13
+ | { kind: "reassign"; id: string; assignee: string | null }
14
+ | { kind: "priority"; id: string; priority: number }
15
+ | { kind: "spec-ready"; id: string; ready: boolean }
16
+ | { kind: "cap"; handle: string; cap: number }
17
+ | { kind: "idea"; id: string; title: string; type: string; priority: number; hours: number; assignee: string | null };
18
+
19
+ export const IDEA_PREFIX = "what-if-";
20
+
21
+ export function isHypothetical(id: string): boolean {
22
+ return id.startsWith(IDEA_PREFIX);
23
+ }
24
+
25
+ /** The key a change replaces: one change per target per kind. */
26
+ export function changeKey(c: WhatIfChange): string {
27
+ switch (c.kind) {
28
+ case "cap":
29
+ return `cap:${c.handle}`;
30
+ case "idea":
31
+ return `idea:${c.id}`;
32
+ default:
33
+ return `${c.kind}:${c.id}`;
34
+ }
35
+ }
36
+
37
+ /** Add or replace: a second reassign of the same bead replaces the first. */
38
+ export function upsertChange(changes: readonly WhatIfChange[], c: WhatIfChange): WhatIfChange[] {
39
+ const k = changeKey(c);
40
+ return [...changes.filter((x) => changeKey(x) !== k), c];
41
+ }
42
+
43
+ export function describeChange(c: WhatIfChange, titleOf: (id: string) => string | undefined): string {
44
+ const name = (id: string) => `${id}${titleOf(id) ? ` · ${titleOf(id)!.slice(0, 40)}` : ""}`;
45
+ switch (c.kind) {
46
+ case "reassign":
47
+ return `${name(c.id)} → ${c.assignee ?? "unassigned"}`;
48
+ case "priority":
49
+ return `${name(c.id)} → P${c.priority}`;
50
+ case "spec-ready":
51
+ return `${name(c.id)} → ${c.ready ? "spec:ready" : "not spec:ready"}`;
52
+ case "cap":
53
+ return `${c.handle}'s cap → ${c.cap}`;
54
+ case "idea":
55
+ return `idea: ${c.title} (${c.type}, P${c.priority}, ~${c.hours}h${c.assignee ? `, ${c.assignee}` : ""})`;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * The beads and roster the what-if schedules: deep enough copies that the
61
+ * real payload is never touched. Ideas become open beads with a hypothetical
62
+ * id and a size the estimator honours (metadata.whatif.hours).
63
+ */
64
+ export function applyChanges(
65
+ beads: readonly Bead[],
66
+ roster: Roster,
67
+ changes: readonly WhatIfChange[],
68
+ now: Date = new Date(),
69
+ ): { beads: Bead[]; roster: Roster } {
70
+ const byId = new Map<string, Bead>(beads.map((b) => [b.id, { ...b, labels: [...(b.labels ?? [])] }]));
71
+ const humans = roster.humans.map((h) => ({ ...h }));
72
+ for (const c of changes) {
73
+ switch (c.kind) {
74
+ case "reassign": {
75
+ const b = byId.get(c.id);
76
+ if (b) b.assignee = c.assignee;
77
+ break;
78
+ }
79
+ case "priority": {
80
+ const b = byId.get(c.id);
81
+ if (b) b.priority = c.priority;
82
+ break;
83
+ }
84
+ case "spec-ready": {
85
+ const b = byId.get(c.id);
86
+ if (!b) break;
87
+ const has = b.labels!.includes("spec:ready");
88
+ if (c.ready && !has) b.labels!.push("spec:ready");
89
+ if (!c.ready && has) b.labels = b.labels!.filter((l) => l !== "spec:ready");
90
+ break;
91
+ }
92
+ case "cap": {
93
+ // Authoritative: a what-if cap is not calibrated back (velocity.mjs
94
+ // honours cap_fixed), or the scenario could never be seen.
95
+ const h = humans.find((x) => x.handle === c.handle);
96
+ if (h) {
97
+ h.cap = c.cap;
98
+ h.cap_fixed = true;
99
+ } else humans.push({ handle: c.handle, cap: c.cap, cap_fixed: true });
100
+ break;
101
+ }
102
+ case "idea": {
103
+ const iso = now.toISOString();
104
+ byId.set(c.id, {
105
+ id: c.id,
106
+ title: `idea · ${c.title}`,
107
+ status: "open",
108
+ priority: c.priority,
109
+ issue_type: c.type,
110
+ assignee: c.assignee,
111
+ parent: null,
112
+ labels: ["spec:ready"],
113
+ started_at: null,
114
+ closed_at: null,
115
+ created_at: iso,
116
+ updated_at: iso,
117
+ dependencies: [],
118
+ metadata: { whatif: { hours: c.hours } },
119
+ hypothetical: true,
120
+ });
121
+ break;
122
+ }
123
+ }
124
+ }
125
+ return { beads: [...byId.values()], roster: { ...roster, humans } };
126
+ }
127
+
128
+ /** An idea's own size, in the estimator's units, or null for a real bead. */
129
+ export function ideaEstimateDays(bead: { metadata?: Record<string, unknown> | null }): number | null {
130
+ const w = (bead.metadata as { whatif?: { hours?: unknown } } | null | undefined)?.whatif;
131
+ return w && typeof w.hours === "number" && w.hours > 0 ? w.hours / 24 : null;
132
+ }
133
+
134
+ export interface MovedBead {
135
+ id: string;
136
+ title: string;
137
+ assigneeBefore: string | null;
138
+ assigneeAfter: string | null;
139
+ /** Landing, in days from today. */
140
+ landBefore: number;
141
+ landAfter: number;
142
+ /** Positive: lands later. */
143
+ deltaDays: number;
144
+ /** The work spans, for the Gantt's ghost bar. */
145
+ workBefore: { start: number; end: number };
146
+ workAfter: { start: number; end: number };
147
+ }
148
+
149
+ export interface FrontierRow {
150
+ handle: string;
151
+ before: number;
152
+ after: number;
153
+ }
154
+
155
+ export interface PlanDiff {
156
+ moved: MovedBead[];
157
+ /** Hypothetical ideas and where they land. */
158
+ added: Array<{ id: string; title: string; assignee: string | null; land: number | null }>;
159
+ frontier: FrontierRow[];
160
+ frontierDays: number;
161
+ /** Beads whose landing changed by less than the noise floor: not listed. */
162
+ unchanged: number;
163
+ }
164
+
165
+ type Entry = { start_day: number; end_day: number; work_start_day?: number; work_end_day?: number; assignee?: string; synthetic_assignee?: boolean };
166
+
167
+ /**
168
+ * What changed between two plans over the same (extended) bead set. A move
169
+ * is a landing shifted by at least a quarter day, or a change of hands.
170
+ */
171
+ export function diffPlans(
172
+ before: Schedule,
173
+ after: Schedule,
174
+ beadsAfter: readonly Bead[],
175
+ opts: { frontierDays?: number } = {},
176
+ ): PlanDiff {
177
+ const frontierDays = opts.frontierDays ?? 14;
178
+ const titleOf = new Map(beadsAfter.map((b) => [b.id, b.title] as const));
179
+ const moved: MovedBead[] = [];
180
+ const added: PlanDiff["added"] = [];
181
+ let unchanged = 0;
182
+ for (const b of beadsAfter) {
183
+ const a = after.get(b.id) as Entry | undefined;
184
+ if (isHypothetical(b.id)) {
185
+ added.push({ id: b.id, title: b.title, assignee: a?.assignee ?? b.assignee ?? null, land: a ? a.end_day : null });
186
+ continue;
187
+ }
188
+ const p = before.get(b.id) as Entry | undefined;
189
+ if (!p || !a) continue;
190
+ const delta = a.end_day - p.end_day;
191
+ const hands = (p.assignee ?? null) !== (a.assignee ?? null);
192
+ if (Math.abs(delta) < 0.25 && !hands) {
193
+ unchanged++;
194
+ continue;
195
+ }
196
+ moved.push({
197
+ id: b.id,
198
+ title: titleOf.get(b.id) ?? b.id,
199
+ assigneeBefore: p.assignee ?? null,
200
+ assigneeAfter: a.assignee ?? null,
201
+ landBefore: p.end_day,
202
+ landAfter: a.end_day,
203
+ deltaDays: delta,
204
+ workBefore: { start: p.work_start_day ?? p.start_day, end: p.work_end_day ?? p.end_day },
205
+ workAfter: { start: a.work_start_day ?? a.start_day, end: a.work_end_day ?? a.end_day },
206
+ });
207
+ }
208
+ moved.sort((x, y) => Math.abs(y.deltaDays) - Math.abs(x.deltaDays) || x.id.localeCompare(y.id));
209
+
210
+ // The frontier: how many beads land for each person within N days.
211
+ const count = (s: Schedule) => {
212
+ const m = new Map<string, number>();
213
+ for (const e of s.values() as Iterable<Entry>) {
214
+ if (!e.assignee) continue;
215
+ if (e.end_day <= frontierDays) m.set(e.assignee, (m.get(e.assignee) ?? 0) + 1);
216
+ }
217
+ return m;
218
+ };
219
+ const cb = count(before);
220
+ const ca = count(after);
221
+ const handles = [...new Set([...cb.keys(), ...ca.keys()])].sort();
222
+ const frontier = handles.map((handle) => ({ handle, before: cb.get(handle) ?? 0, after: ca.get(handle) ?? 0 }));
223
+ return { moved, added, frontier, frontierDays, unchanged };
224
+ }
225
+
226
+ /** "3d later" / "2d earlier" / "same day" for a delta. */
227
+ export function describeDelta(deltaDays: number): string {
228
+ const d = Math.abs(deltaDays);
229
+ const n = d >= 1 ? `${d.toFixed(d >= 10 ? 0 : 1)}d` : `${Math.round(d * 24)}h`;
230
+ if (d < 0.25) return "same day";
231
+ return deltaDays > 0 ? `${n} later` : `${n} earlier`;
232
+ }
233
+
234
+ /**
235
+ * The change set as bd commands, for confirming by hand until the apply path
236
+ * exists (bp-2fq). A cap is a roster.json edit, not a bd command, and says so.
237
+ */
238
+ export function whatIfCommands(changes: readonly WhatIfChange[]): string[] {
239
+ const out: string[] = [];
240
+ for (const c of changes) {
241
+ switch (c.kind) {
242
+ case "reassign":
243
+ out.push(c.assignee ? `bd update ${c.id} --assignee ${c.assignee}` : `bd update ${c.id} --assignee ""`);
244
+ break;
245
+ case "priority":
246
+ out.push(`bd update ${c.id} --priority ${c.priority}`);
247
+ break;
248
+ case "spec-ready":
249
+ out.push(`bd update ${c.id} ${c.ready ? "--add-label" : "--remove-label"} spec:ready`);
250
+ break;
251
+ case "idea":
252
+ out.push(`bd create ${JSON.stringify(c.title)} -t ${c.type} -p ${c.priority} -l spec:ready${c.assignee ? ` -a ${c.assignee}` : ""} # ~${c.hours}h`);
253
+ break;
254
+ case "cap":
255
+ out.push(`# .beadcyte/roster.json: set "cap": ${c.cap} on ${c.handle}`);
256
+ break;
257
+ }
258
+ }
259
+ return out;
260
+ }
261
+
262
+ /**
263
+ * The change set as whitelisted mutations for /api/mutate (bp-2fq), in
264
+ * order. A cap is not a bd fact — it lives in roster.json — so it is
265
+ * returned separately for the summary rather than silently dropped.
266
+ */
267
+ export function mutationsFor(changes: readonly WhatIfChange[]): { mutations: Mutation[]; skipped: WhatIfChange[] } {
268
+ const mutations: Mutation[] = [];
269
+ const skipped: WhatIfChange[] = [];
270
+ for (const c of changes) {
271
+ switch (c.kind) {
272
+ case "reassign":
273
+ if (c.assignee) mutations.push({ action: "assign", id: c.id, assignee: c.assignee });
274
+ else skipped.push(c); // bd has no whitelisted "unassign"; say so
275
+ break;
276
+ case "priority":
277
+ mutations.push({ action: "priority", id: c.id, priority: c.priority });
278
+ break;
279
+ case "spec-ready":
280
+ mutations.push({ action: c.ready ? "spec-ready" : "unspec-ready", id: c.id });
281
+ break;
282
+ case "idea":
283
+ mutations.push({ action: "create", id: "", title: c.title, type: c.type, priority: c.priority, labels: ["spec:ready"], ...(c.assignee ? { assignee: c.assignee } : {}) });
284
+ break;
285
+ case "cap":
286
+ skipped.push(c);
287
+ break;
288
+ }
289
+ }
290
+ return { mutations, skipped };
291
+ }
@@ -0,0 +1,73 @@
1
+ // window.ts — the time window the chart covers, and its presets.
2
+ //
3
+ // Extracted from store.ts so it can be tested. store.ts imports pinia and a
4
+ // dozen sibling modules with extensionless specifiers, which `node --test`
5
+ // cannot resolve, so nothing in it was reachable from a test — and the preset
6
+ // list turned out to be exactly the kind of thing that needs one: a duplicated
7
+ // copy of this mapping lived in ControlsPanel.vue and silently stopped
8
+ // agreeing the moment a seventh preset was added to one switch and not the
9
+ // other. One definition, one test over it.
10
+ //
11
+ // Pure: no store, no DOM.
12
+
13
+ import type { WindowSpec } from "./view-model";
14
+
15
+ export type WindowPreset =
16
+ | "7d"
17
+ | "30d"
18
+ | "90d"
19
+ | "180d"
20
+ | "future"
21
+ | "history"
22
+ | "all";
23
+
24
+ /**
25
+ * Presets in the order they appear in the control, tightest first.
26
+ *
27
+ * ±7d is the near-term view, and it is the only preset at which the
28
+ * estimator's sub-day spread is visible: at ±90d one day is about 6px of an
29
+ * 1100px plot, so every short bead renders at the Gantt's 2px minimum width
30
+ * and they all look alike. At ±7d a day is ~78px.
31
+ */
32
+ export const WINDOW_PRESETS: Array<{ id: WindowPreset; label: string }> = [
33
+ { id: "7d", label: "±7d" },
34
+ { id: "30d", label: "±30d" },
35
+ { id: "90d", label: "±90d" },
36
+ { id: "180d", label: "±180d" },
37
+ { id: "future", label: "future" },
38
+ { id: "history", label: "history" },
39
+ { id: "all", label: "all" },
40
+ ];
41
+
42
+ /**
43
+ * The window a preset means, relative to `asOf` (default today).
44
+ *
45
+ * `asOf` exists for tests: every preset except `all` is relative to now, so
46
+ * asserting anything about them against a moving clock is a coin flip.
47
+ */
48
+ export function windowFromPreset(
49
+ p: WindowPreset,
50
+ asOf: Date = new Date(),
51
+ ): WindowSpec {
52
+ const days = (n: number) => {
53
+ const d = new Date(asOf);
54
+ d.setDate(d.getDate() + n);
55
+ return d;
56
+ };
57
+ switch (p) {
58
+ case "7d":
59
+ return { from: days(-7), to: days(7) };
60
+ case "30d":
61
+ return { from: days(-30), to: days(30) };
62
+ case "90d":
63
+ return { from: days(-90), to: days(90) };
64
+ case "180d":
65
+ return { from: days(-180), to: days(180) };
66
+ case "future":
67
+ return { from: new Date(asOf), to: days(180) };
68
+ case "history":
69
+ return { from: days(-365), to: new Date(asOf) };
70
+ case "all":
71
+ return { from: null, to: null };
72
+ }
73
+ }
package/src/web/wip.ts ADDED
@@ -0,0 +1,83 @@
1
+ // wip.ts — work in flight against the calibrated cap, per person (bp-67g.29).
2
+ //
3
+ // The calibration strip used to say "cap 5→1 · 0.9/wk" per person: accurate,
4
+ // and silent about the thing that matters. On the reference project four of
5
+ // five people were over their calibrated cap by 5–13x, and the text could not
6
+ // say so. These rows put every person on ONE shared scale — per-row scales
7
+ // would make 13 and 3 look alike — with the cap as a notch on the measure and
8
+ // the run past it drawn at its real length.
9
+ //
10
+ // Nothing new is computed: in-flight is a count over the beads, and the cap,
11
+ // its pre-calibration value and the close-rate come off the calibrated roster
12
+ // the scheduler already built. Pure, so the shape is testable.
13
+
14
+ import type { Bead } from "./store";
15
+ import { IN_FLIGHT_STATUSES } from "./highlights.ts";
16
+
17
+ /** A roster human as the calibrated roster carries it (see velocity.mjs). */
18
+ export interface CalibratedHuman {
19
+ handle: string;
20
+ cap?: number;
21
+ _rawCap?: number;
22
+ _closesPerWeek?: number;
23
+ _hoursPerWeek?: number;
24
+ }
25
+
26
+ export interface WipRow {
27
+ handle: string;
28
+ inFlight: number;
29
+ cap: number;
30
+ /** The cap before calibration; equal to `cap` when nothing moved. */
31
+ rawCap: number;
32
+ /** In-flight past the cap, 0 when within it. */
33
+ over: number;
34
+ closesPerWeek: number;
35
+ hoursPerWeek: number;
36
+ }
37
+
38
+ export const DEFAULT_CAP = 5;
39
+
40
+ /**
41
+ * One row per roster human, worst ratio first so the people to talk to are
42
+ * at the top. A person with nothing in flight still gets a row: absence is
43
+ * part of the picture.
44
+ */
45
+ export function wipRows(beads: readonly Bead[], humans: readonly CalibratedHuman[]): WipRow[] {
46
+ const inFlight = new Map<string, number>();
47
+ for (const b of beads) {
48
+ if (!b.assignee || !IN_FLIGHT_STATUSES.includes(b.status)) continue;
49
+ inFlight.set(b.assignee, (inFlight.get(b.assignee) ?? 0) + 1);
50
+ }
51
+ return humans
52
+ .map((h) => {
53
+ const cap = h.cap ?? DEFAULT_CAP;
54
+ const n = inFlight.get(h.handle) ?? 0;
55
+ return {
56
+ handle: h.handle,
57
+ inFlight: n,
58
+ cap,
59
+ rawCap: h._rawCap ?? cap,
60
+ over: Math.max(0, n - cap),
61
+ closesPerWeek: h._closesPerWeek ?? 0,
62
+ hoursPerWeek: h._hoursPerWeek ?? 0,
63
+ };
64
+ })
65
+ .sort((a, b) => b.inFlight / b.cap - a.inFlight / a.cap || a.handle.localeCompare(b.handle));
66
+ }
67
+
68
+ /** The scale every row shares: the largest in-flight or cap across everyone, never below 1. */
69
+ export function wipScaleMax(rows: readonly WipRow[]): number {
70
+ return Math.max(1, ...rows.map((r) => Math.max(r.inFlight, r.cap)));
71
+ }
72
+
73
+ /** "cap 5→1" when calibration moved it, "cap 5" when it did not. */
74
+ export function capBlurb(row: WipRow): string {
75
+ return row.rawCap !== row.cap ? `cap ${row.rawCap}→${row.cap}` : `cap ${row.cap}`;
76
+ }
77
+
78
+ /** "0.9/wk · 12h/wk", or "no ships" when the window saw none. */
79
+ export function rateBlurb(row: WipRow): string {
80
+ if (row.closesPerWeek <= 0) return "no ships";
81
+ const hours = row.hoursPerWeek > 0 ? ` · ${row.hoursPerWeek.toFixed(0)}h/wk` : "";
82
+ return `${row.closesPerWeek.toFixed(1)}/wk${hours}`;
83
+ }