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,717 @@
1
+ // view-model.ts — pure adapter: bd JSON → { rows, bars, groups }.
2
+ //
3
+ // Mirrors the visibility, grouping, sorting, truncation, and bar-generation
4
+ // rules used by the CLI SVG emitter (src/cli.mjs), so the interactive Gantt
5
+ // renders the same picture the static SVG would. All coordinates are in
6
+ // "days from window start" — pixel math is the renderer's problem, and the
7
+ // scheduler entries stay in the same shape scheduleGreedy() returns.
8
+ //
9
+ // Deliberately no DOM/DDV knowledge, no reactivity, no I/O. Consumed by the
10
+ // Pinia store (src/web/store.ts) as a computed derivation of raw beads +
11
+ // filter/group/window state.
12
+
13
+ import type { Bead } from "./store";
14
+ import { isShipped } from "../ship.mjs";
15
+ import { hasOpenBlocker } from "./highlights.ts";
16
+
17
+ // ── shapes ────────────────────────────────────────────────────────────────
18
+
19
+ export interface ScheduleEntry {
20
+ id: string;
21
+ /** Whole span: the start of any wait before the work, to the landing. */
22
+ start_day: number;
23
+ end_day: number;
24
+ /** The work itself (bp-m0g). Absent on entries from an older scheduler. */
25
+ work_start_day?: number;
26
+ work_end_day?: number;
27
+ wait_before_days?: number;
28
+ wait_after_days?: number;
29
+ backlog?: boolean;
30
+ assignee?: string;
31
+ synthetic_assignee?: boolean;
32
+ unassigned?: boolean;
33
+ in_progress_at_start?: boolean;
34
+ truncated?: boolean;
35
+ }
36
+
37
+ export type Schedule = Map<string, ScheduleEntry>;
38
+
39
+ export type GroupBy =
40
+ | "epic"
41
+ | "assignee"
42
+ | "seat"
43
+ | "track"
44
+ | "status"
45
+ | "priority"
46
+ /** One flat list. Still a group internally ("__all") so every consumer
47
+ * keeps one code path; renderers drop the band. */
48
+ | "none";
49
+
50
+ export interface Filters {
51
+ types?: string[] | null;
52
+ priorities?: number[] | null;
53
+ labels?: string[] | null; // ALL of these must be present on the bead
54
+ assignees?: string[] | null;
55
+ /**
56
+ * When set, keep only these beads — plus the children of any watched
57
+ * epic, since watching an epic means "this piece of work", and an epic
58
+ * itself is never a row. Null/absent means no watch filtering.
59
+ */
60
+ watchedIds?: string[] | null;
61
+ /**
62
+ * The statuses in view. This is the ONLY control over status.
63
+ *
64
+ * There used to be `includeDeferred` and `showClosed` booleans alongside
65
+ * it, applied first and unconditionally, so asking for `statuses:
66
+ * ["closed"]` with the toggle off returned nothing and said nothing about
67
+ * why (bp-67g.40). Two controls for one axis, one of them invisible in its
68
+ * effect, and no combination the toggles enabled that this could not
69
+ * express on its own.
70
+ *
71
+ * Null or empty means every status — including closed and deferred. The
72
+ * app pre-selects everything except those two on first load, so the
73
+ * default view is unchanged but its reason is visible in the panel rather
74
+ * than being an invisible default.
75
+ */
76
+ statuses?: string[] | null;
77
+ /**
78
+ * Keep only beads with no open `blocks` dependency (bp-egy). The check is
79
+ * highlights.ts's hasOpenBlocker, the same one the blocked overlay uses,
80
+ * so "unblocked" here and "blocked" there can never disagree.
81
+ */
82
+ unblocked?: boolean | null;
83
+ }
84
+
85
+ export interface WindowSpec {
86
+ from: Date | null; // null = derive from data (all history)
87
+ to: Date | null; // null = derive from schedule horizon
88
+ }
89
+
90
+ export interface Row {
91
+ index: number;
92
+ beadId: string;
93
+ title: string;
94
+ priority: number;
95
+ status: string;
96
+ issueType: string;
97
+ /** Resolved the same way groupId's "assignee" case does: the bead's own
98
+ * assignee, else the scheduler's, else null for unassigned. */
99
+ assignee: string | null;
100
+ /** The scheduler inferred this assignment rather than reading it off the
101
+ * bead — a guess, and never to be presented as recorded fact. */
102
+ syntheticAssignee: boolean;
103
+ groupId: string;
104
+ }
105
+
106
+ export type BarKind =
107
+ | "shipped"
108
+ | "closedNoShip"
109
+ | "inProgressLive" // past-portion of a bead in progress at t=0
110
+ | "future"
111
+ | "futureBacklog"
112
+ | "unassigned" // future from the unassigned pool
113
+ | "waitUnclaimed" // the wait clock: before a pool pick's work (bp-6cg)
114
+ | "waitReview"; // the wait clock: after the work, before it lands
115
+
116
+ /** The wait clock's kinds: drawn dulled and truncated, not counted as bars. */
117
+ export function isWaitKind(kind: BarKind): boolean {
118
+ return kind === "waitUnclaimed" || kind === "waitReview";
119
+ }
120
+
121
+ /**
122
+ * What each kind means, in the words of barsFor() below (bp-67g.48). Shown
123
+ * on hover in the Gantt footer and listed by the first-run walkthrough, so a
124
+ * kind cannot be drawn without being explained, or explained two ways.
125
+ */
126
+ export const BAR_KIND_NOTES: Record<BarKind, string> = {
127
+ shipped: "Closed with ship evidence — a merge request or a commit on the bead. Real started_at → closed_at, with a ship marker.",
128
+ closedNoShip: "Closed with no ship evidence recorded: an administrative close, or work that shipped without saying so. Drawn dim.",
129
+ inProgressLive: "The part of an in-progress bead already spent: from its recorded start to today. What follows it, right of today, is the projection.",
130
+ future: "Projected: the scheduler's plan for ready work, its length the estimator's median for comparable shipped work.",
131
+ futureBacklog: "Projected, but backlog: not spec:ready, so scheduled after the ready work and drawn faint.",
132
+ unassigned: "Projected from the unassigned pool: nobody is on it, so the scheduler shows it where a free slot would take it.",
133
+ waitUnclaimed: "Waiting, before the work: this project's median time from filing to someone starting, added when the scheduler routes an unassigned bead. Drawn dull and cut short — its length is not to scale; the landing date is.",
134
+ waitReview: "Waiting, after the work: this project's median review time (or its lead-time floor where none is measured) before the bead lands. Drawn dull and cut short at the landing — not to scale; the date is.",
135
+ };
136
+
137
+ export interface Bar {
138
+ rowIndex: number;
139
+ beadId: string;
140
+ startDay: number; // days from window start (t0)
141
+ endDay: number;
142
+ kind: BarKind;
143
+ synthetic?: boolean; // model-inferred assignee
144
+ shipMarker?: boolean;
145
+ }
146
+
147
+ export interface Group {
148
+ id: string;
149
+ label: string;
150
+ rowCount: number;
151
+ truncatedCount: number;
152
+ /**
153
+ * How many of the rows shown carry an assignee the scheduler inferred
154
+ * rather than one recorded on the bead. Counted over the rows actually
155
+ * displayed, like rowCount, so "7 beads · 2 inferred" describes the lane
156
+ * the reader is looking at. A lane where this is most of rowCount is the
157
+ * model's plan for someone, not their commitments (bp-67g.49).
158
+ */
159
+ syntheticCount: number;
160
+ /**
161
+ * Progress across the WHOLE group population (all children, before any
162
+ * filter or window clipping — so hiding closed beads doesn't drop the
163
+ * denominator). Renderers use this for a group-header progress chip.
164
+ */
165
+ progress: {
166
+ closed: number;
167
+ total: number;
168
+ };
169
+ }
170
+
171
+ export interface ViewModelMeta {
172
+ t0: Date;
173
+ t1: Date;
174
+ totalDays: number;
175
+ todayDay: number;
176
+ visibleCount: number;
177
+ }
178
+
179
+ export interface ViewModel {
180
+ rows: Row[];
181
+ bars: Bar[];
182
+ groups: Group[];
183
+ meta: ViewModelMeta;
184
+ }
185
+
186
+ export interface AssembleOptions {
187
+ groupBy: GroupBy;
188
+ filters: Filters;
189
+ window: WindowSpec;
190
+ maxPerGroup?: number; // default 20; Infinity disables truncation
191
+ /**
192
+ * Group ids the caller has explicitly expanded. Truncation is lifted for
193
+ * these groups — all rows appear regardless of maxPerGroup. Empty by
194
+ * default so callers who don't wire an expand UI keep the old behavior.
195
+ */
196
+ expandedGroups?: Set<string>;
197
+ }
198
+
199
+ // ── main entry point ──────────────────────────────────────────────────────
200
+
201
+ /**
202
+ * Assemble the reactive view model. Pure, O(n log n) in bead count — the log
203
+ * factor is the per-group sort. Runs comfortably under 50ms for ~500 beads.
204
+ */
205
+ export function assembleViewModel(
206
+ beads: Bead[],
207
+ schedule: Schedule,
208
+ opts: AssembleOptions,
209
+ ): ViewModel {
210
+ const filters = opts.filters ?? {};
211
+ const maxPerGroup = opts.maxPerGroup ?? 20;
212
+
213
+ const today = startOfDay(new Date());
214
+ const { t0, t1 } = resolveWindow(opts.window, beads, schedule, today);
215
+ const totalDays = daysBetween(t0, t1);
216
+ const todayDay = daysBetween(t0, today);
217
+ const byId = new Map(beads.map((b) => [b.id, b] as const));
218
+
219
+ // Progress totals per group id, computed BEFORE filter/window clipping.
220
+ // Denominator stays honest when the user hides closed beads or narrows
221
+ // the window — the chip in the group header still shows the true state.
222
+ const progressByGroup = new Map<string, { closed: number; total: number }>();
223
+ const bumpProgress = (gid: string, isClosed: boolean) => {
224
+ let p = progressByGroup.get(gid);
225
+ if (!p) {
226
+ p = { closed: 0, total: 0 };
227
+ progressByGroup.set(gid, p);
228
+ }
229
+ p.total++;
230
+ if (isClosed) p.closed++;
231
+ };
232
+ for (const b of beads) {
233
+ if (b.issue_type === "epic") continue;
234
+ const gid = groupId(b, opts.groupBy, schedule.get(b.id));
235
+ bumpProgress(gid, b.status === "closed");
236
+ }
237
+
238
+ // 1. Visibility — same rules as cli.mjs:117-144. Epics are group headers,
239
+ // not rows. Filter passes come first (cheap), then window-clipping.
240
+ const visible: Bead[] = [];
241
+ for (const b of beads) {
242
+ if (b.issue_type === "epic") continue;
243
+ if (!passesFilters(b, filters, schedule.get(b.id), byId)) continue;
244
+
245
+ if (b.status === "closed") {
246
+ if (!b.closed_at) continue;
247
+ const closedDay = daysBetween(t0, new Date(b.closed_at));
248
+ if (closedDay < 0 || closedDay > totalDays) continue;
249
+ visible.push(b);
250
+ continue;
251
+ }
252
+
253
+ const sched = schedule.get(b.id);
254
+ if (sched) {
255
+ const s = todayDay + sched.start_day;
256
+ const e = todayDay + sched.end_day;
257
+ if (e < 0 || s > totalDays) continue;
258
+ visible.push(b);
259
+ continue;
260
+ }
261
+ // No schedule entry, not closed: appears at horizon-end. Deferred beads
262
+ // land here whenever `deferred` is in the status selection — the
263
+ // scheduler skips them, so there is no schedule entry to window-check.
264
+ visible.push(b);
265
+ }
266
+
267
+ // 2. Group visible beads.
268
+ const groupBuckets = new Map<string, Bead[]>();
269
+ const groupIdFor = (b: Bead) => groupId(b, opts.groupBy, schedule.get(b.id));
270
+ for (const b of visible) {
271
+ const g = groupIdFor(b);
272
+ let arr = groupBuckets.get(g);
273
+ if (!arr) {
274
+ arr = [];
275
+ groupBuckets.set(g, arr);
276
+ }
277
+ arr.push(b);
278
+ }
279
+
280
+ // 3. Order groups: real IDs first (sorted), synthetic __placeholders last.
281
+ const groupOrder = [...groupBuckets.keys()].sort((a, b) => {
282
+ const aFake = a.startsWith("__");
283
+ const bFake = b.startsWith("__");
284
+ if (aFake !== bFake) return aFake ? 1 : -1;
285
+ return a.localeCompare(b);
286
+ });
287
+
288
+ // 4. Sort within group by start-day, then priority, then id (stable-ish).
289
+ const startDayOf = (b: Bead): number => {
290
+ if (b.status === "closed") {
291
+ const t = b.started_at ?? b.closed_at;
292
+ return t ? daysBetween(t0, new Date(t)) : totalDays + 999;
293
+ }
294
+ const s = schedule.get(b.id);
295
+ if (s) return todayDay + s.start_day;
296
+ return totalDays + 999;
297
+ };
298
+ for (const arr of groupBuckets.values()) {
299
+ arr.sort(
300
+ (a, b) =>
301
+ startDayOf(a) - startDayOf(b) ||
302
+ (a.priority ?? 4) - (b.priority ?? 4) ||
303
+ a.id.localeCompare(b.id),
304
+ );
305
+ }
306
+
307
+ // 5. Truncate: keep past-shipped + in-flight + spec:ready before backlog.
308
+ // Groups the caller has explicitly expanded skip this — the user has
309
+ // asked to see everything.
310
+ const truncatedBy = new Map<string, number>();
311
+ const expanded = opts.expandedGroups ?? new Set<string>();
312
+ if (Number.isFinite(maxPerGroup)) {
313
+ for (const [g, arr] of groupBuckets) {
314
+ if (expanded.has(g)) continue;
315
+ if (arr.length <= maxPerGroup) continue;
316
+ const rank = (b: Bead): number => {
317
+ if (b.status === "closed") return 0;
318
+ if (b.status === "in_progress" || b.status === "in_review") return 1;
319
+ if ((b.labels ?? []).includes("spec:ready")) return 2;
320
+ return 3;
321
+ };
322
+ arr.sort(
323
+ (a, b) => rank(a) - rank(b) || startDayOf(a) - startDayOf(b),
324
+ );
325
+ const kept = arr.slice(0, maxPerGroup);
326
+ truncatedBy.set(g, arr.length - maxPerGroup);
327
+ kept.sort(
328
+ (a, b) =>
329
+ startDayOf(a) - startDayOf(b) ||
330
+ (a.priority ?? 4) - (b.priority ?? 4) ||
331
+ a.id.localeCompare(b.id),
332
+ );
333
+ groupBuckets.set(g, kept);
334
+ }
335
+ }
336
+
337
+ // 6. Materialize rows + bars in group order.
338
+ const rows: Row[] = [];
339
+ const bars: Bar[] = [];
340
+ const groups: Group[] = [];
341
+
342
+ for (const gid of groupOrder) {
343
+ const items = groupBuckets.get(gid) ?? [];
344
+ let syntheticCount = 0;
345
+ for (const b of items) {
346
+ const rowIndex = rows.length;
347
+ const sched = schedule.get(b.id);
348
+ const assigned = resolveAssignee(b, sched);
349
+ if (assigned.synthetic) syntheticCount++;
350
+ rows.push({
351
+ index: rowIndex,
352
+ beadId: b.id,
353
+ title: b.title,
354
+ priority: b.priority ?? 4,
355
+ status: b.status,
356
+ issueType: b.issue_type,
357
+ assignee: assigned.handle,
358
+ syntheticAssignee: assigned.synthetic,
359
+ groupId: gid,
360
+ });
361
+ for (const bar of barsFor(b, sched, t0, todayDay, totalDays)) {
362
+ bars.push({ ...bar, rowIndex, beadId: b.id });
363
+ }
364
+ }
365
+ groups.push({
366
+ id: gid,
367
+ label: groupLabel(gid, opts.groupBy, byId),
368
+ rowCount: items.length,
369
+ truncatedCount: truncatedBy.get(gid) ?? 0,
370
+ syntheticCount,
371
+ progress: progressByGroup.get(gid) ?? { closed: 0, total: 0 },
372
+ });
373
+ }
374
+
375
+ return {
376
+ rows,
377
+ bars,
378
+ groups,
379
+ meta: { t0, t1, totalDays, todayDay, visibleCount: rows.length },
380
+ };
381
+ }
382
+
383
+ // ── window resolution ─────────────────────────────────────────────────────
384
+
385
+ function resolveWindow(
386
+ w: WindowSpec,
387
+ beads: Bead[],
388
+ schedule: Schedule,
389
+ today: Date,
390
+ ): { t0: Date; t1: Date } {
391
+ let t0 = w.from ? startOfDay(w.from) : null;
392
+ let t1 = w.to ? startOfDay(w.to) : null;
393
+ if (t0 && t1) return { t0, t1 };
394
+
395
+ // Derive from data. Scan for min started_at / max end.
396
+ let minMs = today.getTime();
397
+ let maxMs = today.getTime();
398
+ for (const b of beads) {
399
+ if (b.started_at) minMs = Math.min(minMs, +new Date(b.started_at));
400
+ if (b.closed_at) maxMs = Math.max(maxMs, +new Date(b.closed_at));
401
+ }
402
+ for (const s of schedule.values()) {
403
+ const end = today.getTime() + s.end_day * 86_400_000;
404
+ maxMs = Math.max(maxMs, end);
405
+ }
406
+ if (!t0) t0 = startOfDay(new Date(minMs));
407
+ if (!t1) t1 = startOfDay(new Date(maxMs));
408
+ // Guarantee t1 > t0
409
+ if (t1.getTime() <= t0.getTime()) t1 = addDays(t0, 1);
410
+ return { t0, t1 };
411
+ }
412
+
413
+ // ── filters ───────────────────────────────────────────────────────────────
414
+
415
+ function passesFilters(
416
+ b: Bead,
417
+ f: Filters,
418
+ sched: ScheduleEntry | undefined,
419
+ byId: Map<string, Bead> | null,
420
+ ): boolean {
421
+ if (f.unblocked && byId && hasOpenBlocker(b, byId)) return false;
422
+ if (f.types && f.types.length && !f.types.includes(b.issue_type))
423
+ return false;
424
+ if (
425
+ f.priorities &&
426
+ f.priorities.length &&
427
+ !f.priorities.includes(b.priority ?? 4)
428
+ )
429
+ return false;
430
+ if (f.statuses && f.statuses.length && !f.statuses.includes(b.status))
431
+ return false;
432
+ if (f.assignees && f.assignees.length) {
433
+ if (!f.assignees.includes(assigneeKey(b, sched))) return false;
434
+ }
435
+ if (f.watchedIds) {
436
+ const w = new Set(f.watchedIds);
437
+ if (!w.has(b.id) && !(b.parent && w.has(b.parent))) return false;
438
+ }
439
+ if (f.labels && f.labels.length) {
440
+ const bl = new Set(b.labels ?? []);
441
+ for (const l of f.labels) if (!bl.has(l)) return false;
442
+ }
443
+ return true;
444
+ }
445
+
446
+ // ── assignee resolution ───────────────────────────────────────────────────
447
+
448
+ /** Group/filter key for a bead with nobody on it. */
449
+ export const UNASSIGNED = "__unassigned";
450
+
451
+ export interface ResolvedAssignee {
452
+ handle: string | null;
453
+ /** The scheduler inferred this rather than reading it off the bead. */
454
+ synthetic: boolean;
455
+ }
456
+
457
+ /**
458
+ * What a synthetic assignment is, for the marker beside one (bp-67g.47).
459
+ * One sentence in one place, next to the resolver that produces the flag.
460
+ */
461
+ export const SYNTHETIC_ASSIGNEE_NOTE =
462
+ "Nobody is recorded on this bead. The scheduler gave it to this person to plan a lane for it — preferring someone whose roster affinities (labels, then areas) match, then anyone with a free slot — and marked the assignment synthetic. The model's guess, not a decision anyone made.";
463
+
464
+ /**
465
+ * THE definition of a bead's assignee, for every consumer.
466
+ *
467
+ * Grouping, filtering, the filter's option list and the grid card chip must
468
+ * all agree, or the same bead lands in one person's group while the filter
469
+ * for that person hides it. They used to disagree: everything resolved
470
+ * through the scheduler except the filter, which read b.assignee alone — and
471
+ * since most beads carry no recorded assignee, that hid nearly all of a
472
+ * person's work and dumped it under "(unassigned)" instead.
473
+ */
474
+ export function resolveAssignee(
475
+ b: Bead,
476
+ sched: ScheduleEntry | undefined,
477
+ ): ResolvedAssignee {
478
+ if (b.assignee) return { handle: b.assignee, synthetic: false };
479
+ if (sched?.assignee) {
480
+ return { handle: sched.assignee, synthetic: !!sched.synthetic_assignee };
481
+ }
482
+ return { handle: null, synthetic: false };
483
+ }
484
+
485
+ /** resolveAssignee as a group/filter key, with the unassigned placeholder. */
486
+ export function assigneeKey(
487
+ b: Bead,
488
+ sched: ScheduleEntry | undefined,
489
+ ): string {
490
+ return resolveAssignee(b, sched).handle ?? UNASSIGNED;
491
+ }
492
+
493
+ // ── grouping ──────────────────────────────────────────────────────────────
494
+
495
+ function groupId(
496
+ b: Bead,
497
+ mode: GroupBy,
498
+ sched: ScheduleEntry | undefined,
499
+ ): string {
500
+ switch (mode) {
501
+ case "epic":
502
+ return b.parent || "__no_epic";
503
+ case "assignee":
504
+ return assigneeKey(b, sched);
505
+ case "seat": {
506
+ const s = (b.labels ?? []).find((l) => l.startsWith("seat::"));
507
+ return s ?? "__no_seat";
508
+ }
509
+ case "track": {
510
+ const t = (b.labels ?? []).find((l) => l.startsWith("track::"));
511
+ return t ?? "__no_track";
512
+ }
513
+ case "status":
514
+ return b.status || "__unknown_status";
515
+ case "priority":
516
+ return `P${b.priority ?? 4}`;
517
+ case "none":
518
+ default:
519
+ return "__all";
520
+ }
521
+ }
522
+
523
+ function groupLabel(
524
+ id: string,
525
+ mode: GroupBy,
526
+ byId: Map<string, Bead>,
527
+ ): string {
528
+ const placeholders: Record<string, string> = {
529
+ __no_epic: "(no epic)",
530
+ __no_seat: "(no seat)",
531
+ __no_track: "(no track)",
532
+ __unassigned: "(unassigned)",
533
+ __unknown_status: "(unknown status)",
534
+ __all: "(all)",
535
+ };
536
+ if (placeholders[id]) return placeholders[id];
537
+ if (mode === "epic") {
538
+ const epic = byId.get(id);
539
+ return epic ? `${id} · ${truncateTitle(epic.title, 44)}` : id;
540
+ }
541
+ return id;
542
+ }
543
+
544
+ function truncateTitle(s: string, n: number): string {
545
+ if (!s) return "";
546
+ return s.length > n ? s.slice(0, n - 1) + "…" : s;
547
+ }
548
+
549
+ // ── bars ──────────────────────────────────────────────────────────────────
550
+
551
+ interface RawBar {
552
+ startDay: number;
553
+ endDay: number;
554
+ kind: BarKind;
555
+ synthetic?: boolean;
556
+ shipMarker?: boolean;
557
+ }
558
+
559
+ function barsFor(
560
+ b: Bead,
561
+ sched: ScheduleEntry | undefined,
562
+ t0: Date,
563
+ todayDay: number,
564
+ totalDays: number,
565
+ ): RawBar[] {
566
+ // Closed bars are the measured span, started_at → closed_at, in fractional
567
+ // days (bp-vsr). They used to be rounded to whole days with a one-day
568
+ // minimum, which said "a day" about work that took seventeen minutes and
569
+ // made a two-hour bead and a two-day bead the same width. There is no
570
+ // floor in the data any more: a bar's minimum WIDTH is a rendering matter
571
+ // (a few pixels, in BeadsGantt) and shrinks as the viewport zooms in,
572
+ // while the ship marker is what says "this shipped" at any zoom.
573
+ if (b.status === "closed") {
574
+ if (!b.closed_at) return [];
575
+ const start = b.started_at ? new Date(b.started_at) : new Date(b.closed_at);
576
+ const end = new Date(b.closed_at);
577
+ const s = fractionalDaysBetween(t0, start);
578
+ const e = Math.max(fractionalDaysBetween(t0, end), s);
579
+ const sc = clamp(s, 0, totalDays);
580
+ const ec = clamp(e, 0, totalDays);
581
+ const shipped = isShipped(b);
582
+ return [
583
+ {
584
+ startDay: sc,
585
+ endDay: ec,
586
+ kind: shipped ? "shipped" : "closedNoShip",
587
+ shipMarker: shipped,
588
+ },
589
+ ];
590
+ }
591
+
592
+ if (!sched) return [];
593
+
594
+ // The bar is the WORK (bp-m0g): the bright part. The waits around it set
595
+ // the landing date (end_day) and are their own bars (bp-6cg), drawn dulled
596
+ // and truncated by the Gantt so the picture is mostly cost and the landing
597
+ // end is still where it lands.
598
+ const startDay = clamp(todayDay + (sched.work_start_day ?? sched.start_day), 0, totalDays);
599
+ const endDay = clamp(todayDay + (sched.work_end_day ?? sched.end_day), 0, totalDays);
600
+ const kindFuture: BarKind = sched.unassigned
601
+ ? "unassigned"
602
+ : sched.backlog
603
+ ? "futureBacklog"
604
+ : "future";
605
+ const waits: RawBar[] = [];
606
+ if (sched.work_start_day !== undefined && sched.work_start_day - sched.start_day > 1e-6) {
607
+ waits.push({
608
+ startDay: clamp(todayDay + sched.start_day, 0, totalDays),
609
+ endDay: startDay,
610
+ kind: "waitUnclaimed",
611
+ synthetic: sched.synthetic_assignee,
612
+ });
613
+ }
614
+ if (sched.work_end_day !== undefined && sched.end_day - sched.work_end_day > 1e-6) {
615
+ waits.push({
616
+ startDay: endDay,
617
+ endDay: clamp(todayDay + sched.end_day, 0, totalDays),
618
+ kind: "waitReview",
619
+ synthetic: sched.synthetic_assignee,
620
+ });
621
+ }
622
+
623
+ // Projected spans are the scheduler's: whole-day starts (it simulates a
624
+ // day at a time, see docs/model.md) and the estimator's fractional length.
625
+ // No half-day floor here either, for the same reason as above.
626
+ if (sched.in_progress_at_start && startDay < todayDay) {
627
+ // Split into a live-past bar + a projected-future bar.
628
+ return [
629
+ {
630
+ startDay,
631
+ endDay: todayDay,
632
+ kind: "inProgressLive",
633
+ synthetic: sched.synthetic_assignee,
634
+ },
635
+ {
636
+ startDay: todayDay,
637
+ endDay: Math.max(endDay, todayDay),
638
+ kind: kindFuture,
639
+ synthetic: sched.synthetic_assignee,
640
+ },
641
+ ...waits,
642
+ ];
643
+ }
644
+ return [
645
+ {
646
+ startDay,
647
+ endDay: Math.max(endDay, startDay),
648
+ kind: kindFuture,
649
+ synthetic: sched.synthetic_assignee,
650
+ },
651
+ ...waits,
652
+ ];
653
+ }
654
+
655
+ // ── date helpers (duplicated from cli.mjs on purpose — pure module) ───────
656
+
657
+ function startOfDay(d: Date): Date {
658
+ const x = new Date(d);
659
+ x.setHours(0, 0, 0, 0);
660
+ return x;
661
+ }
662
+ function addDays(d: Date, n: number): Date {
663
+ const x = new Date(d);
664
+ x.setDate(x.getDate() + n);
665
+ return startOfDay(x);
666
+ }
667
+ function daysBetween(a: Date, b: Date): number {
668
+ return Math.round((b.getTime() - a.getTime()) / 86_400_000);
669
+ }
670
+ /** The same distance unrounded: for spans that are measured, not scheduled. */
671
+ function fractionalDaysBetween(a: Date, b: Date): number {
672
+ return (b.getTime() - a.getTime()) / 86_400_000;
673
+ }
674
+ function clamp(v: number, lo: number, hi: number): number {
675
+ return Math.max(lo, Math.min(hi, v));
676
+ }
677
+
678
+ // ── project-wide groups (bp-67g.36) ───────────────────────────────────────
679
+
680
+ /** A group as the project-wide Progress block needs it: who, and how far. */
681
+ export interface ProjectGroup {
682
+ id: string;
683
+ label: string;
684
+ progress: { closed: number; total: number };
685
+ }
686
+
687
+ /**
688
+ * Every group the current group-by produces, over EVERY non-epic bead, with
689
+ * its progress. No filter, no window.
690
+ *
691
+ * assembleViewModel counts progress before clipping too, but it only emits a
692
+ * group that still has a visible row — so on the incytes view a window that
693
+ * clipped every bead of a group made that group vanish from Progress while
694
+ * the counts of its neighbours stayed project-wide. Two scopes on one panel.
695
+ * This is the single-scope source: the incytes Progress block reads it, and
696
+ * filters and window do not reach it at all.
697
+ */
698
+ export function projectGroups(
699
+ beads: readonly Bead[],
700
+ schedule: Schedule,
701
+ groupBy: GroupBy,
702
+ ): ProjectGroup[] {
703
+ const byId = new Map(beads.map((b) => [b.id, b] as const));
704
+ const out = new Map<string, ProjectGroup>();
705
+ for (const b of beads) {
706
+ if (b.issue_type === "epic") continue;
707
+ const gid = groupId(b, groupBy, schedule.get(b.id));
708
+ let g = out.get(gid);
709
+ if (!g) {
710
+ g = { id: gid, label: groupLabel(gid, groupBy, byId), progress: { closed: 0, total: 0 } };
711
+ out.set(gid, g);
712
+ }
713
+ g.progress.total++;
714
+ if (b.status === "closed") g.progress.closed++;
715
+ }
716
+ return [...out.values()];
717
+ }