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,567 @@
1
+ <script setup lang="ts">
2
+ // BeadsIncytes.vue — the project-wide view, third alongside gantt and grid.
3
+ //
4
+ // Everything here answers a question about the PROJECT rather than about a
5
+ // bead or a date: how fast is work shipping, how far along is each group,
6
+ // what shape is the plan, who is on it. The Gantt and the grid are both
7
+ // views of individual beads in time; this is the one that steps back.
8
+ //
9
+ // bp-67g.35. bp-67g.16's spike had recommended putting these in the summary
10
+ // tail under the Gantt instead; that was overruled, and having built them
11
+ // the tab is the better home — a project-wide surface buried below a
12
+ // timeline is not a surface anyone goes to.
13
+ //
14
+ // Project-wide means project-wide (bp-67g.36): nothing here reads the
15
+ // filters or the window. "Bars by kind" did — it counted the bars the Gantt
16
+ // was drawing in the current window — and it moved wholesale in bp-67g.35
17
+ // only because it sat in the summary tail. Bar kind is a fact about the
18
+ // renderer ("future" vs "futureBacklog" is inside or beyond the scheduling
19
+ // horizon), so it lives with the renderer again, in the Gantt's footer.
20
+ // Progress reads projectGroups(), so its rows are every group over every
21
+ // bead; the controls panel dims everything but group-by on this view.
22
+ //
23
+ // One person at a time (bp-67g.43): pick a handle and the same two charts
24
+ // re-scope to that person — their weekly ships, in the project's buckets,
25
+ // and the progress of the beads recorded as theirs — and the pane below
26
+ // adds what the project view has no room for: the roster's caps and where
27
+ // they came from, what is in flight, what is ready for them, what the
28
+ // scheduler would route to them next (a projection, marked as one), and
29
+ // what they shipped lately. Every assignee handle in the app leads here
30
+ // (store.openPerson), and ?who= makes the picture shareable. The mine view
31
+ // stays the actionable list for YOU; this is anyone, from the project's
32
+ // angle, and it reuses the mine view's own rules (person.ts) rather than a
33
+ // second set.
34
+
35
+ import { computed, ref, watch } from "vue";
36
+ import { X } from "@lucide/vue";
37
+ import { useBeadsStore } from "../store";
38
+ import { projectGroups, type ViewModel } from "../view-model";
39
+ import { groupProgress } from "../insights";
40
+ import { isShipped } from "../../ship.mjs";
41
+ import {
42
+ personRoster,
43
+ describeCap,
44
+ rosterProvenanceNote,
45
+ personShipTrend,
46
+ beadsAssignedTo,
47
+ personWork,
48
+ } from "../person";
49
+ import ShipTrend from "./ShipTrend.vue";
50
+ import CostTrend from "./CostTrend.vue";
51
+ import { costSeries } from "../cost";
52
+ import GroupProgress from "./GroupProgress.vue";
53
+ import MineRow from "./MineRow.vue";
54
+ import WipBullets from "./WipBullets.vue";
55
+ import { wipRows as computeWipRows, type CalibratedHuman } from "../wip";
56
+ import { frontierLane, describeLane, proposeNext, describeProposal } from "../frontier";
57
+
58
+ // The view model is what the other views draw; this one does not read it
59
+ // (see the header), but every view takes it so App.vue can hand it over
60
+ // uniformly.
61
+ defineProps<{ vm: ViewModel | null }>();
62
+
63
+ const store = useBeadsStore();
64
+
65
+ const rosterHumans = computed(() => store.calibratedRoster?.humans ?? store.roster?.humans ?? []);
66
+
67
+ // ── allocation (bp-67g.29, here since bp-dgq) ────────────────────────────
68
+ // Everyone's work in flight against their calibrated cap, on one shared
69
+ // scale. It used to be a strip under the controls on every view; it is a
70
+ // project-wide fact, so it lives on the project-wide view. With a person
71
+ // selected the other rows stay — the comparison is the point — and theirs
72
+ // is emphasised; a handle click scopes the view to that person.
73
+ const velocity = computed(() => store.velocity);
74
+ const wipRows = computed(() =>
75
+ computeWipRows(store.beads, (store.calibratedRoster?.humans ?? []) as unknown as CalibratedHuman[]),
76
+ );
77
+
78
+ // ── one person (bp-67g.43) ───────────────────────────────────────────────
79
+ const who = computed(() => store.who);
80
+ const person = computed(() =>
81
+ who.value ? personRoster(who.value, (store.calibratedRoster ?? store.roster) as never) : null,
82
+ );
83
+ const personTrend = computed(() => (who.value ? personShipTrend(who.value, store.velocity as never) : null));
84
+ const personProgress = computed(() => {
85
+ const sched = store.scheduleWithEstimates?.schedule;
86
+ if (!who.value || !sched) return [];
87
+ return groupProgress(projectGroups(beadsAssignedTo(store.beads, who.value), sched, store.groupBy));
88
+ });
89
+ const work = computed(() =>
90
+ who.value
91
+ ? personWork(store.beads, who.value, {
92
+ schedule: store.scheduleWithEstimates?.schedule ?? null,
93
+ triage: store.triage,
94
+ watched: store.watched,
95
+ })
96
+ : null,
97
+ );
98
+ // Their recent ships, newest first: the trend's points as beads.
99
+ const recentShips = computed(() => {
100
+ if (!who.value) return [];
101
+ return beadsAssignedTo(store.beads, who.value)
102
+ .filter((b) => b.status === "closed" && b.closed_at && isShipped(b))
103
+ .sort((a, b) => (b.closed_at! > a.closed_at! ? 1 : -1))
104
+ .slice(0, 8);
105
+ });
106
+ const inFlight = computed(() => (work.value ? [...work.value.inProgress, ...work.value.inReview] : []));
107
+ const fmtDay = (iso: string) => new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short" });
108
+ // ── cost trajectory (bp-0z5) ─────────────────────────────────────────────
109
+ // Seat hours and tokens per week: measured in the ship trend's buckets,
110
+ // projected ahead over the window's forward span from medians of comparable
111
+ // shipped work, placed where the schedule lands each bead. Follows the
112
+ // what-if like every other plan reading.
113
+ const costHorizonDays = computed(() => {
114
+ const to = store.window.to;
115
+ return to ? Math.max(7, Math.ceil((to.getTime() - Date.now()) / 86_400_000)) : 56;
116
+ });
117
+ const projectCost = computed(() => {
118
+ const v = store.velocity;
119
+ if (!v) return null;
120
+ return costSeries(store.planBeads, store.scheduleWithEstimates?.schedule ?? null, v.weekly, { horizonDays: costHorizonDays.value });
121
+ });
122
+ const personCost = computed(() => {
123
+ const v = store.velocity;
124
+ if (!v || !who.value) return null;
125
+ return costSeries(store.planBeads, store.scheduleWithEstimates?.schedule ?? null, v.weekly, { horizonDays: costHorizonDays.value, handle: who.value });
126
+ });
127
+
128
+ // ── the frontier (bp-wfe) ────────────────────────────────────────────────
129
+ // The plan's edge for this person over the window's forward span (7–60
130
+ // days, 14 when the window is open-ended), and what they could take next,
131
+ // ranked with reasons. Accepting a proposal stages a what-if reassignment;
132
+ // the diff shows what moves before anything is confirmed (owner decision).
133
+ const frontierDays = computed(() => {
134
+ const to = store.window.to;
135
+ if (!to) return 14;
136
+ return Math.min(60, Math.max(7, Math.ceil((to.getTime() - Date.now()) / 86_400_000)));
137
+ });
138
+ const lane = computed(() =>
139
+ who.value ? frontierLane(who.value, store.planBeads, store.scheduleWithEstimates?.schedule ?? null, frontierDays.value) : null,
140
+ );
141
+ const laneTicks = computed(() => {
142
+ const n = frontierDays.value;
143
+ const step = n <= 14 ? 1 : n <= 30 ? 5 : 10;
144
+ const out: number[] = [];
145
+ for (let d = 0; d <= n; d += step) out.push(d);
146
+ return out;
147
+ });
148
+ const lanePct = (day: number) => `${Math.min(100, Math.max(0, (day / frontierDays.value) * 100))}%`;
149
+ const proposals = computed(() =>
150
+ who.value
151
+ ? proposeNext(who.value, store.planBeads, rosterHumans.value as never, store.scheduleWithEstimates?.schedule ?? null, store.triage)
152
+ : [],
153
+ );
154
+ const beadOf = (id: string) => store.planBeads.find((b) => b.id === id);
155
+ // What-if cap (bp-q56): staged, not written; the roster file is the record.
156
+ const wiCap = ref<number>(1);
157
+ watch(person, (p) => { wiCap.value = p?.cap ?? p?.rawCap ?? 5; }, { immediate: true });
158
+
159
+ /**
160
+ * Group-by "none" collapses everything into one synthetic bucket, so there
161
+ * is nothing to compare. The Progress block still renders — saying so is
162
+ * better than a view with a hole in it — but it needs the caller to admit
163
+ * the grouping is off rather than showing "0 groups".
164
+ */
165
+ const grouped = computed(() => store.groupBy !== "none");
166
+ </script>
167
+
168
+ <template>
169
+ <div class="incytes" :class="{ 'is-person': !!who }">
170
+ <!-- Who the view is about: the project, or one person (bp-67g.43). -->
171
+ <nav class="people-bar" aria-label="Scope">
172
+ <span class="people-label">Showing</span>
173
+ <button type="button" class="chip" :class="{ 'is-active': !who }" @click="store.setWho(null)">everyone</button>
174
+ <button
175
+ v-for="h in rosterHumans"
176
+ :key="h.handle"
177
+ type="button"
178
+ class="chip"
179
+ :class="{ 'is-active': who === h.handle }"
180
+ @click="store.setWho(h.handle)"
181
+ ><code>{{ h.handle }}</code></button>
182
+ <button v-if="who && !rosterHumans.some((h) => h.handle === who)" type="button" class="chip is-active"><code>{{ who }}</code></button>
183
+ <button v-if="who" type="button" class="chip clear" title="Back to everyone" aria-label="Back to everyone" @click="store.setWho(null)"><X class="icon" /></button>
184
+ </nav>
185
+
186
+ <template v-if="who">
187
+ <ShipTrend v-if="personTrend" :trend="personTrend" />
188
+ <CostTrend v-if="personCost" :series="personCost" :who="who" />
189
+ <section v-else class="stat-block trend-block">
190
+ <h2>Ships per week <span class="hint">{{ who }}</span></h2>
191
+ <p class="hint">No ship attributed to {{ who }} in the last {{ store.velocity?.weeklyWeeks ?? 8 }} weeks.</p>
192
+ </section>
193
+
194
+ <GroupProgress v-if="grouped && personProgress.length" :rows="personProgress" :group-by="store.groupBy" />
195
+ <section v-else class="stat-block progress-block">
196
+ <h2>Progress</h2>
197
+ <p class="hint">
198
+ <template v-if="!grouped">Grouping is off, so there are no groups to compare.</template>
199
+ <template v-else>No beads are recorded as {{ who }}'s.</template>
200
+ </p>
201
+ </section>
202
+
203
+ <section v-if="velocity && wipRows.length" class="stat-block allocation-block">
204
+ <h2>
205
+ Allocation
206
+ <span class="hint">calibrated · last {{ velocity.lookbackDays }}d · {{ velocity.totalClosedInWindow }} ships · {{ velocity.activeCount }} active</span>
207
+ </h2>
208
+ <WipBullets :rows="wipRows" :selected="who" @select="store.setWho" />
209
+ </section>
210
+
211
+ <section class="stat-block person-roster">
212
+ <h2>Roster</h2>
213
+ <ul v-if="person">
214
+ <li><code>{{ person.handle }}</code> <span class="stat-count">{{ describeCap(person) }}</span></li>
215
+ <li>
216
+ <span>rate</span>
217
+ <span class="stat-count">{{ person.closesPerWeek > 0 ? person.closesPerWeek.toFixed(1) + "/wk" : "no ships" }}<template v-if="person.hoursPerWeek > 0"> · {{ person.hoursPerWeek.toFixed(0) }}h/wk</template> · {{ person.closedInWindow }} closed in window</span>
218
+ </li>
219
+ <li v-if="person.labels.length || person.areas.length">
220
+ <span>affinities</span>
221
+ <span class="stat-count">{{ [...person.labels, ...person.areas].join(", ") }}</span>
222
+ </li>
223
+ </ul>
224
+ <p class="hint">{{ person ? rosterProvenanceNote(person) : "" }}</p>
225
+ <p class="whatif-cap">
226
+ <span class="hint">what if their cap were</span>
227
+ <input v-model.number="wiCap" type="number" min="1" max="20" aria-label="what-if cap" />
228
+ <button type="button" class="chip" :disabled="who === null || wiCap === (person?.cap ?? null)" @click="who && store.addWhatIf({ kind: 'cap', handle: who, cap: wiCap })">stage</button>
229
+ </p>
230
+ </section>
231
+
232
+ <section class="stat-block person-list">
233
+ <h2>In flight <span class="count">{{ inFlight.length }}</span></h2>
234
+ <p class="hint">Recorded assignments in progress or in review — what they have committed to.</p>
235
+ <ul v-if="inFlight.length" class="mine-list"><MineRow v-for="b in inFlight" :key="b.id" :bead="b" /></ul>
236
+ <p v-else class="hint">Nothing in flight.</p>
237
+ </section>
238
+
239
+ <section class="stat-block person-list">
240
+ <h2>Ready for them <span class="count">{{ work?.ready.length ?? 0 }}</span></h2>
241
+ <p class="hint">Assigned to them, unblocked, not started.</p>
242
+ <ul v-if="work?.ready.length" class="mine-list"><MineRow v-for="b in work!.ready" :key="b.id" :bead="b" /></ul>
243
+ <p v-else class="hint">Nothing waiting for them.</p>
244
+ </section>
245
+
246
+ <section class="stat-block person-list is-projection">
247
+ <h2>Likely next <span class="count">{{ work?.suggested.length ?? 0 }}</span></h2>
248
+ <p class="hint">
249
+ A projection, not work they have: unassigned beads the scheduler would route to
250
+ {{ who }} by affinity when a slot frees. Nobody decided this.
251
+ </p>
252
+ <ul v-if="work?.suggested.length" class="mine-list"><MineRow v-for="b in work!.suggested" :key="b.id" :bead="b" note="inferred" /></ul>
253
+ <p v-else class="hint">The scheduler routes nothing to them right now.</p>
254
+ </section>
255
+
256
+ <section class="stat-block person-list is-projection frontier-block">
257
+ <h2>Frontier <span class="hint">next {{ frontierDays }} days</span></h2>
258
+ <p class="hint">
259
+ Where the plan puts {{ who }}: when beads start for them and when they land, from the same
260
+ schedule the Gantt draws. Follows the window's forward span.
261
+ </p>
262
+ <div v-if="lane" class="lane" role="img" :aria-label="`${who}: ${describeLane(lane)}`">
263
+ <i v-for="t in laneTicks" :key="t" class="tick" :style="{ left: lanePct(t) }"><span>{{ t === 0 ? "today" : `+${t}d` }}</span></i>
264
+ <button
265
+ v-for="(m, i) in lane.markers"
266
+ :key="`${m.id}-${m.kind}-${i}`"
267
+ type="button"
268
+ class="marker"
269
+ :class="[m.kind, { synthetic: m.synthetic }]"
270
+ :style="{ left: lanePct(m.day) }"
271
+ :title="`${m.id} ${m.kind} in ${m.day.toFixed(1)}d${m.synthetic ? ' (inferred assignment)' : ''}`"
272
+ @click="store.openBead(m.id)"
273
+ />
274
+ </div>
275
+ <p class="hint">{{ lane ? describeLane(lane) : "" }}</p>
276
+ <ul v-if="lane?.markers.length" class="mine-list">
277
+ <template v-for="(m, i) in lane.markers" :key="`${m.id}-${m.kind}-${i}`">
278
+ <MineRow v-if="beadOf(m.id)" :bead="beadOf(m.id)!" :note="`${m.kind} +${m.day.toFixed(1)}d${m.synthetic ? ' · inferred' : ''}`" />
279
+ </template>
280
+ </ul>
281
+ </section>
282
+
283
+ <section class="stat-block person-list is-projection proposals-block">
284
+ <h2>Proposed next <span class="count">{{ proposals.length }}</span></h2>
285
+ <p class="hint">
286
+ What {{ who }} could take next, ranked with the reasons: the scheduler's routing, affinity,
287
+ triage, what it unblocks, priority, room today — and beads someone over cap has not started.
288
+ Stage adds it to the what-if and it leaves this list, because in that plan it is theirs;
289
+ nothing is written until you confirm.
290
+ </p>
291
+ <ul v-if="proposals.length" class="mine-list">
292
+ <MineRow v-for="p in proposals" :key="p.bead.id" :bead="p.bead" :note="describeProposal(p)">
293
+ <button
294
+ type="button"
295
+ class="chip stage"
296
+ :title="p.from ? `Stage moving ${p.bead.id} from ${p.from} to ${who}` : `Stage assigning ${p.bead.id} to ${who}`"
297
+ @click.stop="store.addWhatIf(p.change)"
298
+ >stage</button>
299
+ </MineRow>
300
+ </ul>
301
+ <p v-else class="hint">Nothing to propose: no ready bead is unassigned or held by someone over cap.</p>
302
+ </section>
303
+
304
+ <section class="stat-block person-list">
305
+ <h2>Recent ships <span class="count">{{ recentShips.length }}</span></h2>
306
+ <ul v-if="recentShips.length" class="mine-list">
307
+ <MineRow v-for="b in recentShips" :key="b.id" :bead="b" :note="fmtDay(b.closed_at!)" />
308
+ </ul>
309
+ <p v-else class="hint">No ship evidence on any bead recorded as theirs.</p>
310
+ </section>
311
+ </template>
312
+
313
+ <ShipTrend v-if="!who && store.shipTrend" :trend="store.shipTrend" />
314
+ <CostTrend v-if="!who && projectCost" :series="projectCost" />
315
+
316
+ <GroupProgress
317
+ v-if="!who && grouped && store.groupProgress.length"
318
+ :rows="store.groupProgress"
319
+ :group-by="store.groupBy"
320
+ />
321
+ <section v-else-if="!who && !grouped" class="stat-block">
322
+ <h2>Progress</h2>
323
+ <p class="hint">
324
+ Grouping is off, so there are no groups to compare. Pick a
325
+ <strong>group by</strong> mode above — epic, assignee, status,
326
+ priority — and this becomes a sorted completion chart.
327
+ </p>
328
+ </section>
329
+
330
+ <section v-if="!who && velocity && wipRows.length" class="stat-block allocation-block">
331
+ <h2>
332
+ Allocation
333
+ <span class="hint">calibrated · last {{ velocity.lookbackDays }}d · {{ velocity.totalClosedInWindow }} ships · {{ velocity.activeCount }} active</span>
334
+ </h2>
335
+ <p class="hint">Work in flight against each person's calibrated cap, on one scale; the run past the cap is drawn at its real length. A handle shows that person.</p>
336
+ <WipBullets :rows="wipRows" @select="store.setWho" />
337
+ </section>
338
+
339
+ <section v-if="!who" class="stat-block">
340
+ <h2>Roster</h2>
341
+ <p v-if="store.roster?._derived" class="hint">
342
+ (derived from bead assignees — drop
343
+ <code>.beadcyte/roster.json</code> in the CWD for real caps +
344
+ affinities)
345
+ </p>
346
+ <ul>
347
+ <!-- Each handle opens that person's picture (bp-67g.43). -->
348
+ <li v-for="h in rosterHumans" :key="h.handle">
349
+ <button type="button" class="handle-link" :title="`Show ${h.handle}`" @click="store.setWho(h.handle)"><code>{{ h.handle }}</code></button>
350
+ <span v-if="h.cap" class="stat-count">cap {{ h.cap }}</span>
351
+ </li>
352
+ </ul>
353
+ </section>
354
+ </div>
355
+ </template>
356
+
357
+ <style scoped>
358
+ /* Same auto-fit grid app-main used, so the blocks keep the proportions they
359
+ were designed against. The two charts claim wider spans below. */
360
+ .incytes {
361
+ display: grid;
362
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
363
+ gap: 20px;
364
+ width: 100%;
365
+ box-sizing: border-box;
366
+ }
367
+
368
+ /* A line chart in a 280px column is narrower than its own axis labels, and a
369
+ progress row has three columns of its own. */
370
+ .incytes > :deep(.trend-block),
371
+ .incytes > :deep(.progress-block),
372
+ .incytes > .allocation-block {
373
+ grid-column: 1 / -1;
374
+ }
375
+
376
+ .incytes .allocation-block h2 .hint {
377
+ font-weight: 400;
378
+ text-transform: none;
379
+ letter-spacing: normal;
380
+ margin-left: 8px;
381
+ }
382
+
383
+ .incytes .allocation-block .wip-bullets {
384
+ margin-top: 10px;
385
+ }
386
+
387
+ @media (min-width: 900px) {
388
+ /* Side by side once there's room: the trend answers "how fast" and the
389
+ bars answer "how far", and the pair is the summary. */
390
+ .incytes > :deep(.trend-block),
391
+ .incytes > :deep(.progress-block) {
392
+ grid-column: span 2;
393
+ }
394
+ }
395
+
396
+ .incytes .hint {
397
+ font-size: 11px;
398
+ color: var(--c-dim);
399
+ margin: 0;
400
+ line-height: 1.5;
401
+ }
402
+
403
+ .incytes .hint strong {
404
+ color: var(--c-dim-bright);
405
+ font-weight: 600;
406
+ }
407
+
408
+ /* Who the view is about (bp-67g.43). */
409
+ .incytes .people-bar {
410
+ grid-column: 1 / -1;
411
+ display: flex;
412
+ flex-wrap: wrap;
413
+ align-items: center;
414
+ gap: 6px;
415
+ }
416
+
417
+ .incytes .people-label {
418
+ font-size: 10px;
419
+ font-weight: 600;
420
+ text-transform: uppercase;
421
+ letter-spacing: 0.05em;
422
+ color: var(--c-dim);
423
+ margin-right: 4px;
424
+ }
425
+
426
+ .incytes .chip {
427
+ all: unset;
428
+ cursor: pointer;
429
+ padding: 3px 10px;
430
+ border: 1px solid var(--c-border);
431
+ border-radius: 12px;
432
+ font-size: 11px;
433
+ color: var(--c-dim);
434
+ display: inline-flex;
435
+ align-items: center;
436
+ gap: 4px;
437
+ }
438
+
439
+ .incytes .chip code {
440
+ font-family: ui-monospace, monospace;
441
+ font-size: 11px;
442
+ color: inherit;
443
+ }
444
+
445
+ .incytes .chip:hover {
446
+ color: var(--c-text);
447
+ border-color: var(--c-border-strong);
448
+ }
449
+
450
+ .incytes .chip.is-active {
451
+ background: var(--c-accent);
452
+ border-color: var(--c-accent);
453
+ color: var(--c-bg);
454
+ font-weight: 600;
455
+ }
456
+
457
+ .incytes .chip.clear .icon {
458
+ width: 12px;
459
+ height: 12px;
460
+ }
461
+
462
+ /* The frontier lane (bp-wfe): a ruler of the next N days; starts are hollow
463
+ marks, landings filled, inferred assignments dashed. */
464
+ .incytes .lane {
465
+ position: relative;
466
+ height: 34px;
467
+ margin: 8px 16px 20px 4px; /* bottom room for the tick labels */
468
+ border-bottom: 1px solid var(--c-border);
469
+ }
470
+ /* A ruler, a list of reasons and twenty weekly buckets all want the width: span the pane. */
471
+ .incytes .frontier-block,
472
+ .incytes .proposals-block,
473
+ .incytes > :deep(.cost-block) {
474
+ grid-column: 1 / -1;
475
+ }
476
+ .incytes .lane .tick {
477
+ position: absolute;
478
+ top: 18px;
479
+ bottom: -1px;
480
+ width: 1px;
481
+ background: var(--c-border);
482
+ }
483
+ .incytes .lane .tick span {
484
+ position: absolute;
485
+ top: 100%;
486
+ left: 0;
487
+ transform: translateX(-50%);
488
+ font-size: 9px;
489
+ color: var(--c-dim);
490
+ white-space: nowrap;
491
+ padding-top: 2px;
492
+ }
493
+ .incytes .lane .tick:first-child span { transform: none; }
494
+ .incytes .lane .marker {
495
+ position: absolute;
496
+ top: 4px;
497
+ width: 10px;
498
+ height: 10px;
499
+ margin-left: -5px;
500
+ padding: 0;
501
+ border-radius: 50%;
502
+ border: 2px solid var(--c-in-progress);
503
+ background: transparent;
504
+ cursor: pointer;
505
+ }
506
+ .incytes .lane .marker.lands { background: var(--c-shipped); border-color: var(--c-shipped); top: 14px; }
507
+ .incytes .lane .marker.synthetic { border-style: dashed; }
508
+ .incytes .lane .marker:hover, .incytes .lane .marker:focus-visible { transform: scale(1.3); z-index: 1; }
509
+ .incytes .frontier-block { padding-bottom: 14px; }
510
+ .incytes .proposals-block .chip.stage { margin-left: auto; font-size: 10px; }
511
+
512
+ .incytes .whatif-cap {
513
+ display: flex;
514
+ align-items: center;
515
+ gap: 6px;
516
+ margin: 8px 0 0;
517
+ }
518
+
519
+ .incytes .whatif-cap input {
520
+ width: 56px;
521
+ font: inherit;
522
+ padding: 2px 6px;
523
+ border: 1px solid var(--c-border-strong);
524
+ border-radius: 4px;
525
+ background: var(--c-bg);
526
+ color: var(--c-text);
527
+ }
528
+
529
+ .incytes .chip:disabled {
530
+ opacity: 0.4;
531
+ cursor: not-allowed;
532
+ }
533
+
534
+ .incytes .handle-link {
535
+ all: unset;
536
+ cursor: pointer;
537
+ }
538
+
539
+ .incytes .handle-link code {
540
+ text-decoration: underline dotted;
541
+ text-underline-offset: 2px;
542
+ }
543
+
544
+ .incytes .handle-link:hover code,
545
+ .incytes .handle-link:focus-visible code {
546
+ color: var(--c-accent);
547
+ }
548
+
549
+ .incytes .person-list h2 .count,
550
+ .incytes .person-roster h2 .count {
551
+ font-size: 11px;
552
+ color: var(--c-dim);
553
+ font-weight: 400;
554
+ margin-left: 6px;
555
+ }
556
+
557
+ /* Likely-next is a projection: the same visual register as inferred bars. */
558
+ .incytes .person-list.is-projection {
559
+ border-style: dashed;
560
+ }
561
+
562
+ .incytes .mine-list {
563
+ list-style: none;
564
+ margin: 8px 0 0;
565
+ padding: 0;
566
+ }
567
+ </style>