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,1378 @@
1
+ // The one Pinia store. Holds the raw API payload plus the reactive controls
2
+ // state (filters, window, group mode) that the view-model derives against.
3
+ // Later beads mount their own controls-panel state (bp-fdl), URL round-trip
4
+ // (bp-cel), and live refresh polling (bp-1kh) into the same fields here.
5
+
6
+ import { defineStore } from "pinia";
7
+ import {
8
+ assembleViewModel,
9
+ type Filters,
10
+ type GroupBy,
11
+ type Schedule,
12
+ type ScheduleEntry,
13
+ type ViewModel,
14
+ type WindowSpec,
15
+ projectGroups,
16
+ } from "./view-model";
17
+ import type { ViewMode } from "./composables/url-codec";
18
+ import {
19
+ ACTIVE_PROJECT_STORAGE_KEY,
20
+ PROJECTS_STORAGE_KEY,
21
+ addProject,
22
+ moveProject,
23
+ parseProjects,
24
+ removeProject,
25
+ renameProject,
26
+ resolveActive,
27
+ serializeProjects,
28
+ unadmittedPaths,
29
+ type Project,
30
+ } from "./projects";
31
+ import {
32
+ DEFAULT_REFRESH_MS,
33
+ REFRESH_STORAGE_KEY,
34
+ parseRefreshMs,
35
+ } from "./refresh";
36
+ import {
37
+ WATCHLIST_STORAGE_KEY,
38
+ parseWatched,
39
+ serializeWatched,
40
+ toggleWatched,
41
+ } from "./watchlist";
42
+ import {
43
+ buildBeadDetail,
44
+ type BeadDetail,
45
+ type EstimateResult,
46
+ } from "./bead-detail";
47
+ import {
48
+ computeBlockedIds,
49
+ computeReadyNowIds,
50
+ computeOverCapAssignees,
51
+ } from "./highlights";
52
+ import {
53
+ CURRENT_USER_STORAGE_KEY,
54
+ SHORTCUT_IDS,
55
+ isShortcutActive,
56
+ shortcutFilters,
57
+ type Shortcut,
58
+ } from "./shortcuts";
59
+ import {
60
+ computeTriage,
61
+ scorePopulation,
62
+ signalPopulation,
63
+ type ScorePopulation,
64
+ type SignalPopulation,
65
+ type TriageResult,
66
+ } from "./triage";
67
+ import {
68
+ groupProgress,
69
+ shipTrend,
70
+ type GroupProgressRow,
71
+ type ShipTrend,
72
+ } from "./insights";
73
+ // The estimator + scheduler are shared with the CLI SVG mode — pure ES
74
+ // modules, no `bd` shell-out, safe to import into the browser bundle.
75
+ import { buildEstimator } from "../estimator.mjs";
76
+ import { scheduleGreedy } from "../scheduler.mjs";
77
+ import { stageWaits } from "../stage-waits.mjs";
78
+ import { computeVelocity, calibrateRoster } from "../velocity.mjs";
79
+
80
+ export interface Bead {
81
+ id: string;
82
+ title: string;
83
+ description?: string | null;
84
+ status: string;
85
+ priority: number | null;
86
+ issue_type: string;
87
+ assignee: string | null;
88
+ parent: string | null;
89
+ labels: string[] | null;
90
+ started_at: string | null;
91
+ closed_at?: string | null;
92
+ created_at: string;
93
+ updated_at: string;
94
+ dependencies: Array<{
95
+ type: string;
96
+ depends_on_id: string;
97
+ }> | null;
98
+ /** Present in `bd list --json`; feeds triage's activity-churn signal. */
99
+ comment_count?: number | null;
100
+ metadata?: Record<string, unknown> | null;
101
+ /**
102
+ * Added by the server from its background walk of `bd history`
103
+ * (bp-67g.57): when the bead first entered in_review, or null when it
104
+ * never did. Absent until the walk has reached the bead.
105
+ */
106
+ history?: { in_review_at: string | null; assigned_at?: string | null; walked_at?: string | null } | null;
107
+ /** A what-if idea (bp-q56): exists only in the what-if copy of the beads. */
108
+ hypothetical?: boolean;
109
+ }
110
+
111
+ export interface Roster {
112
+ cap?: number;
113
+ humans: Array<{
114
+ handle: string;
115
+ cap?: number;
116
+ /** Do not calibrate this cap (roster.json, or a what-if). */
117
+ cap_fixed?: boolean;
118
+ labels?: string[];
119
+ areas?: string[];
120
+ }>;
121
+ _derived?: boolean;
122
+ }
123
+
124
+ export interface Payload {
125
+ generated_at: string;
126
+ beads: Bead[];
127
+ roster: Roster;
128
+ }
129
+
130
+ interface ApiResponse {
131
+ stale: boolean;
132
+ generated_at?: string;
133
+ beads?: Bead[];
134
+ roster?: Roster;
135
+ /** Which project the server answered for. */
136
+ project?: string;
137
+ /** How long the server's `bd` call took, in ms. */
138
+ took_ms?: number;
139
+ }
140
+
141
+ // The rule lives in status-filter.ts so it is reachable from a test without
142
+ // dragging Pinia and a fetch in — the same reason window.ts exists.
143
+ // Re-exported because store.ts is where consumers already import from.
144
+ export { STATUSES_HIDDEN_BY_DEFAULT, defaultStatusSelection } from "./status-filter";
145
+ import { defaultStatusSelection } from "./status-filter";
146
+ import { nextCursor } from "./keybindings";
147
+ import { applyOptimistic, describeOutcome, type Mutation } from "./mutations";
148
+ import { applyChanges, diffPlans, upsertChange, ideaEstimateDays, mutationsFor, describeChange, type WhatIfChange, type PlanDiff } from "./whatif";
149
+
150
+ const DEFAULT_FILTERS: Filters = {
151
+ types: null,
152
+ priorities: null,
153
+ // Null until the first payload arrives, because the default is "every
154
+ // status this repo actually has, minus the ones above" and that cannot be
155
+ // known before the data. `initStatusSelection` fills it in.
156
+ statuses: null,
157
+ labels: null,
158
+ assignees: null,
159
+ unblocked: null,
160
+ };
161
+
162
+ // The window presets live in window.ts so they are reachable from a test —
163
+ // see the note there about the duplicated copy that drifted. Re-exported
164
+ // because store.ts is where every consumer already imports them from.
165
+ export {
166
+ WINDOW_PRESETS,
167
+ windowFromPreset,
168
+ type WindowPreset,
169
+ } from "./window";
170
+ import { windowFromPreset, WINDOW_PRESETS, type WindowPreset } from "./window";
171
+
172
+ function defaultWindow(): WindowSpec {
173
+ return windowFromPreset("90d");
174
+ }
175
+
176
+ export interface VelocityEntry {
177
+ handle: string;
178
+ closedInWindow: number;
179
+ closesPerWeek: number;
180
+ lastClosedAt: string | null;
181
+ }
182
+
183
+ export interface VelocitySummary {
184
+ asOf: Date;
185
+ lookbackDays: number;
186
+ perHuman: Map<string, VelocityEntry>;
187
+ /** Whole-week ship buckets ending at asOf. See velocity.mjs for why the
188
+ * trend's window is weeklyWeeks * 7 days rather than lookbackDays. */
189
+ weekly: Array<{ weekStart: Date; count: number }>;
190
+ weeklyTotal: number;
191
+ weeklyWeeks: number;
192
+ totalClosedInWindow: number;
193
+ activeCount: number;
194
+ }
195
+
196
+ interface CachedSchedule {
197
+ key: string;
198
+ schedule: Schedule;
199
+ estimates: Map<string, EstimateResult>;
200
+ velocity: VelocitySummary;
201
+ calibratedRoster: Roster;
202
+ }
203
+
204
+ let scheduleCache: CachedSchedule | null = null;
205
+
206
+ // Recompute the velocity + estimator + scheduler only when the underlying
207
+ // beads change. Filter/group/window changes reuse the cached schedule + per-
208
+ // bead estimates — the point of the view-model split is that reactivity is
209
+ // cheap.
210
+ /** The plan for these beads and this roster: velocity, estimator, calibration, waits, schedule. Uncached. */
211
+ function computePlan(
212
+ beads: Bead[],
213
+ rawRoster: Roster,
214
+ horizon: Date,
215
+ ): {
216
+ schedule: Schedule;
217
+ estimates: Map<string, EstimateResult>;
218
+ velocity: VelocitySummary;
219
+ calibratedRoster: Roster;
220
+ } {
221
+
222
+ // Reality calibration: measure per-human throughput over the last 60
223
+ // days and clamp each human's WIP cap via Little's Law using the
224
+ // estimator's median cycle time. Humans with no shipped history keep
225
+ // their raw cap so brand-new teams still get a projection.
226
+ const velocity = computeVelocity(beads) as VelocitySummary;
227
+ const { estimate, stats } = buildEstimator(beads) as unknown as {
228
+ estimate: (bead: Bead) => EstimateResult;
229
+ stats: {
230
+ global_median_days: number;
231
+ median_lead_days: number;
232
+ };
233
+ };
234
+ const calibratedRoster = calibrateRoster(rawRoster, velocity, {
235
+ medianCycleDays: stats.median_lead_days || stats.global_median_days,
236
+ }) as Roster;
237
+ const estimates = new Map<string, EstimateResult>();
238
+ const capturingEstimate = (bead: Bead): EstimateResult => {
239
+ // A what-if idea carries its own size (bp-q56); everything else is the estimator's.
240
+ const own = ideaEstimateDays(bead);
241
+ const r = own !== null ? { days: own, p90: null, source: `what-if, ${(own * 24).toFixed(1)}h` } : (estimate(bead) as EstimateResult);
242
+ estimates.set(bead.id, r);
243
+ return r;
244
+ };
245
+ // The wait clock (bp-m0g): this project's own stage medians, and the
246
+ // estimator's lead-time median as the floor where review data is missing.
247
+ const waits = stageWaits(beads);
248
+ const result = scheduleGreedy(beads, calibratedRoster, capturingEstimate, horizon, {
249
+ waits,
250
+ minLeadDays: stats.median_lead_days || 0,
251
+ }) as unknown as {
252
+ schedule: Schedule;
253
+ unassignedProjection: Schedule;
254
+ };
255
+ const schedule = result.schedule;
256
+ for (const [id, s] of result.unassignedProjection) {
257
+ if (!schedule.has(id)) schedule.set(id, s);
258
+ }
259
+ // Fill in estimates for closed/deferred beads — the scheduler doesn't
260
+ // call the estimator on those, but the tooltip should still show the
261
+ // historical model's opinion.
262
+ for (const b of beads) {
263
+ if (!estimates.has(b.id)) estimates.set(b.id, estimate(b) as EstimateResult);
264
+ }
265
+ return { schedule, estimates, velocity, calibratedRoster };
266
+ }
267
+
268
+ function scheduleFor(
269
+ beads: Bead[],
270
+ rawRoster: Roster,
271
+ horizon: Date,
272
+ ): {
273
+ schedule: Schedule;
274
+ estimates: Map<string, EstimateResult>;
275
+ velocity: VelocitySummary;
276
+ calibratedRoster: Roster;
277
+ } {
278
+ const key =
279
+ beads.length + ":" + beads.map((b) => b.id + "@" + b.status).join(",") +
280
+ "|" + JSON.stringify(rawRoster);
281
+ if (scheduleCache && scheduleCache.key === key) return scheduleCache;
282
+ scheduleCache = { key, ...computePlan(beads, rawRoster, horizon) };
283
+ return scheduleCache;
284
+ }
285
+
286
+
287
+ export type HighlightKind = "blocked" | "over-cap" | "ready-now";
288
+
289
+ export const HIGHLIGHT_KINDS: HighlightKind[] = [
290
+ "blocked",
291
+ "over-cap",
292
+ "ready-now",
293
+ ];
294
+
295
+ // Shared frozen empty set for highlight getters — lets components use
296
+ // `.has()` without a null-check while still hitting Vue's identity-based
297
+ // change detection (returning the same instance every time avoids spurious
298
+ // re-renders).
299
+ const EMPTY_SET: ReadonlySet<string> = new Set();
300
+
301
+ export const useBeadsStore = defineStore("beads", {
302
+ state: () => ({
303
+ payload: null as Payload | null,
304
+ loading: false,
305
+ error: null as string | null,
306
+ lastFetched: null as Date | null,
307
+ filters: { ...DEFAULT_FILTERS } as Filters,
308
+ /**
309
+ * Normally-hidden statuses a legacy `?showClosed=`/`?includeDeferred=`
310
+ * link asked for, awaiting `initStatusSelection`. Not persisted to the
311
+ * URL — it is a one-shot translation of an old link, not state.
312
+ */
313
+ pendingLegacyStatuses: null as string[] | null,
314
+ window: defaultWindow(),
315
+ groupBy: "epic" as GroupBy,
316
+ /** Gantt and grid are alternative views of the same data, not
317
+ * companions — only the selected one is mounted. */
318
+ view: "mine" as ViewMode,
319
+ // ── keyboard (bp-67g.45) ─────────────────────────────────────────────
320
+ /** The ? overlay. */
321
+ helpOpen: false,
322
+ /** The in-app changelog (bp-67g.46). */
323
+ changelogOpen: false,
324
+ /** The first-run walkthrough (bp-67g.48); App opens it once, options re-opens it. */
325
+ walkthroughOpen: false,
326
+ // ── writes (bp-ocs) ─────────────────────────────────────────────────
327
+ /** The quick-actions menu: which bead, and where to draw it. */
328
+ contextMenu: null as { id: string; x: number; y: number } | null,
329
+ /** The last write's outcome, for the toast. */
330
+ notice: null as { kind: "ok" | "error"; text: string } | null,
331
+ /** Ids with a write in flight, so a surface can show it. */
332
+ pending: [] as string[],
333
+ /**
334
+ * The order beads appear in on the active view, published by the views
335
+ * whose order the store cannot derive (table: its sort; mine: its
336
+ * sections). The gantt and grid are viewModel.rows. j/k walk this.
337
+ */
338
+ viewOrder: [] as string[],
339
+ /** Bumped to ask the search palette / options menu to open; they watch it. */
340
+ searchRequests: 0,
341
+ optionsRequests: 0,
342
+ /** Bumped by the t key; the Gantt centres its viewport on today (bp-c6q). */
343
+ todayRequests: 0,
344
+ /** The drawer's subgraph scope, here so a key can flip it (bp-67g.60 built it local). */
345
+ drawerSubgraphScope: "bead" as "bead" | "epic",
346
+ /** Personal watch list, ids in the order they were added. Deliberately
347
+ * NOT part of URL state: sharing a link should share the view, not the
348
+ * recipient's pins. */
349
+ /** Who the MINE shortcuts mean (bp-egy). Stored in this browser. */
350
+ currentUser: (typeof localStorage === "undefined"
351
+ ? null
352
+ : localStorage.getItem(CURRENT_USER_STORAGE_KEY)) as string | null,
353
+ /** A `?me=` carried by a shared link: wins for this load only. */
354
+ urlUser: null as string | null,
355
+ /** The person the incytes view is scoped to (bp-67g.43); ?who= in the URL. */
356
+ who: null as string | null,
357
+ // ── what-if (bp-q56) ─────────────────────────────────────────────────
358
+ /** The change set. Non-empty means every plan-reading view shows the what-if. */
359
+ whatIf: [] as WhatIfChange[],
360
+ whatIfPanelOpen: false,
361
+ /** The last confirm's outcome, per change (bp-2fq). */
362
+ whatIfReport: null as Array<{ text: string; ok: boolean; detail?: string }> | null,
363
+ whatIfApplying: false,
364
+ watched: (typeof localStorage === "undefined"
365
+ ? []
366
+ : parseWatched(localStorage.getItem(WATCHLIST_STORAGE_KEY))) as string[],
367
+ /** Narrow both views to watched beads (and the children of watched
368
+ * epics). Session-only, like the highlight toggles. */
369
+ watchedOnly: false,
370
+
371
+ /** Known project locations, in the order the user arranged them. */
372
+ projects: (typeof localStorage === "undefined"
373
+ ? []
374
+ : parseProjects(localStorage.getItem(PROJECTS_STORAGE_KEY))) as Project[],
375
+ activeProjectId: (typeof localStorage === "undefined"
376
+ ? null
377
+ : localStorage.getItem(ACTIVE_PROJECT_STORAGE_KEY)) as string | null,
378
+ /** The directory the server was started in; seeded into an empty
379
+ * registry so a first run is never blank. */
380
+ launchPath: null as string | null,
381
+ /**
382
+ * Registry paths the CURRENT server would not admit, keyed by path with
383
+ * the refusal code as the value. Populated by loadProjects()'s reconcile
384
+ * step, which re-registers everything the server has forgotten; whatever
385
+ * still fails validation lands here so the menu can say so, instead of
386
+ * leaving a row that 403s whenever it is clicked.
387
+ *
388
+ * Not persisted: it is a statement about this server, exactly like the
389
+ * allowlist it mirrors.
390
+ */
391
+ unavailableProjects: {} as Record<string, string>,
392
+ /** Last observed fetch duration for the active project, from the
393
+ * server. Feeds the load meter, which has no honest progress of its
394
+ * own to report. */
395
+ lastFetchMs: null as number | null,
396
+ /**
397
+ * Prior durations keyed by project path. The meter needs an expectation
398
+ * BEFORE the fetch returns, so remembering the last run per project is
399
+ * what makes a switch back determinate instead of starting blind.
400
+ */
401
+ fetchMsByProject: {} as Record<string, number>,
402
+ /** When the in-flight fetch started, for the meter's elapsed clock. */
403
+ fetchStartedAt: null as number | null,
404
+ /**
405
+ * True while a refresh is happening over data we already have. Derived
406
+ * rather than flagged by the caller: what decides whether to blank the
407
+ * view is simply whether there IS a view — a manual refresh and a
408
+ * polled one both leave the chart up.
409
+ */
410
+ refreshingInPlace: false,
411
+ /** Poll interval in ms; 0 means manual refresh only. bp-1kh acts on it. */
412
+ pollIntervalMs: (typeof localStorage === "undefined"
413
+ ? DEFAULT_REFRESH_MS
414
+ : parseRefreshMs(localStorage.getItem(REFRESH_STORAGE_KEY))) as number,
415
+ // Row cap per group before truncation. Higher than the CLI default (20)
416
+ // because the interactive Gantt scrolls naturally — you rarely want
417
+ // work hidden by default, but a ceiling still helps very large groups
418
+ // stay usable. Per-group expand is offered via the group header.
419
+ maxPerGroup: 50,
420
+ /**
421
+ * Groups where per-group row truncation is lifted (all rows visible).
422
+ * Kept as a plain object rather than a Set so Pinia's reactivity picks
423
+ * up mutations without the caller needing to reassign.
424
+ */
425
+ expandedGroups: {} as Record<string, boolean>,
426
+ highlights: {
427
+ blocked: false,
428
+ "over-cap": false,
429
+ "ready-now": false,
430
+ } as Record<HighlightKind, boolean>,
431
+ /** Draw dependency arrows between visible bars. When on, arrows render
432
+ * around the hovered bead only — that's
433
+ * the most useful signal and stays legible in a big project. Full-graph
434
+ * arrows require the separate `showAllDependencies` opt-in. */
435
+ showDependencies: false,
436
+ /** Draw arrows for every visible blocks edge, regardless of hover. Off
437
+ * by default because the arrow density gets busy fast. */
438
+ showAllDependencies: false,
439
+ /** Which bead has its detail drawer open, if any. Set via
440
+ * openBead() / closeBead() and kept in sync with the URL hash by
441
+ * App.vue so drawers are deep-linkable. */
442
+ openBeadId: null as string | null,
443
+ }),
444
+
445
+ getters: {
446
+ beads: (s): Bead[] => s.payload?.beads ?? [],
447
+ roster: (s): Roster | null => s.payload?.roster ?? null,
448
+ generatedAt: (s): string | null => s.payload?.generated_at ?? null,
449
+
450
+ /** The user the MINE shortcuts filter on: the link's if it carries one, else the stored choice. */
451
+ effectiveUser: (s): string | null => s.urlUser ?? s.currentUser,
452
+
453
+ /** Assignee handles present on real beads, most-used first: the options for "current user". */
454
+ presentAssignees(): string[] {
455
+ const seen = new Map<string, number>();
456
+ for (const b of this.beads) {
457
+ if (b.issue_type === "epic" || !b.assignee) continue;
458
+ seen.set(b.assignee, (seen.get(b.assignee) ?? 0) + 1);
459
+ }
460
+ return [...seen.entries()].sort((a, b) => b[1] - a[1]).map(([h]) => h);
461
+ },
462
+
463
+ /** A stored user that no longer appears in the data: renamed or departed. */
464
+ userIsStale(): boolean {
465
+ const u = this.effectiveUser;
466
+ return !!u && this.beads.length > 0 && !this.presentAssignees.includes(u);
467
+ },
468
+
469
+ /** Statuses on real beads, the same enumeration the status filter seeds from. */
470
+ presentStatuses(): string[] {
471
+ const out = new Set<string>();
472
+ for (const b of this.beads) if (b.issue_type !== "epic") out.add(b.status);
473
+ return [...out];
474
+ },
475
+
476
+ /** Which MINE shortcut the current filters equal, if any. Derived, never stored. */
477
+ activeShortcut(state): Shortcut | null {
478
+ const me = this.effectiveUser;
479
+ if (!me) return null;
480
+ for (const id of SHORTCUT_IDS) {
481
+ if (isShortcutActive(state.filters, id, me, this.presentStatuses)) return id;
482
+ }
483
+ return null;
484
+ },
485
+
486
+ watchedSet: (s): ReadonlySet<string> => new Set(s.watched),
487
+
488
+ /**
489
+ * How long the active project took last time, or null if we've never
490
+ * loaded it. Null is the honest answer and the meter shows an
491
+ * indeterminate bar for it rather than guessing.
492
+ */
493
+ expectedFetchMs(state): number | null {
494
+ const path = this.activeProject?.path;
495
+ if (!path) return null;
496
+ return state.fetchMsByProject[path] ?? null;
497
+ },
498
+
499
+ /**
500
+ * The selected project. Resolution order lives in projects.ts:
501
+ * ?project=<label>, then the stored id, then the launch directory,
502
+ * then the first entry — so a non-empty list always has a selection.
503
+ */
504
+ activeProject(state): Project | null {
505
+ const urlLabel =
506
+ typeof location === "undefined"
507
+ ? null
508
+ : new URLSearchParams(location.search).get("project");
509
+ return resolveActive(state.projects, {
510
+ urlLabel,
511
+ storedId: state.activeProjectId,
512
+ launchPath: state.launchPath,
513
+ });
514
+ },
515
+
516
+ /**
517
+ * Watched ids that still exist in the payload, paired with their bead.
518
+ * An id can outlive its bead (deleted, renamed, or simply out of this
519
+ * repo), so the header list and count are built from this rather than
520
+ * from the raw id list.
521
+ */
522
+ watchedBeads(state): Bead[] {
523
+ const byId = new Map(
524
+ (state.payload?.beads ?? []).map((b) => [b.id, b] as const),
525
+ );
526
+ return state.watched
527
+ .map((id) => byId.get(id))
528
+ .filter((b): b is Bead => !!b);
529
+ },
530
+
531
+ /**
532
+ * Full schedule + per-bead estimates + observed velocity + calibrated
533
+ * roster. Kept in a single cached getter so all consumers share one
534
+ * computation; callers treat every returned map as a read-only
535
+ * snapshot.
536
+ */
537
+ /** The real plan: the payload as bd reports it, cached. */
538
+ baselinePlan(state): {
539
+ schedule: Schedule;
540
+ estimates: Map<string, EstimateResult>;
541
+ velocity: VelocitySummary;
542
+ calibratedRoster: Roster;
543
+ } | null {
544
+ if (!state.payload) return null;
545
+ const roster = state.payload.roster ?? { humans: [] };
546
+ const horizon = state.window.to ?? new Date(Date.now() + 90 * 86_400_000);
547
+ return scheduleFor(state.payload.beads, roster, horizon);
548
+ },
549
+
550
+ whatIfActive(state): boolean {
551
+ return state.whatIf.length > 0;
552
+ },
553
+
554
+ /** The beads the plan is over: the what-if copy when one is active (bp-q56). */
555
+ planBeads(state): Bead[] {
556
+ if (!state.payload) return [];
557
+ if (state.whatIf.length === 0) return state.payload.beads;
558
+ return applyChanges(state.payload.beads, state.payload.roster ?? { humans: [] }, state.whatIf).beads;
559
+ },
560
+
561
+ /**
562
+ * The plan every plan-reading view uses: the baseline, or the what-if's
563
+ * copy run through the same pipeline. Uncached on purpose — a what-if is
564
+ * edited interactively and the pipeline is ~50ms.
565
+ */
566
+ scheduleWithEstimates(state): {
567
+ schedule: Schedule;
568
+ estimates: Map<string, EstimateResult>;
569
+ velocity: VelocitySummary;
570
+ calibratedRoster: Roster;
571
+ } | null {
572
+ if (!state.payload) return null;
573
+ if (state.whatIf.length === 0) return this.baselinePlan;
574
+ const applied = applyChanges(state.payload.beads, state.payload.roster ?? { humans: [] }, state.whatIf);
575
+ const horizon = state.window.to ?? new Date(Date.now() + 90 * 86_400_000);
576
+ return computePlan(applied.beads, applied.roster, horizon);
577
+ },
578
+
579
+ /** What the what-if moves against the baseline; null when none is active. */
580
+ whatIfDiff(state): PlanDiff | null {
581
+ if (state.whatIf.length === 0) return null;
582
+ const base = this.baselinePlan;
583
+ const plan = this.scheduleWithEstimates;
584
+ if (!base || !plan) return null;
585
+ return diffPlans(base.schedule, plan.schedule, this.planBeads, { frontierDays: 14 });
586
+ },
587
+
588
+ /** Observed per-human close-rate over the last 60 days. */
589
+ velocity(): VelocitySummary | null {
590
+ return this.scheduleWithEstimates?.velocity ?? null;
591
+ },
592
+
593
+ /**
594
+ * Roster with per-human WIP caps clamped to observed weekly close-rate.
595
+ * Consumers reach for this instead of the raw roster when they want to
596
+ * show "actual" projected concurrency.
597
+ */
598
+ calibratedRoster(): Roster | null {
599
+ return this.scheduleWithEstimates?.calibratedRoster ?? null;
600
+ },
601
+
602
+ /**
603
+ * The reactive view model. Pinia getters are cached against their
604
+ * reactive reads, so this only recomputes when beads, filters, window,
605
+ * or group mode change.
606
+ */
607
+ viewModel(state): ViewModel | null {
608
+ const sched = this.scheduleWithEstimates;
609
+ if (!state.payload || !sched) return null;
610
+ const expanded = new Set(
611
+ Object.entries(state.expandedGroups)
612
+ .filter(([, v]) => v)
613
+ .map(([k]) => k),
614
+ );
615
+ return assembleViewModel(this.planBeads, sched.schedule, {
616
+ groupBy: state.groupBy,
617
+ filters: state.watchedOnly
618
+ ? { ...state.filters, watchedIds: state.watched }
619
+ : state.filters,
620
+ window: state.window,
621
+ maxPerGroup: state.maxPerGroup,
622
+ expandedGroups: expanded,
623
+ });
624
+ },
625
+
626
+ /**
627
+ * The table view's rows (bp-67g.44): the same filtered, windowed set as
628
+ * viewModel — same filters, same watched-only merge, same window — with
629
+ * the per-group cap lifted. Truncation is a Gantt drawing affordance; a
630
+ * table that silently drops rows is worse than a long one. Lazy, so the
631
+ * second assembly costs nothing until the table is on screen.
632
+ */
633
+ tableViewModel(state): ViewModel | null {
634
+ const sched = this.scheduleWithEstimates;
635
+ if (!state.payload || !sched) return null;
636
+ return assembleViewModel(this.planBeads, sched.schedule, {
637
+ groupBy: state.groupBy,
638
+ filters: state.watchedOnly
639
+ ? { ...state.filters, watchedIds: state.watched }
640
+ : state.filters,
641
+ window: state.window,
642
+ maxPerGroup: Infinity,
643
+ });
644
+ },
645
+
646
+ /**
647
+ * Build the tooltip payload for a bead by id. Returns null if the bead
648
+ * or the payload isn't loaded yet. Consumers (tooltip, drawer)
649
+ * pass this straight to the template.
650
+ */
651
+ beadDetailFor(): (id: string) => BeadDetail | null {
652
+ const sched = this.scheduleWithEstimates;
653
+ const beads = this.planBeads;
654
+ const byId = new Map(beads.map((b) => [b.id, b] as const));
655
+ return (id: string): BeadDetail | null => {
656
+ const b = byId.get(id);
657
+ if (!b) return null;
658
+ return buildBeadDetail(
659
+ b,
660
+ sched?.schedule.get(id) as ScheduleEntry | undefined,
661
+ sched?.estimates.get(id),
662
+ { horizonDay: this.scheduleHorizonDay },
663
+ );
664
+ };
665
+ },
666
+
667
+ /**
668
+ * How many days past today the scheduler was asked to project.
669
+ *
670
+ * The same number scheduleWithEstimates hands scheduleGreedy, so a
671
+ * finish clamped to the horizon can be recognised as a bound rather
672
+ * than reported as a date. It moves with the window preset, which is
673
+ * exactly why the caveat is needed.
674
+ */
675
+ scheduleHorizonDay(state): number {
676
+ const horizon =
677
+ state.window.to ?? new Date(Date.now() + 90 * 86_400_000);
678
+ const today = new Date();
679
+ const day0 = new Date(
680
+ today.getFullYear(),
681
+ today.getMonth(),
682
+ today.getDate(),
683
+ );
684
+ const end = new Date(
685
+ horizon.getFullYear(),
686
+ horizon.getMonth(),
687
+ horizon.getDate(),
688
+ );
689
+ return Math.round((+end - +day0) / 86_400_000);
690
+ },
691
+
692
+ blockedBeadIds(state): ReadonlySet<string> {
693
+ if (!state.highlights.blocked) return EMPTY_SET;
694
+ return computeBlockedIds(state.payload?.beads ?? []);
695
+ },
696
+
697
+ readyNowBeadIds(state): ReadonlySet<string> {
698
+ if (!state.highlights["ready-now"]) return EMPTY_SET;
699
+ return computeReadyNowIds(state.payload?.beads ?? []);
700
+ },
701
+
702
+ overCapAssignees(state): ReadonlySet<string> {
703
+ if (!state.highlights["over-cap"]) return EMPTY_SET;
704
+ return computeOverCapAssignees(
705
+ state.payload?.beads ?? [],
706
+ state.payload?.roster ?? { humans: [] },
707
+ );
708
+ },
709
+
710
+ /**
711
+ * Triage score per bead — same signal set as bv --robot-triage,
712
+ * computed locally. Cached against the full bead array.
713
+ */
714
+ triage(state): Map<string, TriageResult> {
715
+ if (!state.payload) return new Map();
716
+ return computeTriage(state.payload.beads);
717
+ },
718
+
719
+ /**
720
+ * Per-signal spread of the triage signals across the OPEN population.
721
+ *
722
+ * Open only: that is the set triage ranks, and including closed beads
723
+ * would make every live bead look extreme against a mostly-dead field.
724
+ * Cached against the payload like `triage` itself, and read only from
725
+ * the drawer, so it stays off the Gantt's path.
726
+ */
727
+ triageSignalPopulation(): SignalPopulation[] {
728
+ const t = this.triage;
729
+ if (!t.size) return [];
730
+ const openIds = (this.payload?.beads ?? [])
731
+ .filter((b) => b.status !== "closed")
732
+ .map((b) => b.id);
733
+ return signalPopulation(openIds, t);
734
+ },
735
+
736
+ /**
737
+ * The composite score's own population, for the drawer's strip (bp-67g.28).
738
+ * Open beads only, for the reason signalPopulation gives; read only from
739
+ * the drawer, so triage stays lazy.
740
+ */
741
+ triageScorePopulation(): ScorePopulation {
742
+ const t = this.triage;
743
+ const openIds = t.size
744
+ ? (this.payload?.beads ?? []).filter((b) => b.status !== "closed").map((b) => b.id)
745
+ : [];
746
+ return scorePopulation(openIds, t);
747
+ },
748
+
749
+ /**
750
+ * Ships per week, for the summary trend. Reshapes the series
751
+ * computeVelocity already accumulated — nothing walks the beads again,
752
+ * and the trend cannot disagree with the calibration band about what
753
+ * counts as a ship.
754
+ */
755
+ shipTrend(): ShipTrend | null {
756
+ const v = this.velocity;
757
+ return v ? shipTrend(v.weekly) : null;
758
+ },
759
+
760
+ /**
761
+ * Groups by population for the summary chart, largest first.
762
+ *
763
+ * Derived from the view model's groups, so it respects the current
764
+ * group-by mode — but the quantity is `progress`, which is counted
765
+ * before filtering and window clipping, so the chart doesn't shrink
766
+ * when someone hides closed beads.
767
+ */
768
+ // Project-wide (bp-67g.36): every group over every bead, so neither the
769
+ // filters nor the window reach the incytes Progress block. It used to
770
+ // reshape viewModel.groups, which only carries groups with a visible row.
771
+ groupProgress(state): GroupProgressRow[] {
772
+ const sched = this.scheduleWithEstimates;
773
+ if (!state.payload || !sched) return [];
774
+ return groupProgress(projectGroups(state.payload.beads, sched.schedule, state.groupBy));
775
+ },
776
+
777
+ /** The beads in the order the active view shows them; what j/k walk. */
778
+ cursorOrder(state): string[] {
779
+ if (state.view === "gantt" || state.view === "grid") return this.viewModel?.rows.map((r) => r.beadId) ?? [];
780
+ if (state.view === "table" || state.view === "mine") return state.viewOrder;
781
+ return [];
782
+ },
783
+
784
+ /** The preset the current window matches, or null when it is custom. */
785
+ activeWindowPreset(state): WindowPreset | null {
786
+ const same = (a: Date | null, b: Date | null) =>
787
+ (a === null && b === null) || (a !== null && b !== null && Math.abs(a.getTime() - b.getTime()) < 60_000);
788
+ for (const p of WINDOW_PRESETS) {
789
+ const w = windowFromPreset(p.id);
790
+ if (same(state.window.from, w.from) && same(state.window.to, w.to)) return p.id;
791
+ }
792
+ return null;
793
+ },
794
+
795
+ /** One bead by id, or null. */
796
+ beadById(state): (id: string) => Bead | null {
797
+ const byId = new Map((state.payload?.beads ?? []).map((b) => [b.id, b] as const));
798
+ return (id: string) => byId.get(id) ?? null;
799
+ },
800
+
801
+ /** The currently-open bead (drawer target), or null. */
802
+ drawerBead(state): Bead | null {
803
+ if (!state.openBeadId || !state.payload) return null;
804
+ return state.payload.beads.find((b) => b.id === state.openBeadId) ?? null;
805
+ },
806
+ },
807
+
808
+ actions: {
809
+ async fetch() {
810
+ // Refreshing over existing data must not blank the chart someone is
811
+ // reading, whether the trigger was a timer or a button.
812
+ this.refreshingInPlace = !!this.payload;
813
+ this.loading = true;
814
+ this.fetchStartedAt = Date.now();
815
+ this.error = null;
816
+ // Captured before awaiting: a project switch mid-flight must not let
817
+ // the old project's response land on the new selection.
818
+ const requestedProject = this.activeProject?.path ?? null;
819
+ try {
820
+ const params = new URLSearchParams();
821
+ if (this.generatedAt) params.set("since", this.generatedAt);
822
+ if (requestedProject) params.set("project", requestedProject);
823
+ const qs = params.toString();
824
+ const res = await fetch(qs ? `/api/beads?${qs}` : "/api/beads");
825
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
826
+ const body = (await res.json()) as ApiResponse;
827
+ // Discard a response for a project we've since navigated away from.
828
+ if ((this.activeProject?.path ?? null) !== requestedProject) return;
829
+ if (typeof body.took_ms === "number") {
830
+ this.lastFetchMs = body.took_ms;
831
+ if (requestedProject) {
832
+ this.fetchMsByProject[requestedProject] = body.took_ms;
833
+ }
834
+ }
835
+ if (body.stale === false) {
836
+ this.lastFetched = new Date();
837
+ return;
838
+ }
839
+ if (!body.beads || !body.generated_at) {
840
+ throw new Error("malformed payload from /api/beads");
841
+ }
842
+ this.payload = {
843
+ generated_at: body.generated_at,
844
+ beads: body.beads,
845
+ roster: body.roster ?? { humans: [] },
846
+ };
847
+ // The status filter's default depends on which statuses this repo
848
+ // actually has, so it can only be seeded once data exists. No-ops
849
+ // after the first payload and whenever a selection already stands.
850
+ this.initStatusSelection();
851
+ this.lastFetched = new Date();
852
+ } catch (e) {
853
+ this.error = String((e as Error).message ?? e);
854
+ } finally {
855
+ this.loading = false;
856
+ this.fetchStartedAt = null;
857
+ this.refreshingInPlace = false;
858
+ }
859
+ },
860
+
861
+ /**
862
+ * Ask the server what it was launched in and what it already trusts,
863
+ * then make sure the launch directory is in the registry. Without this
864
+ * a fresh browser shows an empty project list against a server that is
865
+ * perfectly happy to serve one.
866
+ */
867
+ async loadProjects() {
868
+ try {
869
+ const res = await fetch("/api/projects");
870
+ if (!res.ok) return;
871
+ const body = (await res.json()) as {
872
+ launch?: string;
873
+ projects?: string[];
874
+ };
875
+ if (!body.launch) return;
876
+ this.launchPath = body.launch;
877
+ if (!this.projects.some((p) => p.path === body.launch)) {
878
+ this.projects = addProject(this.projects, { path: body.launch });
879
+ this.persistProjects();
880
+ }
881
+
882
+ // Reconcile. The server's allowlist is per-process by design, so
883
+ // every restart leaves this registry holding paths it has forgotten
884
+ // — and reading one of those answers 403 with no data. Re-register
885
+ // them now, at boot, rather than discovering it at the click that
886
+ // switches to one.
887
+ //
888
+ // Re-registering rather than persisting the allowlist keeps the
889
+ // property that makes it per-process: inspectProject() runs again in
890
+ // THIS process, so a path that has moved or lost its .beads is
891
+ // refused now instead of being trusted from a stale file.
892
+ await Promise.all(
893
+ unadmittedPaths(this.projects, body.projects).map(async (path) => {
894
+ const result = await this.addProjectByPath(path);
895
+ if (result.ok) {
896
+ delete this.unavailableProjects[path];
897
+ } else {
898
+ this.unavailableProjects[path] = result.reason;
899
+ }
900
+ }),
901
+ );
902
+ } catch {
903
+ // Older server, or none. The app still works against the default
904
+ // project; the menu simply has nothing to switch between.
905
+ }
906
+ },
907
+
908
+ /**
909
+ * Register a path with the server and add it on success. Returns the
910
+ * refusal reason so the UI can say why rather than failing silently.
911
+ */
912
+ async addProjectByPath(
913
+ path: string,
914
+ label?: string,
915
+ ): Promise<{ ok: true } | { ok: false; reason: string }> {
916
+ try {
917
+ const res = await fetch("/api/projects", {
918
+ method: "POST",
919
+ headers: { "Content-Type": "application/json" },
920
+ body: JSON.stringify({ path }),
921
+ });
922
+ const body = (await res.json()) as {
923
+ ok?: boolean;
924
+ path?: string;
925
+ label?: string;
926
+ reason?: string;
927
+ };
928
+ if (!res.ok || !body.ok || !body.path) {
929
+ return { ok: false, reason: body.reason ?? `http-${res.status}` };
930
+ }
931
+ // Store the path the SERVER canonicalised, not what was typed, so
932
+ // later reads normalise to the same string it admitted.
933
+ this.projects = addProject(this.projects, {
934
+ path: body.path,
935
+ label: label || body.label,
936
+ });
937
+ delete this.unavailableProjects[body.path];
938
+ this.persistProjects();
939
+ return { ok: true };
940
+ } catch (e) {
941
+ return { ok: false, reason: String((e as Error).message ?? e) };
942
+ }
943
+ },
944
+
945
+ setPollInterval(ms: number) {
946
+ this.pollIntervalMs = parseRefreshMs(String(ms));
947
+ try {
948
+ localStorage.setItem(REFRESH_STORAGE_KEY, String(this.pollIntervalMs));
949
+ } catch {
950
+ /* private mode — holds for this session only */
951
+ }
952
+ },
953
+
954
+ setActiveProject(id: string) {
955
+ if (this.activeProjectId === id) return;
956
+ this.activeProjectId = id;
957
+ try {
958
+ localStorage.setItem(ACTIVE_PROJECT_STORAGE_KEY, id);
959
+ } catch {
960
+ /* private mode — holds for this session only */
961
+ }
962
+ // A different project is a different dataset: drop the payload so the
963
+ // views show a load state rather than the previous project's beads,
964
+ // and close the drawer, whose id does not exist over there.
965
+ this.payload = null;
966
+ this.openBeadId = null;
967
+ this.lastFetchMs = null;
968
+ void this.fetch();
969
+ },
970
+
971
+ renameProject(id: string, label: string) {
972
+ this.projects = renameProject(this.projects, id, label);
973
+ this.persistProjects();
974
+ },
975
+
976
+ /** Forgets a location. Nothing on disk is touched. */
977
+ forgetProject(id: string) {
978
+ const wasActive = this.activeProject?.id === id;
979
+ const gone = this.projects.find((p) => p.id === id);
980
+ if (gone) delete this.unavailableProjects[gone.path];
981
+ this.projects = removeProject(this.projects, id);
982
+ this.persistProjects();
983
+ if (wasActive) {
984
+ const fallback = resolveActive(this.projects, {
985
+ launchPath: this.launchPath,
986
+ });
987
+ if (fallback) this.setActiveProject(fallback.id);
988
+ }
989
+ },
990
+
991
+ moveProject(id: string, delta: number) {
992
+ this.projects = moveProject(this.projects, id, delta);
993
+ this.persistProjects();
994
+ },
995
+
996
+ persistProjects() {
997
+ try {
998
+ localStorage.setItem(
999
+ PROJECTS_STORAGE_KEY,
1000
+ serializeProjects(this.projects),
1001
+ );
1002
+ } catch {
1003
+ /* private mode — holds for this session only */
1004
+ }
1005
+ },
1006
+
1007
+ setGroupBy(g: GroupBy) {
1008
+ this.groupBy = g;
1009
+ },
1010
+
1011
+ setView(v: ViewMode) {
1012
+ this.view = v;
1013
+ },
1014
+
1015
+ toggleWatch(id: string) {
1016
+ this.watched = toggleWatched(this.watched, id);
1017
+ try {
1018
+ localStorage.setItem(
1019
+ WATCHLIST_STORAGE_KEY,
1020
+ serializeWatched(this.watched),
1021
+ );
1022
+ } catch {
1023
+ // Private mode or a full quota: the list still works for this
1024
+ // session, it just won't survive a reload. Not worth failing over.
1025
+ }
1026
+ // Nothing left to narrow to.
1027
+ if (this.watched.length === 0) this.watchedOnly = false;
1028
+ },
1029
+
1030
+ toggleWatchedOnly() {
1031
+ this.watchedOnly = !this.watchedOnly;
1032
+ },
1033
+
1034
+ toggleHighlight(kind: HighlightKind) {
1035
+ this.highlights = {
1036
+ ...this.highlights,
1037
+ [kind]: !this.highlights[kind],
1038
+ };
1039
+ },
1040
+
1041
+ toggleShowDependencies() {
1042
+ this.showDependencies = !this.showDependencies;
1043
+ },
1044
+
1045
+ toggleShowAllDependencies() {
1046
+ this.showAllDependencies = !this.showAllDependencies;
1047
+ },
1048
+
1049
+ openBead(id: string) {
1050
+ this.openBeadId = id;
1051
+ this.drawerSubgraphScope = "bead";
1052
+ },
1053
+
1054
+ closeBead() {
1055
+ this.openBeadId = null;
1056
+ },
1057
+
1058
+ // ── keyboard (bp-67g.45) ─────────────────────────────────────────────
1059
+ /** The keyboard cursor is the open drawer; see keybindings.ts. */
1060
+ moveCursor(delta: number) {
1061
+ const id = nextCursor(this.cursorOrder, this.openBeadId, delta);
1062
+ if (id) this.openBead(id);
1063
+ },
1064
+
1065
+ publishViewOrder(ids: string[]) {
1066
+ this.viewOrder = ids;
1067
+ },
1068
+
1069
+ openHelp() {
1070
+ this.helpOpen = true;
1071
+ },
1072
+
1073
+ closeHelp() {
1074
+ this.helpOpen = false;
1075
+ },
1076
+
1077
+ openChangelog() {
1078
+ this.changelogOpen = true;
1079
+ },
1080
+
1081
+ openWalkthrough() {
1082
+ this.walkthroughOpen = true;
1083
+ },
1084
+
1085
+ // ── writes (bp-ocs) ─────────────────────────────────────────────────
1086
+ openContextMenu(id: string, x: number, y: number) {
1087
+ this.contextMenu = { id, x, y };
1088
+ },
1089
+
1090
+ closeContextMenu() {
1091
+ this.contextMenu = null;
1092
+ },
1093
+
1094
+ clearNotice() {
1095
+ this.notice = null;
1096
+ },
1097
+
1098
+ /**
1099
+ * Apply a whitelisted mutation: optimistic patch, POST to /api/mutate,
1100
+ * then the server's fresh bead — or the original back on failure — and
1101
+ * a notice either way that carries the exact bd command. Finishes with
1102
+ * a fetch, since the server dropped its list cache for the project.
1103
+ */
1104
+ async mutate(m: Mutation) {
1105
+ const beads = this.payload?.beads;
1106
+ if (!beads) return;
1107
+ const i = beads.findIndex((b) => b.id === m.id);
1108
+ if (i < 0) return;
1109
+ const original = beads[i]!;
1110
+ const guess = applyOptimistic(original, m, this.effectiveUser);
1111
+ beads.splice(i, 1, guess);
1112
+ this.pending = [...this.pending, m.id];
1113
+ const project = this.activeProject?.path ?? null;
1114
+ try {
1115
+ const res = await fetch("/api/mutate", {
1116
+ method: "POST",
1117
+ headers: { "Content-Type": "application/json" },
1118
+ body: JSON.stringify({ ...m, project }),
1119
+ });
1120
+ const body = (await res.json().catch(() => ({}))) as { ok?: boolean; command?: string; bead?: Bead | null; error?: string };
1121
+ // A 404 here is a server started before this route existed: Vite
1122
+ // hot-reloads the client but middleware loads at start.
1123
+ if (res.status === 404) {
1124
+ throw new Error("this server predates quick actions — restart it (beadcyte stop, then beadcyte start)");
1125
+ }
1126
+ if (!res.ok || !body.ok) throw new Error(body.error || `HTTP ${res.status}`, { cause: body.command });
1127
+ const j = this.payload?.beads.findIndex((b) => b.id === m.id) ?? -1;
1128
+ if (j >= 0 && body.bead) this.payload!.beads.splice(j, 1, { ...this.payload!.beads[j]!, ...body.bead });
1129
+ if (body.command) console.info(`beadcyte: ${body.command}`);
1130
+ this.notice = { kind: "ok", text: `${m.id} ${describeOutcome(m)}` };
1131
+ } catch (e) {
1132
+ const j = this.payload?.beads.findIndex((b) => b.id === m.id) ?? -1;
1133
+ if (j >= 0) this.payload!.beads.splice(j, 1, original);
1134
+ const err = e as Error & { cause?: unknown };
1135
+ if (typeof err.cause === "string") console.info(`beadcyte: failed: ${err.cause}`);
1136
+ this.notice = { kind: "error", text: `${m.id}: ${err.message}` };
1137
+ } finally {
1138
+ this.pending = this.pending.filter((id) => id !== m.id);
1139
+ setTimeout(() => {
1140
+ if (this.notice) this.notice = null;
1141
+ }, 6000);
1142
+ void this.fetch();
1143
+ }
1144
+ },
1145
+
1146
+ closeWalkthrough() {
1147
+ this.walkthroughOpen = false;
1148
+ },
1149
+
1150
+ closeChangelog() {
1151
+ this.changelogOpen = false;
1152
+ },
1153
+
1154
+ requestSearch() {
1155
+ this.searchRequests++;
1156
+ },
1157
+
1158
+ requestOptions() {
1159
+ this.optionsRequests++;
1160
+ },
1161
+
1162
+ requestToday() {
1163
+ this.todayRequests++;
1164
+ },
1165
+
1166
+ setDrawerSubgraphScope(scope: "bead" | "epic") {
1167
+ this.drawerSubgraphScope = scope;
1168
+ },
1169
+
1170
+ toggleDrawerSubgraphScope() {
1171
+ this.drawerSubgraphScope = this.drawerSubgraphScope === "bead" ? "epic" : "bead";
1172
+ },
1173
+
1174
+ /**
1175
+ * Add or remove one status from the status selection. An empty or null
1176
+ * selection means every status, so it is materialised from the statuses
1177
+ * present before the toggle, otherwise removing "closed" from "all"
1178
+ * would have nothing to remove it from.
1179
+ */
1180
+ toggleStatusInFilter(status: string) {
1181
+ const cur =
1182
+ this.filters.statuses && this.filters.statuses.length ? this.filters.statuses : this.presentStatuses;
1183
+ const next = cur.includes(status) ? cur.filter((x) => x !== status) : [...cur, status];
1184
+ this.setFilters({ statuses: next });
1185
+ },
1186
+
1187
+ toggleExpandGroup(gid: string) {
1188
+ this.expandedGroups = {
1189
+ ...this.expandedGroups,
1190
+ [gid]: !this.expandedGroups[gid],
1191
+ };
1192
+ },
1193
+
1194
+ /**
1195
+ * Seed the status filter from the data, once.
1196
+ *
1197
+ * Called when a payload lands. Does nothing if a selection already
1198
+ * exists — an explicit `?status=` from the URL, or the user's own choice
1199
+ * surviving a refresh — so this only ever supplies the first-paint
1200
+ * default.
1201
+ *
1202
+ * `pendingLegacyStatuses` carries the intent of a pre-bp-67g.40 link:
1203
+ * `?showClosed=true` has to end up selecting `closed` alongside the
1204
+ * default set, and it cannot be honoured at URL-read time because the
1205
+ * rest of that set is not known until the beads arrive.
1206
+ */
1207
+ initStatusSelection() {
1208
+ if (this.filters.statuses !== null) return;
1209
+ const present: string[] = [];
1210
+ for (const b of this.beads) {
1211
+ if (b.issue_type !== "epic") present.push(b.status);
1212
+ }
1213
+ const seeded = defaultStatusSelection(present, this.pendingLegacyStatuses ?? []);
1214
+ if (seeded === null) return; // nothing to seed from yet
1215
+ this.pendingLegacyStatuses = null;
1216
+ this.filters = { ...this.filters, statuses: seeded };
1217
+ },
1218
+
1219
+ /**
1220
+ * Record which normally-hidden statuses a legacy URL asked for, to be
1221
+ * applied by `initStatusSelection` once the data is known.
1222
+ */
1223
+ setPendingLegacyStatuses(statuses: string[]) {
1224
+ this.pendingLegacyStatuses = statuses.length ? statuses : null;
1225
+ },
1226
+
1227
+ setCurrentUser(handle: string | null) {
1228
+ this.currentUser = handle;
1229
+ // An explicit choice supersedes whatever the link carried in.
1230
+ this.urlUser = null;
1231
+ try {
1232
+ if (handle) localStorage.setItem(CURRENT_USER_STORAGE_KEY, handle);
1233
+ else localStorage.removeItem(CURRENT_USER_STORAGE_KEY);
1234
+ } catch {
1235
+ /* private mode — holds for this session only */
1236
+ }
1237
+ },
1238
+
1239
+ setWho(handle: string | null) {
1240
+ this.who = handle;
1241
+ },
1242
+
1243
+ // ── what-if (bp-q56) ─────────────────────────────────────────────────
1244
+ addWhatIf(c: WhatIfChange) {
1245
+ this.whatIfReport = null; // a new change supersedes the last confirm's report
1246
+ this.whatIf = upsertChange(this.whatIf, c);
1247
+ this.whatIfPanelOpen = true;
1248
+ },
1249
+
1250
+ removeWhatIf(index: number) {
1251
+ this.whatIf = this.whatIf.filter((_, i) => i !== index);
1252
+ },
1253
+
1254
+ discardWhatIf() {
1255
+ this.whatIf = [];
1256
+ this.whatIfPanelOpen = false;
1257
+ },
1258
+
1259
+ openWhatIfPanel() {
1260
+ this.whatIfPanelOpen = true;
1261
+ },
1262
+
1263
+ /**
1264
+ * Close the panel. The last confirm's report goes with it: the panel
1265
+ * stays mounted while a report exists, so a close that left the report
1266
+ * behind left the panel behind too (owner, 2026-09-05). Staged changes
1267
+ * stay — closing is not discarding.
1268
+ */
1269
+ closeWhatIfPanel() {
1270
+ this.whatIfPanelOpen = false;
1271
+ this.whatIfReport = null;
1272
+ },
1273
+
1274
+ /**
1275
+ * Confirm (bp-2fq): the change set becomes bd mutations, run one after
1276
+ * another through /api/mutate — each is the same whitelisted write the
1277
+ * quick actions use, so nothing new can be written from here. Every
1278
+ * change gets a line in the report; a failure stops the batch there and
1279
+ * leaves the rest staged for another try. Caps and unassigns are
1280
+ * reported as not applied, with what to do by hand.
1281
+ */
1282
+ async confirmWhatIf() {
1283
+ if (this.whatIfApplying || this.whatIf.length === 0) return;
1284
+ this.whatIfApplying = true;
1285
+ const titleOf = (id: string) => this.beadById(id)?.title;
1286
+ const report: Array<{ text: string; ok: boolean; detail?: string }> = [];
1287
+ const remaining: WhatIfChange[] = [];
1288
+ const project = this.activeProject?.path ?? null;
1289
+ const { skipped } = mutationsFor(this.whatIf);
1290
+ for (const c of skipped) {
1291
+ report.push({
1292
+ text: describeChange(c, titleOf),
1293
+ ok: false,
1294
+ detail: c.kind === "cap" ? "not a bd fact: set it in .beadcyte/roster.json" : "bd has no unassign here: bd update <id> --assignee \"\"",
1295
+ });
1296
+ }
1297
+ let stopped = false;
1298
+ for (const c of this.whatIf) {
1299
+ if (skipped.includes(c)) continue;
1300
+ if (stopped) {
1301
+ remaining.push(c);
1302
+ continue;
1303
+ }
1304
+ const m = mutationsFor([c]).mutations[0]!;
1305
+ try {
1306
+ const res = await fetch("/api/mutate", {
1307
+ method: "POST",
1308
+ headers: { "Content-Type": "application/json" },
1309
+ body: JSON.stringify({ ...m, project }),
1310
+ });
1311
+ const body = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string; bead?: Bead | null };
1312
+ if (res.status === 404) throw new Error("this server predates quick actions — restart it");
1313
+ if (!res.ok || !body.ok) throw new Error(body.error || `HTTP ${res.status}`);
1314
+ report.push({ text: describeChange(c, titleOf), ok: true, detail: body.bead?.id && c.kind === "idea" ? `created ${body.bead.id}` : undefined });
1315
+ } catch (e) {
1316
+ report.push({ text: describeChange(c, titleOf), ok: false, detail: String((e as Error).message ?? e) });
1317
+ remaining.push(c);
1318
+ stopped = true;
1319
+ }
1320
+ }
1321
+ this.whatIfReport = report;
1322
+ this.whatIf = remaining;
1323
+ this.whatIfApplying = false;
1324
+ const okCount = report.filter((r) => r.ok).length;
1325
+ this.notice = { kind: stopped ? "error" : "ok", text: `what-if: ${okCount} change${okCount === 1 ? "" : "s"} written${stopped ? ", stopped at a failure" : ""}${skipped.length ? `, ${skipped.length} not applicable` : ""}` };
1326
+ setTimeout(() => {
1327
+ if (this.notice) this.notice = null;
1328
+ }, 6000);
1329
+ void this.fetch();
1330
+ },
1331
+
1332
+ /** Any assignee handle in the app leads here: incytes, scoped to that person. */
1333
+ openPerson(handle: string) {
1334
+ this.who = handle;
1335
+ this.view = "incytes";
1336
+ },
1337
+
1338
+ setUrlUser(handle: string | null) {
1339
+ this.urlUser = handle;
1340
+ },
1341
+
1342
+ /** Every filter back to its default; the status seed is recomputed from the data. */
1343
+ resetFilters() {
1344
+ const seeded = defaultStatusSelection(this.presentStatuses, []);
1345
+ this.filters = { ...DEFAULT_FILTERS, statuses: seeded };
1346
+ },
1347
+
1348
+ /**
1349
+ * MINE shortcut (bp-egy). Replaces the whole filter state with the
1350
+ * shortcut's; clicking the active one clears back to the defaults.
1351
+ */
1352
+ applyShortcut(kind: Shortcut) {
1353
+ const me = this.effectiveUser;
1354
+ if (!me) return;
1355
+ if (this.activeShortcut === kind) {
1356
+ this.resetFilters();
1357
+ return;
1358
+ }
1359
+ this.filters = shortcutFilters(kind, me, this.presentStatuses);
1360
+ },
1361
+
1362
+ setFilters(patch: Partial<Filters>) {
1363
+ this.filters = { ...this.filters, ...patch };
1364
+ },
1365
+
1366
+ setWindowFromPreset(preset: WindowPreset) {
1367
+ this.window = windowFromPreset(preset);
1368
+ },
1369
+
1370
+ setPriorityFilter(priorities: number[] | null) {
1371
+ this.filters = { ...this.filters, priorities };
1372
+ },
1373
+
1374
+ setWindow(w: WindowSpec) {
1375
+ this.window = w;
1376
+ },
1377
+ },
1378
+ });