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,208 @@
1
+ // frontier.ts — the plan's edge for one person (bp-wfe).
2
+ //
3
+ // Two things, both pure:
4
+ //
5
+ // - the LANE: over the next N days, which beads start for this person and
6
+ // which land, read straight off the two-clock schedule (the same entries
7
+ // the Gantt draws), with synthetic assignments marked as such;
8
+ // - PROPOSALS: what this person could take next, ranked with the reasons
9
+ // stated — the scheduler's own routing (one definition, exported from
10
+ // scheduler.mjs: affinityMarksFor / beadAffinityMatches), triage score,
11
+ // unblocking power, priority, and whether they have room today — and,
12
+ // where someone else is over cap, their not-yet-started beads offered
13
+ // across as reassignments.
14
+ //
15
+ // DECISION (owner, 2026-09-05): the planner may propose reassignments across
16
+ // people, but every proposal is confirmed by the owner. So a proposal is a
17
+ // what-if change (bp-q56), never a write: accepting it stages it, the diff
18
+ // shows what moves, and confirm (bp-2fq) is where bd is touched.
19
+
20
+ import type { Bead } from "./store";
21
+ import type { Schedule } from "./view-model";
22
+ import type { TriageResult } from "./triage";
23
+ import type { WhatIfChange } from "./whatif";
24
+ import { resolveAssignee } from "./view-model.ts";
25
+ import { FINISHED_STATUSES, IN_FLIGHT_STATUSES, NOT_WORKABLE_STATUSES, READY_LABEL, hasOpenBlocker } from "./highlights.ts";
26
+ import { affinityMarksFor, beadAffinityMatches } from "../scheduler.mjs";
27
+ import { DEFAULT_CAP } from "./wip.ts";
28
+
29
+ // ── the lane ──────────────────────────────────────────────────────────────
30
+
31
+ export interface FrontierMarker {
32
+ id: string;
33
+ title: string;
34
+ /** "starts": the work begins for them; "lands": the bead is projected done. */
35
+ kind: "starts" | "lands";
36
+ /** Days from today, fractional. */
37
+ day: number;
38
+ /** The assignment is the scheduler's, not recorded on the bead. */
39
+ synthetic: boolean;
40
+ }
41
+
42
+ export interface FrontierLane {
43
+ days: number;
44
+ markers: FrontierMarker[];
45
+ starts: number;
46
+ lands: number;
47
+ }
48
+
49
+ const isEpic = (b: Bead) => b.issue_type === "epic";
50
+
51
+ /**
52
+ * The next `days` days for one person. A bead already in flight has no
53
+ * "starts" marker (it started); a bead landing past the horizon has no
54
+ * "lands" marker but may still start inside it.
55
+ */
56
+ export function frontierLane(handle: string, beads: readonly Bead[], schedule: Schedule | null, days: number): FrontierLane {
57
+ const markers: FrontierMarker[] = [];
58
+ if (schedule) {
59
+ for (const b of beads) {
60
+ if (isEpic(b) || FINISHED_STATUSES.includes(b.status)) continue;
61
+ const sched = schedule.get(b.id);
62
+ if (!sched) continue;
63
+ const who = resolveAssignee(b, sched);
64
+ if (who.handle !== handle) continue;
65
+ const start = sched.work_start_day ?? sched.start_day;
66
+ if (!IN_FLIGHT_STATUSES.includes(b.status) && start >= 0 && start <= days) {
67
+ markers.push({ id: b.id, title: b.title, kind: "starts", day: start, synthetic: who.synthetic });
68
+ }
69
+ if (sched.end_day >= 0 && sched.end_day <= days) {
70
+ markers.push({ id: b.id, title: b.title, kind: "lands", day: sched.end_day, synthetic: who.synthetic });
71
+ }
72
+ }
73
+ }
74
+ markers.sort((a, b) => a.day - b.day || a.id.localeCompare(b.id));
75
+ return {
76
+ days,
77
+ markers,
78
+ starts: markers.filter((m) => m.kind === "starts").length,
79
+ lands: markers.filter((m) => m.kind === "lands").length,
80
+ };
81
+ }
82
+
83
+ /** "3 start, 2 land in the next 14 days", or the honest empty sentence. */
84
+ export function describeLane(lane: FrontierLane): string {
85
+ if (!lane.markers.length) return `nothing starts or lands for them in the next ${lane.days} days`;
86
+ return `${lane.starts} start${lane.starts === 1 ? "s" : ""}, ${lane.lands} land${lane.lands === 1 ? "s" : ""} in the next ${lane.days} days`;
87
+ }
88
+
89
+ // ── proposals ─────────────────────────────────────────────────────────────
90
+
91
+ export interface RosterHumanForProposals {
92
+ handle: string;
93
+ cap?: number;
94
+ labels?: string[];
95
+ areas?: string[];
96
+ }
97
+
98
+ export interface Proposal {
99
+ bead: Bead;
100
+ score: number;
101
+ /** Stated, in the order they were weighed; shown beside the row. */
102
+ reasons: string[];
103
+ /** The person it would come from, when it is a reassignment. */
104
+ from: string | null;
105
+ /** Accepting = staging this in the what-if. */
106
+ change: WhatIfChange;
107
+ }
108
+
109
+ export interface ProposalOptions {
110
+ limit?: number;
111
+ }
112
+
113
+ /**
114
+ * Rank what `handle` could take next. Candidates are workable (not
115
+ * deferred/finished/in flight), unblocked, non-epic beads that are unassigned,
116
+ * or assigned to someone over their cap and not yet started — those are the
117
+ * reassignments the owner decided the planner may propose. spec:ready is a
118
+ * reason, not a gate, as in the scheduler's own pool pick. Weights are
119
+ * stated in code because they are the argument; the reasons repeat them in
120
+ * words.
121
+ */
122
+ export function proposeNext(
123
+ handle: string,
124
+ beads: readonly Bead[],
125
+ humans: readonly RosterHumanForProposals[],
126
+ schedule: Schedule | null,
127
+ triage: Map<string, TriageResult> | null,
128
+ opts: ProposalOptions = {},
129
+ ): Proposal[] {
130
+ const limit = opts.limit ?? 5;
131
+ const byId = new Map(beads.map((b) => [b.id, b] as const));
132
+ const me = humans.find((h) => h.handle === handle);
133
+ const marks = me ? affinityMarksFor(me) : new Set<string>();
134
+
135
+ // In flight and cap per person, for room and for who is over.
136
+ const inFlight = new Map<string, number>();
137
+ for (const b of beads) {
138
+ if (b.assignee && IN_FLIGHT_STATUSES.includes(b.status)) inFlight.set(b.assignee, (inFlight.get(b.assignee) ?? 0) + 1);
139
+ }
140
+ const capOf = (h: string) => humans.find((x) => x.handle === h)?.cap ?? DEFAULT_CAP;
141
+ const overBy = (h: string) => Math.max(0, (inFlight.get(h) ?? 0) - capOf(h));
142
+ const room = capOf(handle) - (inFlight.get(handle) ?? 0);
143
+
144
+ // Unblocking power: open beads that name this one as a blocker.
145
+ const dependents = new Map<string, number>();
146
+ for (const b of beads) {
147
+ if (FINISHED_STATUSES.includes(b.status)) continue;
148
+ for (const d of b.dependencies ?? []) {
149
+ if (d.type === "blocks") dependents.set(d.depends_on_id, (dependents.get(d.depends_on_id) ?? 0) + 1);
150
+ }
151
+ }
152
+
153
+ const out: Proposal[] = [];
154
+ for (const b of beads) {
155
+ if (isEpic(b) || b.assignee === handle) continue;
156
+ if (FINISHED_STATUSES.includes(b.status) || NOT_WORKABLE_STATUSES.includes(b.status) || IN_FLIGHT_STATUSES.includes(b.status)) continue;
157
+ if (hasOpenBlocker(b, byId)) continue;
158
+ const from = b.assignee ?? null;
159
+ if (from && overBy(from) === 0) continue; // theirs, and they have room: not ours to propose
160
+
161
+ const reasons: string[] = [];
162
+ let score = 0;
163
+ const sched = schedule?.get(b.id);
164
+ if (sched?.synthetic_assignee && sched.assignee === handle) {
165
+ score += 3;
166
+ reasons.push("the scheduler routes it here");
167
+ }
168
+ if ((b.labels ?? []).includes(READY_LABEL)) {
169
+ score += 1;
170
+ reasons.push(READY_LABEL);
171
+ }
172
+ const hits = beadAffinityMatches(b, marks);
173
+ if (hits.length) {
174
+ score += 2;
175
+ reasons.push(`affinity ${hits.join(", ")}`);
176
+ }
177
+ const t = triage?.get(b.id)?.score ?? 0;
178
+ if (t > 0) {
179
+ score += 2 * t;
180
+ reasons.push(`triage ${(t * 100).toFixed(0)}%`);
181
+ }
182
+ const unblocks = dependents.get(b.id) ?? 0;
183
+ if (unblocks > 0) {
184
+ score += Math.min(2, unblocks * 0.5);
185
+ reasons.push(`unblocks ${unblocks}`);
186
+ }
187
+ const p = b.priority ?? 4;
188
+ score += (4 - p) * 0.25;
189
+ if (p <= 1) reasons.push(`P${p}`);
190
+ if (from) {
191
+ score += 0.5;
192
+ reasons.push(`from ${from}, ${overBy(from)} over cap`);
193
+ }
194
+ if (room > 0) reasons.push(`fits: ${room} slot${room === 1 ? "" : "s"} free`);
195
+ else {
196
+ score *= 0.5;
197
+ reasons.push(room === 0 ? "no room today: at cap" : `no room today: ${-room} over cap`);
198
+ }
199
+ out.push({ bead: b, score, reasons, from, change: { kind: "reassign", id: b.id, assignee: handle } });
200
+ }
201
+ out.sort((a, b) => b.score - a.score || (a.bead.priority ?? 4) - (b.bead.priority ?? 4) || a.bead.id.localeCompare(b.bead.id));
202
+ return out.slice(0, limit);
203
+ }
204
+
205
+ /** The reasons as one line for a row note. */
206
+ export function describeProposal(p: Proposal): string {
207
+ return p.reasons.join(" · ");
208
+ }
@@ -0,0 +1,99 @@
1
+ // gantt-viewport.ts — the Gantt's horizontal viewport (bp-c6q).
2
+ //
3
+ // The scheduling window is [t0, t1]; the viewport is the part of it on
4
+ // screen, in days from t0. Until now the whole window was always on screen,
5
+ // so a wide preset ("all", "history") squeezed a year into the plot column
6
+ // at two or three pixels a day. Now the window is drawn at a readable
7
+ // density and the rest scrolls:
8
+ //
9
+ // FIT when the window fits at MIN_PX_PER_DAY or better, the viewport is
10
+ // the window and nothing scrolls — the Gantt looks exactly as before.
11
+ // DENSE otherwise the viewport spans plotWidth / MIN_PX_PER_DAY days,
12
+ // centred on today when today is inside the window and on the
13
+ // window's end otherwise (the present is the interesting end), and
14
+ // the plot gains a scrollbar, drag-to-pan on the axis and wheel zoom.
15
+ //
16
+ // Why not DDV's usePannedAndZoomedDomain: it exists (rc.4 exports it, rc.5
17
+ // fixes bugs in it), but its wheel handler zooms and calls preventDefault on
18
+ // every wheel event over its target — attached to the plot it would stop the
19
+ // page scrolling over a tall chart — its pan is unbounded, it mutates the
20
+ // source domain object, and its adjusted domain is read-only, so a
21
+ // scrollbar or a jump-to-today could not drive it. The scaler and the domain
22
+ // helpers are DDV's; this file is only the bounded arithmetic, kept pure so
23
+ // the rules are testable.
24
+
25
+ export const MIN_PX_PER_DAY = 6; // a week is 42px: labels stay readable
26
+ export const MAX_PX_PER_DAY = 240; // the zoom-in limit: an hour is 10px
27
+
28
+ export interface Viewport {
29
+ /** Days from t0 at the left edge of the plot. */
30
+ startDay: number;
31
+ /** Days visible across the plot. */
32
+ spanDays: number;
33
+ }
34
+
35
+ export function fitsWithoutScroll(totalDays: number, plotWidth: number): boolean {
36
+ return plotWidth / Math.max(1, totalDays) >= MIN_PX_PER_DAY;
37
+ }
38
+
39
+ /** Keep the span within the zoom limits and the start within the window. */
40
+ export function clampViewport(v: Viewport, totalDays: number, plotWidth: number): Viewport {
41
+ const whole = Math.max(1, totalDays);
42
+ const minSpan = plotWidth > 0 ? Math.min(plotWidth / MAX_PX_PER_DAY, whole) : 1;
43
+ const spanDays = Math.min(Math.max(v.spanDays, minSpan), whole);
44
+ const startDay = Math.min(Math.max(v.startDay, 0), Math.max(0, whole - spanDays));
45
+ return { startDay, spanDays };
46
+ }
47
+
48
+ /** What a fresh window shows: see the header. */
49
+ export function initialViewport(totalDays: number, plotWidth: number, todayDay: number): Viewport {
50
+ const whole = Math.max(1, totalDays);
51
+ if (plotWidth <= 0 || fitsWithoutScroll(whole, plotWidth)) return { startDay: 0, spanDays: whole };
52
+ const spanDays = plotWidth / MIN_PX_PER_DAY;
53
+ const anchor = todayDay >= 0 && todayDay <= whole ? todayDay : whole;
54
+ return clampViewport({ startDay: anchor - spanDays / 2, spanDays }, whole, plotWidth);
55
+ }
56
+
57
+ export function pxPerDay(v: Viewport, plotWidth: number): number {
58
+ return plotWidth / Math.max(1e-9, v.spanDays);
59
+ }
60
+
61
+ /** Move the viewport by a pixel distance: positive reveals later days. */
62
+ export function panViewportPx(v: Viewport, dxPx: number, totalDays: number, plotWidth: number): Viewport {
63
+ return clampViewport({ startDay: v.startDay + dxPx / pxPerDay(v, plotWidth), spanDays: v.spanDays }, totalDays, plotWidth);
64
+ }
65
+
66
+ /**
67
+ * Scale the span by `factor` (> 1 zooms out) keeping the day under
68
+ * `anchorFrac` of the plot width where it is, so the point under the pointer
69
+ * stays under the pointer.
70
+ */
71
+ export function zoomViewport(v: Viewport, factor: number, anchorFrac: number, totalDays: number, plotWidth: number): Viewport {
72
+ const f = Math.min(1, Math.max(0, anchorFrac));
73
+ const anchorDay = v.startDay + f * v.spanDays;
74
+ const spanDays = v.spanDays * factor;
75
+ return clampViewport({ startDay: anchorDay - f * spanDays, spanDays }, totalDays, plotWidth);
76
+ }
77
+
78
+ export function centreViewport(v: Viewport, day: number, totalDays: number, plotWidth: number): Viewport {
79
+ return clampViewport({ startDay: day - v.spanDays / 2, spanDays: v.spanDays }, totalDays, plotWidth);
80
+ }
81
+
82
+ /** The native scrollbar's numbers for this viewport. */
83
+ export function scrollMetrics(v: Viewport, totalDays: number, plotWidth: number) {
84
+ const p = pxPerDay(v, plotWidth);
85
+ return {
86
+ contentWidth: Math.max(1, totalDays) * p,
87
+ scrollLeft: v.startDay * p,
88
+ scrollable: v.spanDays < Math.max(1, totalDays) - 1e-6,
89
+ };
90
+ }
91
+
92
+ export function viewportFromScrollLeft(v: Viewport, scrollLeft: number, totalDays: number, plotWidth: number): Viewport {
93
+ return clampViewport({ startDay: scrollLeft / pxPerDay(v, plotWidth), spanDays: v.spanDays }, totalDays, plotWidth);
94
+ }
95
+
96
+ /** Wheel down (positive deltaY) zooms out; ~500px of wheel doubles the span. */
97
+ export function wheelZoomFactor(deltaY: number): number {
98
+ return Math.exp(Math.max(-300, Math.min(300, deltaY)) * (Math.LN2 / 500));
99
+ }
@@ -0,0 +1,124 @@
1
+ // highlights.ts — pure helpers for the highlight overlays.
2
+ //
3
+ // Three overlays — blocked (has an open blocker), ready-now (spec:ready,
4
+ // unblocked, unassigned or unscheduled), over-cap (humans exceeding their
5
+ // WIP cap).
6
+ //
7
+ // All pure, no I/O, no reactivity — consumers wrap them in Pinia getters
8
+ // and reactively resolve dependencies through the store's state.
9
+
10
+ import type { Bead, Roster } from "./store";
11
+
12
+ // ── readiness core (shared) ───────────────────────────────────────────────
13
+ //
14
+ // One definition of "workable", read by the ready-now highlight and by the
15
+ // MINE shortcuts (shortcuts.ts). Assignment is deliberately not part of it:
16
+ // each caller adds its own clause, and the rule itself is written once.
17
+
18
+ /** Finished or parked: no longer live work. */
19
+ export const FINISHED_STATUSES: readonly string[] = [
20
+ "closed",
21
+ "deferred",
22
+ "tombstone",
23
+ "superseded",
24
+ ];
25
+
26
+ /** Already moving: what counts as work in flight for WIP against a cap. */
27
+ export const IN_FLIGHT_STATUSES: readonly string[] = ["in_progress", "in_review"];
28
+
29
+ /** Finished, parked, or already moving: not workable right now. */
30
+ export const NOT_WORKABLE_STATUSES: readonly string[] = [
31
+ ...FINISHED_STATUSES,
32
+ ...IN_FLIGHT_STATUSES,
33
+ ];
34
+
35
+ /** The label that means the work has been approved for implementation. */
36
+ export const READY_LABEL = "spec:ready";
37
+
38
+ /**
39
+ * Does at least one `blocks` dependency point at a bead that is not closed?
40
+ * Closed deps do not count; deferred ones do (they still gate work). A dep
41
+ * whose bead is not in the map is ignored, as the blocked overlay always has.
42
+ */
43
+ export function hasOpenBlocker(bead: Bead, byId: Map<string, Bead>): boolean {
44
+ for (const d of bead.dependencies ?? []) {
45
+ if (d.type !== "blocks") continue;
46
+ const dep = byId.get(d.depends_on_id);
47
+ if (dep && dep.status !== "closed") return true;
48
+ }
49
+ return false;
50
+ }
51
+
52
+ /**
53
+ * Approved, in a workable status, and unblocked. Nothing about who holds it.
54
+ */
55
+ export function isReadyCore(bead: Bead, byId: Map<string, Bead>): boolean {
56
+ if (NOT_WORKABLE_STATUSES.includes(bead.status)) return false;
57
+ if (!(bead.labels ?? []).includes(READY_LABEL)) return false;
58
+ return !hasOpenBlocker(bead, byId);
59
+ }
60
+
61
+ // ── blocked overlay ───────────────────────────────────────────────────────
62
+
63
+ /**
64
+ * A bead is "blocked" when at least one of its `blocks` dependencies points
65
+ * at another bead that isn't closed. Closed deps don't count. Deferred deps
66
+ * do (they still gate work).
67
+ */
68
+ export function computeBlockedIds(beads: Bead[]): Set<string> {
69
+ const byId = new Map(beads.map((b) => [b.id, b] as const));
70
+ const out = new Set<string>();
71
+ for (const b of beads) if (hasOpenBlocker(b, byId)) out.add(b.id);
72
+ return out;
73
+ }
74
+
75
+ // ── ready-now overlay ─────────────────────────────────────────────────────
76
+
77
+ /**
78
+ * A bead is "ready now" when:
79
+ * - it isn't closed / deferred / superseded
80
+ * - it carries the `spec:ready` label
81
+ * - every `blocks` dep resolves to a closed bead
82
+ * - it isn't currently in-flight (in_progress / in_review)
83
+ *
84
+ * That's the queue of things that could dispatch _right now_ but haven't.
85
+ */
86
+ export function computeReadyNowIds(beads: Bead[]): Set<string> {
87
+ const byId = new Map(beads.map((b) => [b.id, b] as const));
88
+ const out = new Set<string>();
89
+ for (const b of beads) if (isReadyCore(b, byId)) out.add(b.id);
90
+ return out;
91
+ }
92
+
93
+ // ── over-cap overlay ──────────────────────────────────────────────────────
94
+
95
+ const DEFAULT_WIP_CAP = 5;
96
+
97
+ /**
98
+ * Assignees currently over their WIP cap. In-flight = in_progress + in_review.
99
+ * Cap comes from the roster; missing entries fall through to the roster-wide
100
+ * default (or DEFAULT_WIP_CAP when the roster doesn't set one either).
101
+ */
102
+ export function computeOverCapAssignees(
103
+ beads: Bead[],
104
+ roster: Roster,
105
+ ): Set<string> {
106
+ const defaultCap = roster.cap ?? DEFAULT_WIP_CAP;
107
+ const capByHandle = new Map<string, number>();
108
+ for (const h of roster.humans ?? []) {
109
+ capByHandle.set(h.handle, h.cap ?? defaultCap);
110
+ }
111
+ const inFlight = new Map<string, number>();
112
+ for (const b of beads) {
113
+ if (!IN_FLIGHT_STATUSES.includes(b.status)) continue;
114
+ if (!b.assignee) continue;
115
+ inFlight.set(b.assignee, (inFlight.get(b.assignee) ?? 0) + 1);
116
+ }
117
+ const out = new Set<string>();
118
+ for (const [handle, count] of inFlight) {
119
+ const cap = capByHandle.get(handle) ?? defaultCap;
120
+ if (count > cap) out.add(handle);
121
+ }
122
+ return out;
123
+ }
124
+
@@ -0,0 +1,46 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>beadcyte</title>
7
+ <link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
8
+ <!--
9
+ Applies the stored theme and style before the browser paints, so an
10
+ explicit light/dark choice doesn't flash the OS's preference first.
11
+ Plain (non-module) script: runs synchronously during parsing, whereas
12
+ main.ts is deferred by type="module". Mirrors useTheme.ts's keys and
13
+ valid values — keep both in sync. useTheme().init() still runs after
14
+ mount to sync Vue's reactive state; this only covers the pre-JS paint.
15
+ -->
16
+ <script>
17
+ (function () {
18
+ try {
19
+ var q = new URLSearchParams(location.search);
20
+ var t = q.get("theme") || localStorage.getItem("beadcyte:theme");
21
+ if (t === "light" || t === "dark")
22
+ document.documentElement.setAttribute("data-theme", t);
23
+ var fx = q.get("fx");
24
+ var s = fx || localStorage.getItem("beadcyte:style");
25
+ // Every named style except default. theme.test.mjs checks this list
26
+ // against STYLE_NAMES, so a style added there but not here fails a
27
+ // test instead of flashing the default palette before hydration.
28
+ var STYLES = ["high-contrast", "catppuccin", "dracula", "github", "gruvbox", "nord", "one", "solarized", "synthwave", "terminal", "tokyonight"];
29
+ if (STYLES.indexOf(s) >= 0)
30
+ document.documentElement.setAttribute("data-style", s);
31
+ } catch (e) {
32
+ /* storage blocked — falls back to prefers-color-scheme */
33
+ }
34
+ })();
35
+ </script>
36
+ <!-- Stylesheets are imported from main.ts, not linked here. A plain
37
+ <link> sits outside Vite's module graph, so editing tokens.css or
38
+ styles-alt.css never triggered HMR: component changes hot-reloaded
39
+ while the colour layer silently stayed at whatever first loaded,
40
+ which looks exactly like a change not taking effect. -->
41
+ </head>
42
+ <body>
43
+ <div id="app"></div>
44
+ <script type="module" src="./main.ts"></script>
45
+ </body>
46
+ </html>
@@ -0,0 +1,107 @@
1
+ // insights.ts — pure derivations for the project-wide summary charts.
2
+ //
3
+ // Small on purpose. The two charts in app-main (bp-67g.30) read data the
4
+ // pipeline already produced: the weekly ship series comes straight off
5
+ // computeVelocity, and group progress is a reshape of the view model's own
6
+ // groups. Neither walks the bead list again.
7
+ //
8
+ // No DOM, no reactivity, no I/O.
9
+
10
+ import type { Group } from "./view-model";
11
+
12
+ /** What groupProgress reads: the view model's groups, or projectGroups(). */
13
+ export type ProgressSource = Pick<Group, "id" | "label" | "progress">;
14
+
15
+ export interface GroupProgressRow {
16
+ id: string;
17
+ label: string;
18
+ closed: number;
19
+ total: number;
20
+ /** 0..1. Zero-population groups read as 0 rather than NaN. */
21
+ fraction: number;
22
+ /**
23
+ * True for the view model's placeholder buckets (`__no_epic`,
24
+ * `__unassigned`, `__all`).
25
+ *
26
+ * These are the REMAINDER, not a group whose size means anything beside a
27
+ * real one, and they are usually the largest thing in the list — 305 of
28
+ * 556 beads on the repo this was built against. Leaving one in the bars
29
+ * put every real group on 13% of the width, so callers are expected to
30
+ * split on this and report the remainder separately.
31
+ */
32
+ synthetic: boolean;
33
+ }
34
+
35
+ /**
36
+ * Groups by population, largest first.
37
+ *
38
+ * Encodes `progress.total`, never `rowCount`. rowCount is what the Gantt
39
+ * DRAWS — clamped at maxPerGroup — so on a repo with several large epics it
40
+ * reports the same number for all of them and a chart of it is flat-topped
41
+ * by construction. `progress` is counted over the whole group before any
42
+ * filter or window clipping, which is also why closed-vs-open comes along
43
+ * for free and stays honest when the user hides closed beads.
44
+ *
45
+ * Fed by projectGroups() since bp-67g.36, so the SET of groups is project-wide
46
+ * as well as the counts: a window that clips every bead of a group no longer
47
+ * drops the group from the chart.
48
+ */
49
+ export function groupProgress(groups: readonly ProgressSource[]): GroupProgressRow[] {
50
+ return groups
51
+ .map((g) => ({
52
+ id: g.id,
53
+ label: g.label,
54
+ closed: g.progress.closed,
55
+ total: g.progress.total,
56
+ fraction: g.progress.total > 0 ? g.progress.closed / g.progress.total : 0,
57
+ synthetic: g.id.startsWith("__"),
58
+ }))
59
+ .sort((a, b) => b.total - a.total || a.id.localeCompare(b.id));
60
+ }
61
+
62
+ export interface ShipWeekPoint {
63
+ weekStart: Date;
64
+ count: number;
65
+ }
66
+
67
+ export interface ShipTrend {
68
+ weeks: ShipWeekPoint[];
69
+ total: number;
70
+ /** Mean ships per week over the series — what the calibration band shows. */
71
+ perWeek: number;
72
+ /** The most recent complete week. */
73
+ last: number;
74
+ /**
75
+ * Change from the mean, as a signed fraction. Compared against the mean
76
+ * rather than the previous week, because week-to-week on counts this small
77
+ * is mostly noise and the question is "are we above or below our own
78
+ * normal". Null when there is no history to be normal.
79
+ *
80
+ * "No history" means fewer than two weeks that shipped anything, not
81
+ * merely a zero total. With a single non-zero week the mean IS that week
82
+ * divided by the series length, so the figure is decided by the window
83
+ * size rather than by the data: 15 ships in one week of an 8-week window
84
+ * reports +700%, and the identical beads under a 4-week window report
85
+ * +300%. That is an artefact with a percent sign on it.
86
+ */
87
+ vsMean: number | null;
88
+ /** Weeks that shipped anything. Below 2, `vsMean` is null. */
89
+ activeWeeks: number;
90
+ }
91
+
92
+ /** Reshape computeVelocity's weekly buckets for the trend chart. */
93
+ export function shipTrend(weekly: readonly ShipWeekPoint[]): ShipTrend {
94
+ const weeks = weekly.map((w) => ({ ...w }));
95
+ const total = weeks.reduce((a, w) => a + w.count, 0);
96
+ const perWeek = weeks.length ? total / weeks.length : 0;
97
+ const last = weeks.length ? weeks[weeks.length - 1].count : 0;
98
+ const activeWeeks = weeks.filter((w) => w.count > 0).length;
99
+ return {
100
+ weeks,
101
+ total,
102
+ perWeek,
103
+ last,
104
+ activeWeeks,
105
+ vsMean: activeWeeks >= 2 && perWeek > 0 ? (last - perWeek) / perWeek : null,
106
+ };
107
+ }