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.
- package/CHANGELOG.md +73 -0
- package/LICENSE +661 -0
- package/README.md +386 -0
- package/THIRD_PARTY_NOTICES.md +56 -0
- package/bin/beadcyte.mjs +60 -0
- package/package.json +77 -0
- package/src/changelog-cli.mjs +105 -0
- package/src/changelog.mjs +196 -0
- package/src/cli.mjs +577 -0
- package/src/estimator.mjs +314 -0
- package/src/format.mjs +22 -0
- package/src/history-walk.mjs +170 -0
- package/src/index.mjs +5 -0
- package/src/mutate.mjs +193 -0
- package/src/projects.mjs +120 -0
- package/src/provenance.mjs +75 -0
- package/src/review-hours.mjs +117 -0
- package/src/roster-path.mjs +24 -0
- package/src/scheduler.mjs +424 -0
- package/src/serve.mjs +411 -0
- package/src/server-state.mjs +105 -0
- package/src/ship.mjs +178 -0
- package/src/stage-waits.mjs +69 -0
- package/src/start.mjs +111 -0
- package/src/stop.mjs +66 -0
- package/src/velocity.mjs +209 -0
- package/src/web/App.vue +691 -0
- package/src/web/app.css +54 -0
- package/src/web/assets/favicon.svg +12 -0
- package/src/web/avatar.ts +53 -0
- package/src/web/bead-detail.ts +240 -0
- package/src/web/changelog-view.ts +41 -0
- package/src/web/components/BeadDrawer.vue +1883 -0
- package/src/web/components/BeadSubGraph.vue +326 -0
- package/src/web/components/BeadSubGraphOverlay.vue +192 -0
- package/src/web/components/BeadTooltip.vue +516 -0
- package/src/web/components/BeadcyteMark.vue +64 -0
- package/src/web/components/BeadsGantt.vue +2125 -0
- package/src/web/components/BeadsGrid.vue +468 -0
- package/src/web/components/BeadsIncytes.vue +567 -0
- package/src/web/components/BeadsMine.vue +325 -0
- package/src/web/components/BeadsTable.vue +335 -0
- package/src/web/components/ChangelogOverlay.vue +198 -0
- package/src/web/components/ContextMenu.vue +386 -0
- package/src/web/components/ControlsPanel.vue +476 -0
- package/src/web/components/CostTrend.vue +206 -0
- package/src/web/components/FilterPopover.vue +245 -0
- package/src/web/components/GroupProgress.vue +274 -0
- package/src/web/components/LoadMeter.vue +144 -0
- package/src/web/components/MineRow.vue +28 -0
- package/src/web/components/OptionsMenu.vue +825 -0
- package/src/web/components/PriorityChip.vue +105 -0
- package/src/web/components/ScoreStrip.vue +131 -0
- package/src/web/components/SearchPalette.vue +210 -0
- package/src/web/components/ShipTrend.vue +510 -0
- package/src/web/components/ShortcutsOverlay.vue +164 -0
- package/src/web/components/Term.vue +177 -0
- package/src/web/components/Toast.vue +50 -0
- package/src/web/components/TriageMeters.vue +426 -0
- package/src/web/components/TypeChip.vue +96 -0
- package/src/web/components/Walkthrough.vue +209 -0
- package/src/web/components/WhatIfPanel.vue +206 -0
- package/src/web/components/WipBullets.vue +191 -0
- package/src/web/components/filter-option.ts +9 -0
- package/src/web/composables/url-codec.ts +136 -0
- package/src/web/composables/useBeadTooltip.ts +148 -0
- package/src/web/composables/useKeyboard.ts +97 -0
- package/src/web/composables/useLiveRefresh.ts +69 -0
- package/src/web/composables/useTheme.ts +125 -0
- package/src/web/composables/useUrlState.ts +208 -0
- package/src/web/controls-scope.ts +83 -0
- package/src/web/cost.ts +251 -0
- package/src/web/dep-headings.ts +62 -0
- package/src/web/economics.ts +440 -0
- package/src/web/env.d.ts +85 -0
- package/src/web/frontier.ts +208 -0
- package/src/web/gantt-viewport.ts +99 -0
- package/src/web/highlights.ts +124 -0
- package/src/web/index.html +46 -0
- package/src/web/insights.ts +107 -0
- package/src/web/keybindings.ts +200 -0
- package/src/web/load-meter.ts +72 -0
- package/src/web/main.ts +20 -0
- package/src/web/markdown.ts +14 -0
- package/src/web/mine.ts +137 -0
- package/src/web/mutations.ts +21 -0
- package/src/web/person.ts +102 -0
- package/src/web/projects-text.ts +15 -0
- package/src/web/projects.ts +188 -0
- package/src/web/refresh.ts +47 -0
- package/src/web/search.ts +50 -0
- package/src/web/shortcuts.ts +113 -0
- package/src/web/status-filter.ts +48 -0
- package/src/web/store.ts +1378 -0
- package/src/web/style-audit.mjs +346 -0
- package/src/web/styles-alt.css +111 -0
- package/src/web/styles-ported.css +270 -0
- package/src/web/subgraph.ts +362 -0
- package/src/web/table.ts +201 -0
- package/src/web/theme.ts +88 -0
- package/src/web/tokens.css +168 -0
- package/src/web/triage.ts +914 -0
- package/src/web/view-model.ts +717 -0
- package/src/web/walkthrough.ts +133 -0
- package/src/web/watchlist.ts +47 -0
- package/src/web/whatif.ts +291 -0
- package/src/web/window.ts +73 -0
- 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
|
+
}
|