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,125 @@
1
+ // useTheme.ts — applies the theme mode and style family to <html>.
2
+ //
3
+ // theme.ts owns what a valid preference is; this owns the reactive state,
4
+ // the root attributes and persistence. Module-level singletons so every
5
+ // consumer shares one source of truth.
6
+
7
+ import { computed, readonly, ref } from "vue";
8
+ import {
9
+ STYLE_STORAGE_KEY,
10
+ THEME_STORAGE_KEY,
11
+ parseStyleName,
12
+ parseThemeMode,
13
+ resolveIsDark,
14
+ nextThemeMode,
15
+ type StyleName,
16
+ type ThemeMode,
17
+ } from "../theme";
18
+
19
+ /**
20
+ * Reading localStorage THROWS when storage is blocked (not just returns
21
+ * null), and this runs at module load — so an unguarded read takes the whole
22
+ * app down to a blank page rather than merely losing a preference.
23
+ */
24
+ function readStored(key: string): string | null {
25
+ try {
26
+ return localStorage.getItem(key);
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function writeStored(key: string, value: string) {
33
+ try {
34
+ localStorage.setItem(key, value);
35
+ } catch {
36
+ // Private mode or a full quota. The choice still applies for this
37
+ // session, it just won't survive a reload.
38
+ }
39
+ }
40
+
41
+ // ?theme= overrides the stored mode for one visit without persisting, the
42
+ // same way ?fx= overrides the style. Useful for sharing or inspecting a
43
+ // specific appearance, and the only way to reach a mode the OS isn't in
44
+ // without changing OS settings.
45
+ function detectMode(): ThemeMode {
46
+ const stored = parseThemeMode(readStored(THEME_STORAGE_KEY));
47
+ if (typeof location === "undefined") return stored;
48
+ const q = new URLSearchParams(location.search).get("theme");
49
+ return q ? parseThemeMode(q) : stored;
50
+ }
51
+
52
+ const mode = ref<ThemeMode>(detectMode());
53
+
54
+ // ?fx= overrides the stored style for one visit without persisting, so a
55
+ // style can be shared or tried by link.
56
+ //
57
+ // DECISION (bp-67g.14): appearance is an override, not URL state. ?view=,
58
+ // ?group= and the filters round-trip — they describe what you are looking
59
+ // AT, so a link should reproduce it. A theme describes how you like to look
60
+ // at it, which is a property of the reader, not the thing. Writing it into
61
+ // the URL would mean every shared link imposed the sender's taste on the
62
+ // recipient. So it persists per browser and the query param is a one-visit
63
+ // override for sharing or inspecting a specific appearance.
64
+ function detectStyle(): StyleName {
65
+ const stored = parseStyleName(readStored(STYLE_STORAGE_KEY));
66
+ if (typeof location === "undefined") return stored;
67
+ const fx = new URLSearchParams(location.search).get("fx");
68
+ return fx ? parseStyleName(fx) : stored;
69
+ }
70
+
71
+ const style = ref<StyleName>(detectStyle());
72
+
73
+ const mql =
74
+ typeof window !== "undefined" && window.matchMedia
75
+ ? window.matchMedia("(prefers-color-scheme: dark)")
76
+ : null;
77
+ const prefersDark = ref(mql ? mql.matches : true);
78
+ mql?.addEventListener("change", (e) => (prefersDark.value = e.matches));
79
+
80
+ /** What's on screen right now, with 'system' already resolved. */
81
+ const isDark = computed(() => resolveIsDark(mode.value, prefersDark.value));
82
+
83
+ function applyMode(value: ThemeMode) {
84
+ const root = document.documentElement;
85
+ // Absent attribute is what lets color-scheme fall through to the OS, so
86
+ // 'system' removes rather than writes.
87
+ if (value === "system") root.removeAttribute("data-theme");
88
+ else root.setAttribute("data-theme", value);
89
+ }
90
+
91
+ function applyStyle(value: StyleName) {
92
+ const root = document.documentElement;
93
+ if (value === "default") root.removeAttribute("data-style");
94
+ else root.setAttribute("data-style", value);
95
+ }
96
+
97
+ export function useTheme() {
98
+ return {
99
+ mode: readonly(mode),
100
+ style: readonly(style),
101
+ isDark,
102
+
103
+ setMode(value: ThemeMode) {
104
+ mode.value = value;
105
+ writeStored(THEME_STORAGE_KEY, value);
106
+ applyMode(value);
107
+ },
108
+
109
+ cycleMode() {
110
+ this.setMode(nextThemeMode(mode.value));
111
+ },
112
+
113
+ setStyle(value: StyleName) {
114
+ style.value = value;
115
+ writeStored(STYLE_STORAGE_KEY, value);
116
+ applyStyle(value);
117
+ },
118
+
119
+ /** Push the loaded preferences onto <html>. Call once at startup. */
120
+ init() {
121
+ applyMode(mode.value);
122
+ applyStyle(style.value);
123
+ },
124
+ };
125
+ }
@@ -0,0 +1,208 @@
1
+ import { watch } from "vue";
2
+ import { useBeadsStore } from "../store";
3
+ import {
4
+ parseGroupBy,
5
+ parseView,
6
+ serializeArray,
7
+ deserializeStrings,
8
+ deserializeNumbers,
9
+ serializeDate,
10
+ parseDate,
11
+ legacyHiddenStatuses,
12
+ } from "./url-codec";
13
+
14
+ /**
15
+ * Hydrate the store from the URL query string on mount.
16
+ */
17
+ export function useUrlState() {
18
+ const store = useBeadsStore();
19
+
20
+ // Hydrate from URL on initial load
21
+ function hydrateFromUrl() {
22
+ const urlParams = new URLSearchParams(window.location.search);
23
+
24
+ // Handle groupBy. parseGroupBy returns null for anything unrecognised so
25
+ // the store keeps its default rather than being cast into a bad mode.
26
+ const groupBy = parseGroupBy(urlParams.get("group"));
27
+ if (groupBy) store.setGroupBy(groupBy);
28
+
29
+ // Handle view. Unlike the others this always resolves — a missing or
30
+ // unrecognised value means the Gantt.
31
+ store.setView(parseView(urlParams.get("view")));
32
+
33
+ // Handle filters - types
34
+ const types = urlParams.get("type");
35
+ if (types) {
36
+ store.setFilters({ types: deserializeStrings(types) });
37
+ }
38
+
39
+ // Handle filters - priorities
40
+ const priorities = urlParams.get("priority");
41
+ if (priorities) {
42
+ store.setFilters({ priorities: deserializeNumbers(priorities) });
43
+ }
44
+
45
+ // Handle filters - statuses
46
+ const statuses = urlParams.get("status");
47
+ if (statuses) {
48
+ store.setFilters({ statuses: deserializeStrings(statuses) });
49
+ }
50
+
51
+ // Handle filters - labels
52
+ const labels = urlParams.get("label");
53
+ if (labels) {
54
+ store.setFilters({ labels: deserializeStrings(labels) });
55
+ }
56
+
57
+ // Handle filters - assignees
58
+ const assignees = urlParams.get("assignee");
59
+ if (assignees) {
60
+ store.setFilters({ assignees: deserializeStrings(assignees) });
61
+ }
62
+
63
+ // The unblocked term (bp-egy): a flag, on or absent.
64
+ if (urlParams.get("unblocked") === "1") {
65
+ store.setFilters({ unblocked: true });
66
+ }
67
+
68
+ // Current user carried by a shared link. Wins for this load only; the
69
+ // stored choice is untouched, the same rule ?theme= follows.
70
+ const me = urlParams.get("me");
71
+ if (me) store.setUrlUser(me);
72
+
73
+ // The person the incytes view is scoped to (bp-67g.43). Handles are not
74
+ // private, so a link carrying one is meaningful anywhere the repo is.
75
+ const who = urlParams.get("who");
76
+ if (who) store.setWho(who);
77
+
78
+ // Legacy `?showClosed=`/`?includeDeferred=` (pre-bp-67g.40). Both were
79
+ // separate booleans that silently overrode `?status=`; they are now just
80
+ // statuses. An explicit `?status=` in the same URL wins outright — it is
81
+ // the newer, more specific instruction — otherwise these two decide
82
+ // whether `closed` and `deferred` join the default selection, which
83
+ // cannot be assembled until the beads say which statuses exist.
84
+ if (!statuses) {
85
+ store.setPendingLegacyStatuses(legacyHiddenStatuses(urlParams));
86
+ }
87
+
88
+ // Handle window - from
89
+ const from = urlParams.get("from");
90
+ if (from) {
91
+ const parsedFrom = parseDate(from);
92
+ if (parsedFrom) {
93
+ // Update window from
94
+ const window = { ...store.window };
95
+ window.from = parsedFrom;
96
+ store.setWindow(window);
97
+ }
98
+ }
99
+
100
+ // Handle window - to
101
+ const to = urlParams.get("to");
102
+ if (to) {
103
+ const parsedTo = parseDate(to);
104
+ if (parsedTo) {
105
+ // Update window to
106
+ const window = { ...store.window };
107
+ window.to = parsedTo;
108
+ store.setWindow(window);
109
+ }
110
+ }
111
+
112
+ }
113
+
114
+ // Watch changes to the store and update URL
115
+ watch(
116
+ () => ({
117
+ view: store.view,
118
+ groupBy: store.groupBy,
119
+ filters: store.filters,
120
+ window: store.window,
121
+ me: store.effectiveUser,
122
+ who: store.who,
123
+ }),
124
+ (newState) => {
125
+ // Don't update URL for search text or other transient state
126
+ const ignoreList = new Set(["search"]); // Add any other ignore list fields here
127
+
128
+ const urlParams = new URLSearchParams(window.location.search);
129
+
130
+ // Update view + groupBy in URL
131
+ urlParams.set("view", newState.view);
132
+ urlParams.set("group", newState.groupBy);
133
+
134
+ // Update filters in URL
135
+ if (newState.filters.types) {
136
+ urlParams.set("type", serializeArray(newState.filters.types));
137
+ } else {
138
+ urlParams.delete("type");
139
+ }
140
+
141
+ if (newState.filters.priorities) {
142
+ urlParams.set("priority", serializeArray(newState.filters.priorities));
143
+ } else {
144
+ urlParams.delete("priority");
145
+ }
146
+
147
+ if (newState.filters.statuses) {
148
+ urlParams.set("status", serializeArray(newState.filters.statuses));
149
+ } else {
150
+ urlParams.delete("status");
151
+ }
152
+
153
+ if (newState.filters.labels) {
154
+ urlParams.set("label", serializeArray(newState.filters.labels));
155
+ } else {
156
+ urlParams.delete("label");
157
+ }
158
+
159
+ if (newState.filters.assignees) {
160
+ urlParams.set("assignee", serializeArray(newState.filters.assignees));
161
+ } else {
162
+ urlParams.delete("assignee");
163
+ }
164
+
165
+ if (newState.filters.unblocked) {
166
+ urlParams.set("unblocked", "1");
167
+ } else {
168
+ urlParams.delete("unblocked");
169
+ }
170
+
171
+ if (newState.me) {
172
+ urlParams.set("me", newState.me);
173
+ } else {
174
+ urlParams.delete("me");
175
+ }
176
+
177
+ if (newState.who) {
178
+ urlParams.set("who", newState.who);
179
+ } else {
180
+ urlParams.delete("who");
181
+ }
182
+
183
+ // `includeDeferred` and `showClosed` are no longer written. Deleted
184
+ // rather than left alone, so following an old link and then copying
185
+ // the address bar does not hand someone a URL carrying params this
186
+ // build ignores on write and reinterprets on read.
187
+ urlParams.delete("includeDeferred");
188
+ urlParams.delete("showClosed");
189
+
190
+ // Handle window - from
191
+ urlParams.set("from", serializeDate(newState.window.from));
192
+
193
+ // Handle window - to
194
+ urlParams.set("to", serializeDate(newState.window.to));
195
+
196
+
197
+ // Push changes to URL using replaceState (no history entry). The hash
198
+ // is the drawer's deep link (App.vue): writing the query without it
199
+ // wiped the bead hash before the payload arrived and the hash handler
200
+ // ran, so a shared drawer link opened the chart and no drawer.
201
+ history.replaceState(null, "", `?${urlParams.toString()}${location.hash}`);
202
+ },
203
+ { deep: true }
204
+ );
205
+
206
+ // Initialize from URL
207
+ hydrateFromUrl();
208
+ }
@@ -0,0 +1,83 @@
1
+ // controls-scope.ts — which controls act on which view (bp-67g.36).
2
+ //
3
+ // The controls panel offers the same controls on every view, and on some
4
+ // views most of them do nothing visible. A control that looks live and is
5
+ // inert is worse than one that is absent: the user concludes the data is
6
+ // wrong rather than that the control is irrelevant. This module is the one
7
+ // place that says what each view listens to, derived from what the views
8
+ // actually read:
9
+ //
10
+ // mine buildMineModel(store.beads, …): the raw bead list for one person.
11
+ // Nothing in the panel but the view switch reaches it.
12
+ // gantt the filtered, windowed view model, with highlight overlays and
13
+ // dependency arrows. Everything applies.
14
+ // grid the same view model and highlights as cards; it draws no arrows.
15
+ // table the same set with the per-group cap lifted, sorted by column
16
+ // (bp-67g.44). Nothing is painted on it: highlight and arrows are
17
+ // the Gantt's and the grid's.
18
+ // incytes project-wide by design: the trend keeps its own eight-week
19
+ // window, the roster is static, and Progress groups every bead
20
+ // (projectGroups). Only group-by changes what it shows.
21
+ //
22
+ // Pure, so the matrix is testable; the panel reads it through
23
+ // controlApplies() and prints scopeNote() once when something is dimmed.
24
+
25
+ import type { ViewMode } from "./composables/url-codec";
26
+
27
+ export type ControlId =
28
+ | "groupBy"
29
+ | "watch"
30
+ | "mine"
31
+ | "filters"
32
+ | "window"
33
+ | "highlight"
34
+ | "deps"
35
+ | "whatif";
36
+
37
+ export const CONTROL_IDS: readonly ControlId[] = [
38
+ "groupBy",
39
+ "watch",
40
+ "mine",
41
+ "filters",
42
+ "window",
43
+ "highlight",
44
+ "deps",
45
+ "whatif",
46
+ ];
47
+
48
+ const LIVE: Record<ViewMode, ReadonlySet<ControlId>> = {
49
+ mine: new Set<ControlId>(),
50
+ gantt: new Set<ControlId>(CONTROL_IDS),
51
+ grid: new Set<ControlId>(["groupBy", "watch", "mine", "filters", "window", "highlight", "whatif"]),
52
+ table: new Set<ControlId>(["groupBy", "watch", "mine", "filters", "window", "whatif"]),
53
+ incytes: new Set<ControlId>(["groupBy", "whatif"]),
54
+ };
55
+
56
+ /** Does this control change what the view shows? */
57
+ export function controlApplies(view: ViewMode, control: ControlId): boolean {
58
+ return LIVE[view].has(control);
59
+ }
60
+
61
+ /** The controls a view does not act on, in panel order. */
62
+ export function inertControls(view: ViewMode): ControlId[] {
63
+ return CONTROL_IDS.filter((c) => !LIVE[view].has(c));
64
+ }
65
+
66
+ /**
67
+ * One sentence for the panel to print when something is dimmed: what the
68
+ * view is, and where the dimmed controls do work. Null when nothing is.
69
+ */
70
+ export function scopeNote(view: ViewMode): string | null {
71
+ switch (view) {
72
+ case "mine":
73
+ return "mine lists one person's work from every bead — the other controls act on the gantt and grid";
74
+ case "incytes":
75
+ return "incytes is project-wide — only group by changes it; filters, window and highlight act on the gantt and grid";
76
+ case "grid":
77
+ return "dependency arrows are drawn on the gantt only";
78
+ case "table":
79
+ return "highlight paints gantt bars and grid cards, and arrows the gantt only — sort a column instead";
80
+ case "gantt":
81
+ return null;
82
+ }
83
+ }
@@ -0,0 +1,251 @@
1
+ // cost.ts — the cost trajectory: seat hours and tokens per week, measured
2
+ // behind us and projected ahead (bp-0z5).
3
+ //
4
+ // Beads already carry what they cost: metadata.economics.by_seat (hours per
5
+ // seat, with sessions) and totals (tokens in/out). The ship trend buckets
6
+ // closes into weeks; this buckets the COST of those closes into the same
7
+ // weeks, so the two charts share an axis. Ahead of today, every scheduled
8
+ // bead is given the median cost of comparable shipped work — by issue type,
9
+ // falling back to everything when a type has too few samples — and that cost
10
+ // lands in the week the two-clock schedule says the bead lands.
11
+ //
12
+ // What is measured and what is projected never mix: a week is one or the
13
+ // other (`measured`), the projected weeks are drawn dulled, and the medians
14
+ // that made them are reported with their sample size. Nothing here is a
15
+ // forecast of what a bead WILL cost; it is what comparable work DID cost,
16
+ // placed where the plan puts the bead.
17
+
18
+ import type { Bead } from "./store";
19
+ import type { Schedule } from "./view-model";
20
+ import { resolveAssignee } from "./view-model.ts";
21
+ import { buildEconomics } from "./economics.ts";
22
+ import { FINISHED_STATUSES } from "./highlights.ts";
23
+
24
+ const MS_PER_WEEK = 7 * 86_400_000;
25
+ const MS_PER_DAY = 86_400_000;
26
+
27
+ export interface CostWeek {
28
+ weekStart: Date;
29
+ /** Hours per seat name ("agent", "human", …) as recorded, or as median. */
30
+ hoursBySeat: Record<string, number>;
31
+ hours: number;
32
+ /** Null when no bead in the week recorded tokens. */
33
+ tokensIn: number | null;
34
+ tokensOut: number | null;
35
+ /** Beads that contributed (closed with economics; or scheduled to land). */
36
+ beads: number;
37
+ /** True: read off records. False: medians placed by the schedule. */
38
+ measured: boolean;
39
+ }
40
+
41
+ export interface CostMedians {
42
+ hoursBySeat: Record<string, number>;
43
+ tokensIn: number | null;
44
+ tokensOut: number | null;
45
+ /** Beads the medians were taken over. */
46
+ sample: number;
47
+ /** "task" when the type had enough samples, "all" when it fell back. */
48
+ bucket: string;
49
+ }
50
+
51
+ export interface CostSeries {
52
+ past: CostWeek[];
53
+ projected: CostWeek[];
54
+ /** Every seat seen, most hours first, so stacks and tables share an order. */
55
+ seats: string[];
56
+ /** Closed beads with recorded economics, project-wide: the projection's evidence. */
57
+ sample: number;
58
+ /** Of those, records that declare themselves reconstructed rather than measured. */
59
+ reconstructed: number;
60
+ horizonWeeks: number;
61
+ }
62
+
63
+ export interface CostOptions {
64
+ /** Restrict to one person: measured by bead assignee, projected by the schedule's (resolved) assignee. */
65
+ handle?: string | null;
66
+ /** How far ahead to project, in days; rounded up to whole weeks, at most 12. */
67
+ horizonDays?: number;
68
+ now?: number;
69
+ }
70
+
71
+ /** Below this many beads of a type, the medians fall back to every type. */
72
+ export const MIN_BUCKET_SAMPLE = 3;
73
+ const MAX_HORIZON_WEEKS = 12;
74
+
75
+ const isEpic = (b: Bead) => b.issue_type === "epic";
76
+
77
+ function median(xs: number[]): number {
78
+ const s = [...xs].sort((a, b) => a - b);
79
+ const n = s.length;
80
+ if (!n) return 0;
81
+ return n % 2 ? s[(n - 1) / 2]! : (s[n / 2 - 1]! + s[n / 2]!) / 2;
82
+ }
83
+
84
+ interface Recorded {
85
+ bead: Bead;
86
+ hoursBySeat: Record<string, number>;
87
+ tokensIn: number | null;
88
+ tokensOut: number | null;
89
+ reconstructed: boolean;
90
+ }
91
+
92
+ /** Closed beads whose economics were written down, project-wide. */
93
+ function recorded(beads: readonly Bead[]): Recorded[] {
94
+ const out: Recorded[] = [];
95
+ for (const b of beads) {
96
+ if (b.status !== "closed" || !b.closed_at) continue;
97
+ const e = buildEconomics(b);
98
+ if (!e.hasAny) continue;
99
+ const hoursBySeat: Record<string, number> = {};
100
+ for (const s of e.seats) hoursBySeat[s.seat] = (hoursBySeat[s.seat] ?? 0) + s.hours;
101
+ if (!e.seats.length && e.totals.agentHours !== null) hoursBySeat.agent = e.totals.agentHours;
102
+ // Tokens: the rolled-up totals when written, else summed from the
103
+ // sessions that carry them (this repo's records put them there).
104
+ let tokensIn = e.totals.tokensIn;
105
+ let tokensOut = e.totals.tokensOut;
106
+ if (tokensIn === null && tokensOut === null) {
107
+ for (const seat of e.seats) {
108
+ for (const ses of seat.sessions) {
109
+ if (ses.tokensIn !== null) tokensIn = (tokensIn ?? 0) + ses.tokensIn;
110
+ if (ses.tokensOut !== null) tokensOut = (tokensOut ?? 0) + ses.tokensOut;
111
+ }
112
+ }
113
+ }
114
+ const reconstructed = Object.values(e.provenance).some((p) => p?.kind === "reconstructed");
115
+ out.push({ bead: b, hoursBySeat, tokensIn, tokensOut, reconstructed });
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /**
121
+ * Median cost of comparable shipped work, for one issue type. Hours per seat
122
+ * are medians over the beads that recorded that seat; tokens over the beads
123
+ * that recorded tokens. Null medians mean nobody wrote tokens down.
124
+ */
125
+ export function costMedians(recs: readonly Recorded[], type: string): CostMedians {
126
+ let pool = recs.filter((r) => r.bead.issue_type === type);
127
+ let bucket = type;
128
+ if (pool.length < MIN_BUCKET_SAMPLE) {
129
+ pool = [...recs];
130
+ bucket = "all";
131
+ }
132
+ const seats = new Set<string>();
133
+ for (const r of pool) for (const s of Object.keys(r.hoursBySeat)) seats.add(s);
134
+ const hoursBySeat: Record<string, number> = {};
135
+ for (const s of seats) hoursBySeat[s] = median(pool.filter((r) => s in r.hoursBySeat).map((r) => r.hoursBySeat[s]!));
136
+ const tin = pool.filter((r) => r.tokensIn !== null).map((r) => r.tokensIn!);
137
+ const tout = pool.filter((r) => r.tokensOut !== null).map((r) => r.tokensOut!);
138
+ return {
139
+ hoursBySeat,
140
+ tokensIn: tin.length ? median(tin) : null,
141
+ tokensOut: tout.length ? median(tout) : null,
142
+ sample: pool.length,
143
+ bucket,
144
+ };
145
+ }
146
+
147
+ function emptyWeek(weekStart: Date, measured: boolean): CostWeek {
148
+ return { weekStart, hoursBySeat: {}, hours: 0, tokensIn: null, tokensOut: null, beads: 0, measured };
149
+ }
150
+
151
+ function addTo(w: CostWeek, hoursBySeat: Record<string, number>, tokensIn: number | null, tokensOut: number | null) {
152
+ for (const [s, h] of Object.entries(hoursBySeat)) {
153
+ w.hoursBySeat[s] = (w.hoursBySeat[s] ?? 0) + h;
154
+ w.hours += h;
155
+ }
156
+ if (tokensIn !== null) w.tokensIn = (w.tokensIn ?? 0) + tokensIn;
157
+ if (tokensOut !== null) w.tokensOut = (w.tokensOut ?? 0) + tokensOut;
158
+ w.beads += 1;
159
+ }
160
+
161
+ /**
162
+ * Past weeks in the ship trend's own buckets, projected weeks continuing
163
+ * the same grid from the end of the last bucket.
164
+ */
165
+ export function costSeries(
166
+ beads: readonly Bead[],
167
+ schedule: Schedule | null,
168
+ weekly: ReadonlyArray<{ weekStart: Date }>,
169
+ opts: CostOptions = {},
170
+ ): CostSeries {
171
+ const now = opts.now ?? Date.now();
172
+ const handle = opts.handle ?? null;
173
+ const horizonWeeks = Math.max(1, Math.min(MAX_HORIZON_WEEKS, Math.ceil((opts.horizonDays ?? 56) / 7)));
174
+
175
+ const recs = recorded(beads);
176
+
177
+ // ── measured ──
178
+ const past: CostWeek[] = weekly.map((w) => emptyWeek(w.weekStart, true));
179
+ const gridStart = weekly.length ? weekly[0]!.weekStart.getTime() : now;
180
+ const gridEnd = weekly.length ? weekly[weekly.length - 1]!.weekStart.getTime() + MS_PER_WEEK : now;
181
+ for (const r of recs) {
182
+ if (handle && r.bead.assignee !== handle) continue;
183
+ const t = new Date(r.bead.closed_at!).getTime();
184
+ if (!(t >= gridStart && t < gridEnd)) continue;
185
+ const i = Math.min(past.length - 1, Math.floor((t - gridStart) / MS_PER_WEEK));
186
+ addTo(past[i]!, r.hoursBySeat, r.tokensIn, r.tokensOut);
187
+ }
188
+
189
+ // ── projected ──
190
+ const projected: CostWeek[] = Array.from({ length: horizonWeeks }, (_, k) => emptyWeek(new Date(gridEnd + k * MS_PER_WEEK), false));
191
+ const medianByType = new Map<string, CostMedians>();
192
+ if (schedule && recs.length) {
193
+ for (const b of beads) {
194
+ if (isEpic(b) || FINISHED_STATUSES.includes(b.status)) continue;
195
+ const sched = schedule.get(b.id);
196
+ if (!sched || sched.end_day < 0) continue;
197
+ if (handle && resolveAssignee(b, sched).handle !== handle) continue;
198
+ const landing = now + sched.end_day * MS_PER_DAY;
199
+ const k = Math.floor((landing - gridEnd) / MS_PER_WEEK);
200
+ if (k < 0 || k >= horizonWeeks) continue;
201
+ const type = b.issue_type ?? "task";
202
+ let m = medianByType.get(type);
203
+ if (!m) {
204
+ m = costMedians(recs, type);
205
+ medianByType.set(type, m);
206
+ }
207
+ addTo(projected[k]!, m.hoursBySeat, m.tokensIn, m.tokensOut);
208
+ }
209
+ }
210
+
211
+ const seatHours = new Map<string, number>();
212
+ for (const w of [...past, ...projected]) for (const [s, h] of Object.entries(w.hoursBySeat)) seatHours.set(s, (seatHours.get(s) ?? 0) + h);
213
+ const seats = [...seatHours.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([s]) => s);
214
+
215
+ return { past, projected, seats, sample: recs.length, reconstructed: recs.filter((r) => r.reconstructed).length, horizonWeeks };
216
+ }
217
+
218
+ /** Per-week averages over a run of weeks, for the head figures. */
219
+ export function weeklyAverage(weeks: readonly CostWeek[]): { hours: number; hoursBySeat: Record<string, number>; tokensIn: number | null; tokensOut: number | null; beads: number } {
220
+ const n = Math.max(1, weeks.length);
221
+ const hoursBySeat: Record<string, number> = {};
222
+ let hours = 0;
223
+ let beads = 0;
224
+ let tin: number | null = null;
225
+ let tout: number | null = null;
226
+ for (const w of weeks) {
227
+ hours += w.hours;
228
+ beads += w.beads;
229
+ for (const [s, h] of Object.entries(w.hoursBySeat)) hoursBySeat[s] = (hoursBySeat[s] ?? 0) + h;
230
+ if (w.tokensIn !== null) tin = (tin ?? 0) + w.tokensIn;
231
+ if (w.tokensOut !== null) tout = (tout ?? 0) + w.tokensOut;
232
+ }
233
+ for (const s of Object.keys(hoursBySeat)) hoursBySeat[s] = hoursBySeat[s]! / n;
234
+ return { hours: hours / n, hoursBySeat, tokensIn: tin === null ? null : tin / n, tokensOut: tout === null ? null : tout / n, beads };
235
+ }
236
+
237
+ /** Hours with the precision the size needs: "12h", "3.4h", "0.38h" — never a "0.0h" that hides a number. */
238
+ export function fmtHours(h: number): string {
239
+ if (h >= 10) return `${h.toFixed(0)}h`;
240
+ if (h >= 1) return `${h.toFixed(1)}h`;
241
+ if (h > 0) return `${h.toFixed(2)}h`;
242
+ return "0h";
243
+ }
244
+
245
+ /** "1.2k", "34k", "1.5M" — tokens are big numbers and the chart is small. */
246
+ export function fmtTokens(n: number | null): string {
247
+ if (n === null) return "—";
248
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
249
+ if (n >= 1e3) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`;
250
+ return n.toFixed(0);
251
+ }