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,200 @@
1
+ // keybindings.ts — the one table of keyboard shortcuts (bp-67g.45).
2
+ //
3
+ // Everything the keyboard does is declared here and nowhere else: the
4
+ // composable that listens (composables/useKeyboard.ts) matches against this
5
+ // table, and the ? overlay (components/ShortcutsOverlay.vue) is generated
6
+ // from it, so the guide cannot drift from the bindings.
7
+ //
8
+ // THE CURSOR (decided before any key was bound). There is no separate
9
+ // "focused row". The keyboard cursor IS the open drawer: j and k move the
10
+ // drawer to the next or previous bead in the active view's order, and open
11
+ // it on the first or last bead when it is closed. Pin, copy and the subgraph
12
+ // scope act on the open bead. Hover stays what it is — a transient tooltip —
13
+ // and the views mark the open bead's row, so exactly one thing ever looks
14
+ // selected. The app used to have a third notion, the Gantt's focus mode; it
15
+ // was removed (bp-6tn), which is what made this reconciliation possible.
16
+ //
17
+ // THE CONVENTION. Single letters, no modifier — vim's j/k where the habit
18
+ // exists, digits for the view tabs — because this is a tool people keep
19
+ // open, and reaching for a chord every time is the tax that stops shortcuts
20
+ // being used. The cost is collision with typing, so no binding fires while
21
+ // an input, textarea, select or contenteditable has focus (isTypingTarget),
22
+ // which also covers the search palette and the options menu's fields. The
23
+ // only chord is ⌘K / Ctrl+K, kept because it is what every tool means by
24
+ // "search"; / opens the same palette for the modifier-averse.
25
+
26
+ export type KeyGroup =
27
+ | "View and navigation"
28
+ | "Drawer and selection"
29
+ | "Filters and toggles"
30
+ | "Appearance"
31
+ | "Search and help";
32
+
33
+ export const KEY_GROUPS: readonly KeyGroup[] = [
34
+ "View and navigation",
35
+ "Drawer and selection",
36
+ "Filters and toggles",
37
+ "Appearance",
38
+ "Search and help",
39
+ ];
40
+
41
+ export type KeyAction =
42
+ | "view-mine"
43
+ | "view-gantt"
44
+ | "view-grid"
45
+ | "view-table"
46
+ | "view-incytes"
47
+ | "cycle-group"
48
+ | "cycle-window"
49
+ | "today"
50
+ | "next-bead"
51
+ | "prev-bead"
52
+ | "open-first"
53
+ | "close"
54
+ | "toggle-watch"
55
+ | "toggle-scope"
56
+ | "copy-id"
57
+ | "actions"
58
+ | "hl-blocked"
59
+ | "hl-over-cap"
60
+ | "hl-ready-now"
61
+ | "toggle-deps"
62
+ | "clear-filters"
63
+ | "toggle-closed"
64
+ | "cycle-theme"
65
+ | "cycle-style"
66
+ | "options"
67
+ | "search"
68
+ | "help";
69
+
70
+ /** A key as KeyboardEvent.key reports it, plus the modifiers that must be held. */
71
+ export interface KeyChord {
72
+ key: string;
73
+ ctrl?: boolean;
74
+ meta?: boolean;
75
+ }
76
+
77
+ export interface KeyBinding {
78
+ action: KeyAction;
79
+ chords: readonly KeyChord[];
80
+ label: string;
81
+ group: KeyGroup;
82
+ }
83
+
84
+ export const KEYBINDINGS: readonly KeyBinding[] = [
85
+ // ── View and navigation ──────────────────────────────────────────────────
86
+ { action: "view-mine", chords: [{ key: "1" }], label: "mine view", group: "View and navigation" },
87
+ { action: "view-gantt", chords: [{ key: "2" }], label: "gantt view", group: "View and navigation" },
88
+ { action: "view-grid", chords: [{ key: "3" }], label: "grid view", group: "View and navigation" },
89
+ { action: "view-table", chords: [{ key: "4" }], label: "table view", group: "View and navigation" },
90
+ { action: "view-incytes", chords: [{ key: "5" }], label: "incytes view", group: "View and navigation" },
91
+ { action: "cycle-group", chords: [{ key: "g" }], label: "cycle group by", group: "View and navigation" },
92
+ { action: "cycle-window", chords: [{ key: "w" }], label: "cycle the window preset", group: "View and navigation" },
93
+ { action: "today", chords: [{ key: "t" }], label: "back to today: the ±90d window, scrolled to today", group: "View and navigation" },
94
+ { action: "next-bead", chords: [{ key: "j" }], label: "next bead — moves the drawer down the view", group: "View and navigation" },
95
+ { action: "prev-bead", chords: [{ key: "k" }], label: "previous bead", group: "View and navigation" },
96
+ // ── Drawer and selection ─────────────────────────────────────────────────
97
+ { action: "open-first", chords: [{ key: "Enter" }], label: "open the drawer on the first bead in view", group: "Drawer and selection" },
98
+ { action: "close", chords: [{ key: "Escape" }], label: "close: a menu, this overlay, the changelog, the drawer, then the walkthrough", group: "Drawer and selection" },
99
+ { action: "toggle-watch", chords: [{ key: "p" }], label: "pin / unpin the open bead (watch list)", group: "Drawer and selection" },
100
+ { action: "toggle-scope", chords: [{ key: "s" }], label: "subgraph scope: this bead / whole epic", group: "Drawer and selection" },
101
+ { action: "copy-id", chords: [{ key: "c" }], label: "copy the open bead's id", group: "Drawer and selection" },
102
+ { action: "actions", chords: [{ key: "a" }], label: "actions on the open bead: claim, assign, spec:ready, label, defer", group: "Drawer and selection" },
103
+ // ── Filters and toggles ──────────────────────────────────────────────────
104
+ { action: "hl-blocked", chords: [{ key: "b" }], label: "highlight blocked", group: "Filters and toggles" },
105
+ { action: "hl-over-cap", chords: [{ key: "o" }], label: "highlight over-cap", group: "Filters and toggles" },
106
+ { action: "hl-ready-now", chords: [{ key: "r" }], label: "highlight ready-now", group: "Filters and toggles" },
107
+ { action: "toggle-deps", chords: [{ key: "d" }], label: "dependency arrows", group: "Filters and toggles" },
108
+ { action: "clear-filters", chords: [{ key: "x" }], label: "clear all filters", group: "Filters and toggles" },
109
+ { action: "toggle-closed", chords: [{ key: "z" }], label: "show / hide closed beads", group: "Filters and toggles" },
110
+ // ── Appearance ───────────────────────────────────────────────────────────
111
+ { action: "cycle-theme", chords: [{ key: "m" }], label: "cycle light / dark / system", group: "Appearance" },
112
+ { action: "cycle-style", chords: [{ key: "y" }], label: "cycle the named style", group: "Appearance" },
113
+ { action: "options", chords: [{ key: "," }], label: "open the options menu", group: "Appearance" },
114
+ // ── Search and help ──────────────────────────────────────────────────────
115
+ { action: "search", chords: [{ key: "k", meta: true }, { key: "k", ctrl: true }, { key: "/" }], label: "search", group: "Search and help" },
116
+ { action: "help", chords: [{ key: "?" }], label: "this overlay", group: "Search and help" },
117
+ ];
118
+
119
+ /** The convention, stated once, for the overlay to print. */
120
+ export const KEY_CONVENTION =
121
+ "Single letters, no modifier. Nothing fires while you are typing in a field. ⌘K / Ctrl+K is the one chord, because that is what every tool means by search.";
122
+
123
+ export interface KeyEventLike {
124
+ key: string;
125
+ ctrlKey: boolean;
126
+ metaKey: boolean;
127
+ altKey: boolean;
128
+ }
129
+
130
+ /**
131
+ * The binding this key event triggers, or null. `key` is compared as the
132
+ * browser reports it, so "?" and "J" already carry their shift; ctrl and meta
133
+ * must match the chord exactly, and alt never matches anything — an
134
+ * alt-letter is the browser's or the OS's.
135
+ */
136
+ export function matchBinding(e: KeyEventLike): KeyBinding | null {
137
+ if (e.altKey) return null;
138
+ for (const b of KEYBINDINGS) {
139
+ for (const c of b.chords) {
140
+ if (c.key === e.key && !!c.ctrl === e.ctrlKey && !!c.meta === e.metaKey) return b;
141
+ }
142
+ }
143
+ return null;
144
+ }
145
+
146
+ export interface TargetLike {
147
+ tagName?: string;
148
+ isContentEditable?: boolean;
149
+ }
150
+
151
+ /** Is the key going into a field? Then it is text, not a command. */
152
+ export function isTypingTarget(t: TargetLike | null | undefined): boolean {
153
+ if (!t) return false;
154
+ const tag = (t.tagName ?? "").toUpperCase();
155
+ return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || t.isContentEditable === true;
156
+ }
157
+
158
+ const KEY_NAMES: Record<string, string> = {
159
+ Escape: "Esc",
160
+ Enter: "Enter",
161
+ " ": "Space",
162
+ ArrowUp: "↑",
163
+ ArrowDown: "↓",
164
+ };
165
+
166
+ /** "⌘K" on a Mac, "Ctrl+K" elsewhere, "?" for a plain key. */
167
+ export function formatChord(c: KeyChord, mac: boolean): string {
168
+ const name = KEY_NAMES[c.key] ?? (c.key.length === 1 ? c.key.toUpperCase() : c.key);
169
+ const mods: string[] = [];
170
+ if (c.meta) mods.push(mac ? "⌘" : "Meta+");
171
+ if (c.ctrl) mods.push(mac ? "⌃" : "Ctrl+");
172
+ return mods.join("") + name;
173
+ }
174
+
175
+ /**
176
+ * The chords to SHOW for a binding on this platform: the meta chord on a
177
+ * Mac, the ctrl chord elsewhere, never both — the other one still works.
178
+ */
179
+ export function chordsToShow(b: KeyBinding, mac: boolean): KeyChord[] {
180
+ return b.chords.filter((c) => !(mac ? c.ctrl : c.meta));
181
+ }
182
+
183
+ /** The table grouped for the overlay, in declared order within each group. */
184
+ export function bindingsByGroup(): Array<{ group: KeyGroup; bindings: KeyBinding[] }> {
185
+ return KEY_GROUPS.map((group) => ({ group, bindings: KEYBINDINGS.filter((b) => b.group === group) }));
186
+ }
187
+
188
+ /**
189
+ * Where the cursor goes. With nothing open (or the open bead not in view),
190
+ * a step down lands on the first bead and a step up on the last; otherwise
191
+ * it moves and stops at the ends rather than wrapping — wrapping is how a
192
+ * held key laps the list without the reader noticing.
193
+ */
194
+ export function nextCursor(order: readonly string[], current: string | null, delta: number): string | null {
195
+ if (order.length === 0) return null;
196
+ const i = current ? order.indexOf(current) : -1;
197
+ if (i < 0) return delta > 0 ? order[0] : order[order.length - 1];
198
+ const n = Math.min(order.length - 1, Math.max(0, i + delta));
199
+ return order[n];
200
+ }
@@ -0,0 +1,72 @@
1
+ // load-meter.ts — how far along a load appears to be.
2
+ //
3
+ // There is no real progress to report. Measured on a 558-bead repo, the
4
+ // `bd` subprocess is ~860ms of a ~945ms load — about 90% — and it emits
5
+ // nothing while it runs. `bd list --json` produces one blob with no
6
+ // incremental mode, and pre-counting with `bd stats` measured 1.02s against
7
+ // the 0.86s fetch it would describe, so buying a count costs more than the
8
+ // thing being measured.
9
+ //
10
+ // So the only honest determinate signal is HISTORY: how long this same
11
+ // project took last time. That makes the bar an estimate, and it has to
12
+ // behave like one — labelled as such, and degrading to indeterminate when
13
+ // the run outlasts the expectation instead of parking at 99% and lying.
14
+
15
+ /** Never claim completion from an estimate; the response decides that. */
16
+ export const MAX_ESTIMATED_PCT = 92;
17
+
18
+ export interface MeterState {
19
+ /** 'determinate' only when a prior measurement exists AND still holds. */
20
+ mode: "determinate" | "indeterminate";
21
+ /** 0-92 when determinate; null otherwise. */
22
+ pct: number | null;
23
+ /** True once the load has outlasted its estimate. */
24
+ overrun: boolean;
25
+ }
26
+
27
+ export function meterState(
28
+ elapsedMs: number,
29
+ expectedMs: number | null,
30
+ ): MeterState {
31
+ const elapsed = Number.isFinite(elapsedMs) && elapsedMs > 0 ? elapsedMs : 0;
32
+
33
+ // No history, or a nonsense history, means no honest percentage.
34
+ if (
35
+ expectedMs === null ||
36
+ !Number.isFinite(expectedMs) ||
37
+ expectedMs <= 0
38
+ ) {
39
+ return { mode: "indeterminate", pct: null, overrun: false };
40
+ }
41
+
42
+ if (elapsed >= expectedMs) {
43
+ // The estimate has been disproved by the run itself. Stop pretending to
44
+ // know and say so, rather than creeping toward a completion we can't
45
+ // observe.
46
+ return { mode: "indeterminate", pct: null, overrun: true };
47
+ }
48
+
49
+ return {
50
+ mode: "determinate",
51
+ pct: Math.min(MAX_ESTIMATED_PCT, (elapsed / expectedMs) * 100),
52
+ overrun: false,
53
+ };
54
+ }
55
+
56
+ /** "1.4s" / "820ms" — the elapsed counter that runs regardless of mode. */
57
+ export function formatElapsed(ms: number): string {
58
+ if (!Number.isFinite(ms) || ms < 0) return "0ms";
59
+ return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
60
+ }
61
+
62
+ /** "updated 12s ago" / "updated 4m ago". Null when never loaded. */
63
+ export function formatAge(ms: number | null): string | null {
64
+ if (ms === null || !Number.isFinite(ms) || ms < 0) return null;
65
+ const s = Math.floor(ms / 1000);
66
+ if (s < 5) return "updated just now";
67
+ if (s < 60) return `updated ${s}s ago`;
68
+ const m = Math.floor(s / 60);
69
+ if (m < 60) return `updated ${m}m ago`;
70
+ const h = Math.floor(m / 60);
71
+ return h < 24 ? `updated ${h}h ago` : `updated ${Math.floor(h / 24)}d ago`;
72
+ }
@@ -0,0 +1,20 @@
1
+ // Import order IS the cascade: tokens define, styles-alt overrides per
2
+ // family, app.css sets globals, and component <style> blocks land after
3
+ // these because their modules load later.
4
+ import "./tokens.css";
5
+ import "./styles-alt.css";
6
+ import "./styles-ported.css";
7
+ import "./app.css";
8
+
9
+ import { createApp } from "vue";
10
+ import { createPinia } from "pinia";
11
+ import App from "./App.vue";
12
+ import { useTheme } from "./composables/useTheme";
13
+
14
+ // Sync Vue's reactive theme state with what index.html's pre-paint script
15
+ // already put on <html>.
16
+ useTheme().init();
17
+
18
+ const app = createApp(App);
19
+ app.use(createPinia());
20
+ app.mount("#app");
@@ -0,0 +1,14 @@
1
+ // markdown.ts — the one markdown renderer (bp-67g.46).
2
+ //
3
+ // Bead descriptions and the changelog both come from trusted local sources
4
+ // — our own bd DB, and a file generated from it and shipped in the package —
5
+ // so marked runs with no sanitizer between it and v-html. If either source
6
+ // ever becomes untrusted, DOMPurify goes HERE, once, not in each consumer.
7
+
8
+ import { marked } from "marked";
9
+
10
+ marked.setOptions({ gfm: true, breaks: false });
11
+
12
+ export function renderMarkdown(src: string): string {
13
+ return marked.parse(src) as string;
14
+ }
@@ -0,0 +1,137 @@
1
+ // mine.ts — the sections of the "mine" view (bp-egy): what should I be
2
+ // doing, and what is going on around my work.
3
+ //
4
+ // Everything here is about one person, the current user, and is a list, not
5
+ // a chart: several small sets at once, which no single filter on the Gantt or
6
+ // grid can show together. The readiness rule is highlights.ts's, shared with
7
+ // the ready-now overlay and the MINE quick filters; this module only adds the
8
+ // "whose" clauses and the relationships the filters cannot express — who is
9
+ // holding the bead that blocks yours, and whose work you are holding up.
10
+ //
11
+ // "Assigned to me" means the bead's own assignee field. The scheduler's
12
+ // inferred assignments appear separately as suggestions, marked as such,
13
+ // because a guess about who might take a bead is not the same as a bead that
14
+ // is yours.
15
+ //
16
+ // Pure: beads in, sections out. No store, no DOM.
17
+
18
+ import type { Bead } from "./store";
19
+ import type { Schedule } from "./view-model";
20
+ import type { TriageResult } from "./triage";
21
+ import { FINISHED_STATUSES, hasOpenBlocker, isReadyCore } from "./highlights.ts";
22
+
23
+ /** A bead that stands in the way of one of mine, and who holds it. */
24
+ export interface BlockerRef {
25
+ id: string;
26
+ title: string;
27
+ status: string;
28
+ holder: string | null;
29
+ }
30
+
31
+ /** One of my live beads that cannot move yet. */
32
+ export interface WaitingItem {
33
+ bead: Bead;
34
+ blockers: BlockerRef[];
35
+ }
36
+
37
+ /** Someone else's live bead that one of mine is holding up. */
38
+ export interface HoldingItem {
39
+ bead: Bead;
40
+ /** My open beads it depends on. */
41
+ via: Bead[];
42
+ }
43
+
44
+ export interface MineModel {
45
+ /** Assigned to me, approved, not already moving, every blocker closed. */
46
+ ready: Bead[];
47
+ /** Unassigned and ready, and the scheduler would route them to me. */
48
+ suggested: Bead[];
49
+ inProgress: Bead[];
50
+ inReview: Bead[];
51
+ /** My live beads with an open blocker, and who holds each blocker. */
52
+ waitingOnOthers: WaitingItem[];
53
+ /** Other people's live beads blocked by an open bead of mine. */
54
+ waitingOnMe: HoldingItem[];
55
+ /** The watch list, in watch order, whatever the status. */
56
+ watching: Bead[];
57
+ }
58
+
59
+ export interface MineOptions {
60
+ schedule?: Schedule | null;
61
+ triage?: Map<string, TriageResult> | null;
62
+ watched?: readonly string[] | null;
63
+ }
64
+
65
+ const isEpic = (b: Bead) => b.issue_type === "epic";
66
+ const isLive = (b: Bead) => !FINISHED_STATUSES.includes(b.status);
67
+
68
+ export function buildMineModel(
69
+ beads: Bead[],
70
+ me: string,
71
+ opts: MineOptions = {},
72
+ ): MineModel {
73
+ const byId = new Map(beads.map((b) => [b.id, b] as const));
74
+ const triage = opts.triage ?? null;
75
+ const schedule = opts.schedule ?? null;
76
+
77
+ // Priority first (P0 before P4, unset counts as P4), then triage score,
78
+ // then id so the order is stable when neither separates two beads.
79
+ const byUrgency = (a: Bead, b: Bead) => {
80
+ const pa = a.priority ?? 4;
81
+ const pb = b.priority ?? 4;
82
+ if (pa !== pb) return pa - pb;
83
+ const ta = triage?.get(a.id)?.score ?? 0;
84
+ const tb = triage?.get(b.id)?.score ?? 0;
85
+ if (ta !== tb) return tb - ta;
86
+ return a.id.localeCompare(b.id);
87
+ };
88
+ // Most recently touched first, for the sets that are already moving.
89
+ const byRecency = (a: Bead, b: Bead) => b.updated_at.localeCompare(a.updated_at) || a.id.localeCompare(b.id);
90
+
91
+ const mine = beads.filter((b) => !isEpic(b) && b.assignee === me);
92
+
93
+ const ready = mine.filter((b) => isReadyCore(b, byId)).sort(byUrgency);
94
+ const inProgress = mine.filter((b) => b.status === "in_progress").sort(byRecency);
95
+ const inReview = mine.filter((b) => b.status === "in_review").sort(byRecency);
96
+
97
+ const suggested = beads
98
+ .filter(
99
+ (b) =>
100
+ !isEpic(b) &&
101
+ !b.assignee &&
102
+ schedule?.get(b.id)?.synthetic_assignee === true &&
103
+ schedule?.get(b.id)?.assignee === me &&
104
+ isReadyCore(b, byId),
105
+ )
106
+ .sort(byUrgency);
107
+
108
+ const waitingOnOthers: WaitingItem[] = mine
109
+ .filter((b) => isLive(b) && hasOpenBlocker(b, byId))
110
+ .sort(byUrgency)
111
+ .map((bead) => ({
112
+ bead,
113
+ blockers: (bead.dependencies ?? [])
114
+ .filter((d) => d.type === "blocks")
115
+ .map((d) => byId.get(d.depends_on_id))
116
+ .filter((dep): dep is Bead => !!dep && dep.status !== "closed")
117
+ .map((dep) => ({ id: dep.id, title: dep.title, status: dep.status, holder: dep.assignee })),
118
+ }));
119
+
120
+ const waitingOnMe: HoldingItem[] = beads
121
+ .filter((b) => !isEpic(b) && b.assignee !== me && isLive(b))
122
+ .map((bead) => ({
123
+ bead,
124
+ via: (bead.dependencies ?? [])
125
+ .filter((d) => d.type === "blocks")
126
+ .map((d) => byId.get(d.depends_on_id))
127
+ .filter((dep): dep is Bead => !!dep && dep.assignee === me && dep.status !== "closed"),
128
+ }))
129
+ .filter((h) => h.via.length > 0)
130
+ .sort((a, b) => byUrgency(a.bead, b.bead));
131
+
132
+ const watching = (opts.watched ?? [])
133
+ .map((id) => byId.get(id))
134
+ .filter((b): b is Bead => !!b);
135
+
136
+ return { ready, suggested, inProgress, inReview, waitingOnOthers, waitingOnMe, watching };
137
+ }
@@ -0,0 +1,21 @@
1
+ // mutations.ts — the client side of writing to the tracker (bp-ocs). The
2
+ // whitelist, the argv and the optimistic patch are src/mutate.mjs's, shared
3
+ // with the server; this file only types them for the app.
4
+
5
+ import { applyOptimistic, describeMutation, describeOutcome, DEFER_CHOICES, READY_LABEL, MUTATION_ACTIONS } from "../mutate.mjs";
6
+
7
+ export type MutationAction = (typeof MUTATION_ACTIONS)[number];
8
+
9
+ export interface Mutation {
10
+ action: MutationAction;
11
+ id: string;
12
+ label?: string;
13
+ until?: string;
14
+ assignee?: string;
15
+ priority?: number;
16
+ title?: string;
17
+ type?: string;
18
+ labels?: string[];
19
+ }
20
+
21
+ export { applyOptimistic, describeMutation, describeOutcome, DEFER_CHOICES, READY_LABEL, MUTATION_ACTIONS };
@@ -0,0 +1,102 @@
1
+ // person.ts — one person's picture, for the incytes view (bp-67g.43).
2
+ //
3
+ // The mine view answers "what should I be doing" for the current user with
4
+ // actionable lists. This answers "how is this person doing, and what is on
5
+ // their plate" for ANYONE, from the project's angle, and it reuses what the
6
+ // app already computes rather than growing person-shaped copies:
7
+ //
8
+ // - the ship trend is the person's weekly series from computeVelocity, in
9
+ // the project's buckets, through the same shipTrend() the project uses;
10
+ // - progress is projectGroups() over the beads recorded as theirs;
11
+ // - current, ready and likely-next come from buildMineModel() for that
12
+ // handle — the same rules the mine view applies to you — and "likely
13
+ // next" is exactly the scheduler's affinity routing (synthetic), shown
14
+ // as a projection, never as work they have;
15
+ // - roster details are what calibrateRoster stashed: the raw cap, the
16
+ // calibrated cap and the derivation string, and whether the roster was
17
+ // authored or derived from assignees.
18
+ //
19
+ // Pure: the shapes and the sentences are testable without a store.
20
+
21
+ import type { Bead } from "./store";
22
+ import { buildMineModel, type MineModel, type MineOptions } from "./mine.ts";
23
+ import { shipTrend, type ShipTrend, type ShipWeekPoint } from "./insights.ts";
24
+
25
+ export interface RosterHumanLike {
26
+ handle: string;
27
+ cap?: number;
28
+ labels?: string[];
29
+ areas?: string[];
30
+ _rawCap?: number;
31
+ _closesPerWeek?: number;
32
+ _hoursPerWeek?: number;
33
+ _closedInWindow?: number;
34
+ _capDerivation?: string;
35
+ }
36
+
37
+ export interface PersonRoster {
38
+ handle: string;
39
+ cap: number | null;
40
+ rawCap: number | null;
41
+ /** "Little: 4.4/wk × 2.4d" or "raw (no history)", as calibrateRoster wrote it. */
42
+ derivation: string | null;
43
+ closesPerWeek: number;
44
+ hoursPerWeek: number;
45
+ closedInWindow: number;
46
+ labels: string[];
47
+ areas: string[];
48
+ /** True when nobody wrote a roster: the caps are defaults, not decisions. */
49
+ derivedRoster: boolean;
50
+ }
51
+
52
+ export function personRoster(handle: string, roster: { humans?: RosterHumanLike[]; _derived?: boolean } | null): PersonRoster {
53
+ const h = roster?.humans?.find((x) => x.handle === handle);
54
+ return {
55
+ handle,
56
+ cap: h?.cap ?? null,
57
+ rawCap: h?._rawCap ?? h?.cap ?? null,
58
+ derivation: h?._capDerivation ?? null,
59
+ closesPerWeek: h?._closesPerWeek ?? 0,
60
+ hoursPerWeek: h?._hoursPerWeek ?? 0,
61
+ closedInWindow: h?._closedInWindow ?? 0,
62
+ labels: h?.labels ?? [],
63
+ areas: h?.areas ?? [],
64
+ derivedRoster: !!roster?._derived,
65
+ };
66
+ }
67
+
68
+ /** "cap 5 → 1 (Little: 4.4/wk × 2.4d)", "cap 5 (raw, no history)", or "no cap on record". */
69
+ export function describeCap(r: PersonRoster): string {
70
+ if (r.cap === null) return "no cap on record";
71
+ const moved = r.rawCap !== null && r.rawCap !== r.cap;
72
+ const how = r.derivation ? ` (${r.derivation})` : "";
73
+ return moved ? `cap ${r.rawCap} → ${r.cap}${how}` : `cap ${r.cap}${how}`;
74
+ }
75
+
76
+ /** The sentence the pane prints about where the roster came from. */
77
+ export function rosterProvenanceNote(r: PersonRoster): string {
78
+ return r.derivedRoster
79
+ ? "No roster file: this person was derived from bead assignees, so the cap is a default and the affinities are empty — nobody decided them."
80
+ : "From .beadcyte/roster.json — the cap and affinities were authored.";
81
+ }
82
+
83
+ /** The person's ship trend, or null when velocity has never seen them ship. */
84
+ export function personShipTrend(
85
+ handle: string,
86
+ velocity: { weekly: ShipWeekPoint[]; perHuman: Map<string, { weekly?: number[] }> } | null,
87
+ ): ShipTrend | null {
88
+ if (!velocity) return null;
89
+ const mine = velocity.perHuman.get(handle)?.weekly;
90
+ if (!mine) return null;
91
+ return shipTrend(velocity.weekly.map((w, i) => ({ weekStart: w.weekStart, count: mine[i] ?? 0 })));
92
+ }
93
+
94
+ /** The beads recorded as this person's — real assignments, not the scheduler's guesses. */
95
+ export function beadsAssignedTo(beads: readonly Bead[], handle: string): Bead[] {
96
+ return beads.filter((b) => b.assignee === handle);
97
+ }
98
+
99
+ /** Current, ready and likely-next, by the mine view's own rules, for anyone. */
100
+ export function personWork(beads: Bead[], handle: string, opts: MineOptions): MineModel {
101
+ return buildMineModel(beads, handle, opts);
102
+ }
@@ -0,0 +1,15 @@
1
+ // projects-text.ts — how a server refusal is phrased for a person.
2
+ //
3
+ // The server returns codes (src/projects.mjs REFUSAL) so the wording lives
4
+ // with the UI rather than in the API. Each says what to do about it, not
5
+ // just what went wrong — the user typed a path and needs to know whether to
6
+ // fix the path, pick a different directory, or check permissions.
7
+
8
+ export const REFUSAL_TEXT: Record<string, string> = {
9
+ "not-absolute": "Use an absolute path, starting with /.",
10
+ missing: "Nothing exists at that path.",
11
+ "not-a-directory": "That's a file, not a directory.",
12
+ "no-beads":
13
+ "No .beads directory here — this isn't a beads project. Try the repo root.",
14
+ "not-readable": "That directory isn't readable by the server process.",
15
+ };