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,424 @@
1
+ // scheduler.mjs — greedy WIP-cap simulator with TWO CLOCKS (bp-m0g).
2
+ //
3
+ // A bead's elapsed time is mostly waiting; its cost is the work. The model
4
+ // keeps them apart, matching how allocation actually happens:
5
+ //
6
+ // - Timestep = 1 calendar day, starting today.
7
+ // - Each human has a WIP cap (roster.json; default 5, per-human overrides,
8
+ // calibrated by velocity.mjs) and a daily WORK BUDGET: their observed
9
+ // hours per week spread over five working days, or eight hours a day
10
+ // when nothing has been recorded — never a whole calendar day.
11
+ // - WORK CLOCK. Each day a human's in-flight beads consume their remaining
12
+ // estimate from that budget, in order; a bead that finishes leaves
13
+ // budget for the next, so several small beads pack into one day (the
14
+ // day quantum this replaced completed one bead per slot per day, which
15
+ // projected twenty 17-minute beads as twenty days — bp-81h, decided).
16
+ // A bead that outruns the budget continues tomorrow.
17
+ // - WAIT CLOCK. Waiting comes from the project's own stage medians
18
+ // (stage-waits.mjs): a bead pulled from the unassigned pool waits the
19
+ // median UNCLAIMED time before its work starts (nobody has claimed it;
20
+ // the projection says who would); every bead waits the median REVIEW
21
+ // time after its work ends before it lands. Review holds the WIP slot —
22
+ // a bead in review is in flight — but spends no work budget. Where the
23
+ // review median is unknown, the landing is floored at the estimator's
24
+ // lead-time median so waiting is never assumed to cost nothing.
25
+ // - A bead is READY when its status is live, every `blocks` dependency is
26
+ // closed or has LANDED (end_day) by this day, and — for the first pass
27
+ // of pool picks — it carries `spec:ready`. Dispatch order is priority
28
+ // ascending, then created_at ascending.
29
+ // - Beads already in_progress at t=0 start in flight with the estimate
30
+ // less the time since started_at; beads already in_review sit in the
31
+ // review wait.
32
+ // - Backlog (not spec:ready) is scheduled identically and tagged so the
33
+ // renderer can draw it faint. Pool picks are tagged synthetic_assignee.
34
+ //
35
+ // Entries carry both clocks: work_start_day → work_end_day is the bright
36
+ // part of a bar; start_day → end_day is the whole span including waits and
37
+ // is the landing date. Deliberately no cross-human load balancing and no
38
+ // auto-reassignment: the planner may PROPOSE moves (bp-ufa) and the owner
39
+ // confirms; the simulation projects the allocation as it stands.
40
+
41
+ const MS_PER_DAY = 86_400_000;
42
+ const DEFAULT_WIP_CAP = 5;
43
+ const WORK_DAYS_PER_WEEK = 5;
44
+ const DEFAULT_HOURS_PER_DAY = 8;
45
+
46
+ /**
47
+ * A human's daily work budget in estimate units (fractions of a 24-hour
48
+ * day, which is what the estimator's `days` are): observed hours per week
49
+ * over five working days, else eight hours.
50
+ * @param {{ _hoursPerWeek?: number, hoursPerDay?: number } | undefined} human
51
+ */
52
+ export function dailyCapacityFor(human) {
53
+ if (human && typeof human.hoursPerDay === "number" && human.hoursPerDay > 0) return human.hoursPerDay / 24;
54
+ const perWeek = human?._hoursPerWeek;
55
+ if (typeof perWeek === "number" && perWeek > 0) return perWeek / WORK_DAYS_PER_WEEK / 24;
56
+ return DEFAULT_HOURS_PER_DAY / 24;
57
+ }
58
+
59
+ /**
60
+ * @param {Array} beads — all beads (open + closed) from bd list --all
61
+ * @param {Object} roster — { humans: [{handle, cap?}], cap: default }
62
+ * @param {Function} estimate — (bead) → { days, p90, source }
63
+ * @param {Date} horizon — stop scheduling past this date
64
+ * @param {Object} [opts]
65
+ * @param {{ unclaimedDays?: number|null, reviewDays?: number|null }} [opts.waits] stage medians (stage-waits.mjs)
66
+ * @param {number} [opts.minLeadDays] the estimator's lead-time median: the landing floor where review data is missing
67
+ * @param {(human: object) => number} [opts.capacityFor] daily work budget per human; default dailyCapacityFor
68
+ * @param {Date} [opts.now] test hook
69
+ * @returns {{ schedule: Map<string, object>, unscheduled: string[], unassignedBacklog: object[], unassignedProjection: Map<string, object>, waits: object }}
70
+ */
71
+ export function scheduleGreedy(beads, roster, estimate, horizon, opts = {}) {
72
+ const today = startOfDay(opts.now ?? new Date());
73
+ const nowMs = (opts.now ?? new Date()).getTime();
74
+ const horizonDay = daysBetween(today, startOfDay(horizon));
75
+ const waits = {
76
+ unclaimedDays: Math.max(0, opts.waits?.unclaimedDays ?? 0),
77
+ reviewDays: opts.waits?.reviewDays ?? null,
78
+ };
79
+ const minLeadDays = Math.max(0, opts.minLeadDays ?? 0);
80
+ const capacityFor = opts.capacityFor ?? dailyCapacityFor;
81
+ const wipCap = new Map();
82
+ const capacity = new Map();
83
+ const defaultCap = roster.cap ?? DEFAULT_WIP_CAP;
84
+ for (const h of roster.humans ?? []) {
85
+ wipCap.set(h.handle, h.cap ?? defaultCap);
86
+ capacity.set(h.handle, capacityFor(h));
87
+ }
88
+ // The landing of a bead whose work ends at `workEnd` (fractional day).
89
+ const landingFor = (workStart, workEnd) => {
90
+ if (waits.reviewDays !== null) return workEnd + Math.max(0, waits.reviewDays);
91
+ return Math.max(workEnd, workStart + minLeadDays);
92
+ };
93
+ const finish = (human, entry, workEnd) => {
94
+ const end = landingFor(entry.work_start_day, workEnd);
95
+ schedule.set(entry.bead.id, {
96
+ id: entry.bead.id,
97
+ start_day: entry.start_day,
98
+ end_day: end,
99
+ work_start_day: entry.work_start_day,
100
+ work_end_day: workEnd,
101
+ wait_before_days: entry.work_start_day - entry.start_day,
102
+ wait_after_days: end - workEnd,
103
+ backlog: entry.backlog,
104
+ assignee: human,
105
+ synthetic_assignee: entry.synthetic ?? false,
106
+ in_progress_at_start: entry.start_day < 0,
107
+ });
108
+ entry.landing = end;
109
+ };
110
+
111
+ const byId = new Map(beads.map((b) => [b.id, b]));
112
+ const isClosed = (b) => b?.status === "closed";
113
+ const isFuture = (b) =>
114
+ b &&
115
+ b.status !== "closed" &&
116
+ b.status !== "deferred" &&
117
+ b.status !== "superseded";
118
+ const isReady = (b) => {
119
+ if (!isFuture(b)) return false;
120
+ for (const d of b.dependencies ?? []) {
121
+ if (d.type !== "blocks") continue;
122
+ const dep = byId.get(d.depends_on_id);
123
+ if (dep && !isClosed(dep)) return false;
124
+ }
125
+ return true;
126
+ };
127
+ const isSpecReady = (b) => (b.labels ?? []).includes("spec:ready");
128
+
129
+ // Priority sort key: priority asc, then created_at asc
130
+ const prioKey = (b) => [b.priority ?? 4, b.created_at ?? ""];
131
+ const cmp = (a, b) => {
132
+ const [pa, ca] = prioKey(a);
133
+ const [pb, cb] = prioKey(b);
134
+ return pa - pb || ca.localeCompare(cb);
135
+ };
136
+
137
+ const schedule = new Map();
138
+ const inFlight = new Map(); // human → [{bead, remaining, start_day, backlog}]
139
+
140
+ // Seed in-flight from currently in_progress / in_review beads. In-progress
141
+ // work has the estimate less what has elapsed (a sliver at least, so it
142
+ // lands today rather than yesterday); in-review work is done and sits in
143
+ // the review wait from now.
144
+ for (const b of beads) {
145
+ if (b.status !== "in_progress" && b.status !== "in_review") continue;
146
+ if (!b.assignee) continue;
147
+ const est = estimate(b);
148
+ const elapsedDays = b.started_at
149
+ ? (nowMs - new Date(b.started_at).getTime()) / MS_PER_DAY
150
+ : 0;
151
+ const startDay = -Math.max(0, elapsedDays);
152
+ if (!inFlight.has(b.assignee)) inFlight.set(b.assignee, []);
153
+ const entry = {
154
+ bead: b,
155
+ remaining: b.status === "in_review" ? 0 : Math.max(0.001, est.days - Math.max(0, elapsedDays)),
156
+ start_day: startDay,
157
+ work_start_day: startDay,
158
+ working: true, // already begun: its start is its recorded start
159
+ backlog: !isSpecReady(b),
160
+ landing: null,
161
+ };
162
+ inFlight.get(b.assignee).push(entry);
163
+ if (b.status === "in_review") finish(b.assignee, entry, 0);
164
+ }
165
+
166
+ // The queues of unstarted beads per human (spec:ready and backlog together;
167
+ // rendered distinctly, scheduled identically), plus a SHARED unassigned pool
168
+ // that any human can dip into when their own queue is empty (or from the
169
+ // start if `--force-synthetic` is on). Beads pulled from the pool land in
170
+ // schedule entries with `synthetic_assignee: true` so the renderer can
171
+ // signal "we made this up."
172
+ const queueByHuman = new Map();
173
+ const unassignedPool = [];
174
+ for (const b of beads) {
175
+ if (!isFuture(b)) continue;
176
+ if (b.status === "in_progress" || b.status === "in_review") continue;
177
+ if (!b.assignee) {
178
+ unassignedPool.push(b);
179
+ continue;
180
+ }
181
+ if (!queueByHuman.has(b.assignee)) queueByHuman.set(b.assignee, []);
182
+ queueByHuman.get(b.assignee).push(b);
183
+ }
184
+ for (const q of queueByHuman.values()) q.sort(cmp);
185
+ unassignedPool.sort(cmp);
186
+
187
+ // Affinity map: human handle → array of area/label markers that match beads
188
+ // they should prefer. Built from roster.json's per-human `labels` and
189
+ // `areas`. Used to give the pool a soft-preference dispatch, so
190
+ // component::Data drifts to one person and component::UX to another.
191
+ // Falls through to "any spec:ready bead" if no affinity match.
192
+ const affinityByHuman = new Map();
193
+ for (const h of roster.humans ?? []) affinityByHuman.set(h.handle, affinityMarksFor(h));
194
+
195
+ function poolPickFor(human, day) {
196
+ const marks = affinityByHuman.get(human);
197
+ // Two-pass: affinity spec:ready → any spec:ready → any (incl. backlog).
198
+ const passes = [
199
+ (b) => isSpecReady(b) && beadMatchesAffinity(b, marks),
200
+ (b) => isSpecReady(b),
201
+ (b) => true,
202
+ ];
203
+ for (const pred of passes) {
204
+ const idx = unassignedPool.findIndex(
205
+ (b) => pred(b) && depsMetBy(b, byId, schedule, day),
206
+ );
207
+ if (idx >= 0) return unassignedPool.splice(idx, 1)[0];
208
+ }
209
+ return null;
210
+ }
211
+
212
+ // Timestep loop. Each day, per human: free slots whose review wait has
213
+ // ended; spend the day's work budget on in-flight beads in order, then on
214
+ // newly dispatched ones while budget and slots remain.
215
+ for (let day = 0; day <= horizonDay; day++) {
216
+ for (const [human, cap] of wipCap.entries()) {
217
+ const list = inFlight.get(human) ?? [];
218
+ // 1. Landed beads leave their slot. A landing inside a day frees the
219
+ // slot for that day, so a cap of one can still chain several short
220
+ // beads through it.
221
+ const prune = () => {
222
+ for (let i = list.length - 1; i >= 0; i--) {
223
+ const entry = list[i];
224
+ if (entry.landing !== null && Math.floor(entry.landing) <= day) list.splice(i, 1);
225
+ }
226
+ };
227
+ prune();
228
+ // 2. Work: spend the budget on beads still working, in order. Work
229
+ // advances at wall-clock rate within the day — a 2.4h bead is 0.1 of a
230
+ // day wide — and the budget caps how much of the day is worked.
231
+ const dayBudget = capacity.get(human) ?? dailyCapacityFor(undefined);
232
+ let budget = dayBudget;
233
+ const spend = (entry) => {
234
+ if (entry.landing !== null) return; // in review already
235
+ if (entry.work_start_day > day + 1 - 1e-9) return; // still in its unclaimed wait
236
+ if (budget <= 1e-9) return;
237
+ const usedBefore = dayBudget - budget;
238
+ // A bead whose work begins today begins after what was already
239
+ // worked today.
240
+ if (!entry.working) {
241
+ entry.working = true;
242
+ entry.work_start_day = Math.max(entry.work_start_day, day + usedBefore);
243
+ }
244
+ const used = Math.min(entry.remaining, budget);
245
+ entry.remaining -= used;
246
+ budget -= used;
247
+ if (entry.remaining <= 1e-9) finish(human, entry, day + (dayBudget - budget));
248
+ };
249
+ for (const entry of list) spend(entry);
250
+ prune();
251
+ // 3. Dispatch: while a slot is free and budget remains (or the bead
252
+ // will wait first), take the next ready bead — own queue first, then
253
+ // the shared pool with affinity preference (synthetic, marked).
254
+ const q = queueByHuman.get(human) ?? [];
255
+ while (list.length < cap) {
256
+ let bead = null;
257
+ let synthetic = false;
258
+ const idx = q.findIndex((b) => depsMetBy(b, byId, schedule, day));
259
+ if (idx >= 0) {
260
+ bead = q.splice(idx, 1)[0];
261
+ } else {
262
+ bead = poolPickFor(human, day);
263
+ synthetic = !!bead;
264
+ }
265
+ if (!bead) break;
266
+ const est = estimate(bead);
267
+ const waitBefore = synthetic ? waits.unclaimedDays : 0;
268
+ const entry = {
269
+ bead,
270
+ remaining: Math.max(0.001, est.days),
271
+ start_day: day,
272
+ work_start_day: day + waitBefore,
273
+ working: false,
274
+ backlog: !isSpecReady(bead),
275
+ synthetic,
276
+ landing: null,
277
+ };
278
+ list.push(entry);
279
+ spend(entry);
280
+ prune();
281
+ // Out of budget: whatever was dispatched keeps its slot and works
282
+ // tomorrow; nothing more starts today.
283
+ if (budget <= 1e-9) break;
284
+ }
285
+ inFlight.set(human, list);
286
+ }
287
+
288
+ // Everyone drained? (Landed-but-not-yet-freed entries do not count as
289
+ // live work.)
290
+ let live = 0;
291
+ for (const l of inFlight.values()) live += l.filter((e) => e.landing === null).length;
292
+ let queued = 0;
293
+ for (const q of queueByHuman.values()) queued += q.length;
294
+ if (live === 0 && queued === 0 && unassignedPool.length === 0) break;
295
+ }
296
+
297
+ // Anything still working at the horizon → clamped there, marked truncated.
298
+ // Beads that landed but were waiting to free their slot already have an
299
+ // entry.
300
+ const unscheduled = [];
301
+ for (const [human, list] of inFlight) {
302
+ for (const entry of list) {
303
+ if (entry.landing !== null) continue;
304
+ schedule.set(entry.bead.id, {
305
+ id: entry.bead.id,
306
+ start_day: entry.start_day,
307
+ end_day: horizonDay,
308
+ work_start_day: entry.work_start_day,
309
+ work_end_day: horizonDay,
310
+ wait_before_days: entry.work_start_day - entry.start_day,
311
+ wait_after_days: 0,
312
+ backlog: entry.backlog,
313
+ assignee: human,
314
+ synthetic_assignee: entry.synthetic ?? false,
315
+ in_progress_at_start: entry.start_day < 0,
316
+ truncated: true,
317
+ });
318
+ }
319
+ }
320
+ for (const q of queueByHuman.values())
321
+ for (const b of q) unscheduled.push(b.id);
322
+
323
+ // Pool beads that ran out the clock (horizon reached, still queued) — project
324
+ // them at earliest-possible start so they render somewhere honest rather than
325
+ // vanish. These represent "backlog depth beyond the window," and are drawn
326
+ // extra-dim.
327
+ const unassignedProjection = new Map();
328
+ for (const b of unassignedPool) {
329
+ let earliest = horizonDay; // if it didn't dispatch within window, it starts past horizon
330
+ for (const d of b.dependencies ?? []) {
331
+ if (d.type !== "blocks") continue;
332
+ const dep = byId.get(d.depends_on_id);
333
+ if (!dep) continue;
334
+ if (dep.status === "closed") continue;
335
+ const sched = schedule.get(d.depends_on_id);
336
+ if (sched) earliest = Math.max(earliest, sched.end_day);
337
+ else earliest = horizonDay;
338
+ }
339
+ const est = estimate(b);
340
+ const workStart = earliest + waits.unclaimedDays;
341
+ const workEnd = Math.min(horizonDay, workStart + est.days);
342
+ unassignedProjection.set(b.id, {
343
+ id: b.id,
344
+ start_day: earliest,
345
+ end_day: Math.min(horizonDay, landingFor(workStart, workEnd)),
346
+ work_start_day: Math.min(horizonDay, workStart),
347
+ work_end_day: workEnd,
348
+ wait_before_days: waits.unclaimedDays,
349
+ wait_after_days: Math.max(0, Math.min(horizonDay, landingFor(workStart, workEnd)) - workEnd),
350
+ backlog: !((b.labels ?? []).includes("spec:ready")),
351
+ unassigned: true,
352
+ });
353
+ unscheduled.push(b.id);
354
+ }
355
+
356
+ return {
357
+ schedule,
358
+ unscheduled,
359
+ unassignedBacklog: unassignedPool,
360
+ unassignedProjection,
361
+ waits: { ...waits, minLeadDays },
362
+ };
363
+ }
364
+
365
+ /**
366
+ * A human's affinity marks: roster labels as written, areas lower-cased.
367
+ * Exported (bp-wfe) so the frontier's proposals read the SAME routing the
368
+ * scheduler applies, rather than a second definition that drifts.
369
+ * @param {{ labels?: string[], areas?: string[] }} h
370
+ * @returns {Set<string>}
371
+ */
372
+ export function affinityMarksFor(h) {
373
+ const marks = new Set(h.labels ?? []);
374
+ for (const a of h.areas ?? []) marks.add(a.toLowerCase());
375
+ return marks;
376
+ }
377
+
378
+ /**
379
+ * Which of a human's marks a bead matches: a label as written, or an area
380
+ * keyword found in the title/labels (component::/track:: marks only match
381
+ * as labels). Empty when none.
382
+ * @param {{ title?: string, labels?: string[] | null }} b
383
+ * @param {Set<string> | undefined} marks
384
+ * @returns {string[]}
385
+ */
386
+ export function beadAffinityMatches(b, marks) {
387
+ if (!marks || marks.size === 0) return [];
388
+ const labels = b.labels ?? [];
389
+ const out = [];
390
+ for (const l of labels) if (marks.has(l)) out.push(l);
391
+ // Also match a bead's title/label text against seat-area keywords
392
+ const hay = `${b.title ?? ""} ${labels.join(" ")}`.toLowerCase();
393
+ for (const m of marks) {
394
+ if (m.startsWith("component::") || m.startsWith("track::")) continue;
395
+ if (!out.includes(m) && hay.includes(m)) out.push(m);
396
+ }
397
+ return out;
398
+ }
399
+
400
+ function beadMatchesAffinity(b, marks) {
401
+ return beadAffinityMatches(b, marks).length > 0;
402
+ }
403
+
404
+ function depsMetBy(bead, byId, schedule, day) {
405
+ for (const d of bead.dependencies ?? []) {
406
+ if (d.type !== "blocks") continue;
407
+ const dep = byId.get(d.depends_on_id);
408
+ if (!dep) continue;
409
+ if (dep.status === "closed") continue;
410
+ const sched = schedule.get(d.depends_on_id);
411
+ if (sched && sched.end_day <= day) continue;
412
+ return false;
413
+ }
414
+ return true;
415
+ }
416
+
417
+ function startOfDay(d) {
418
+ const x = new Date(d);
419
+ x.setHours(0, 0, 0, 0);
420
+ return x;
421
+ }
422
+ function daysBetween(a, b) {
423
+ return Math.round((b.getTime() - a.getTime()) / MS_PER_DAY);
424
+ }