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,717 @@
|
|
|
1
|
+
// view-model.ts — pure adapter: bd JSON → { rows, bars, groups }.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors the visibility, grouping, sorting, truncation, and bar-generation
|
|
4
|
+
// rules used by the CLI SVG emitter (src/cli.mjs), so the interactive Gantt
|
|
5
|
+
// renders the same picture the static SVG would. All coordinates are in
|
|
6
|
+
// "days from window start" — pixel math is the renderer's problem, and the
|
|
7
|
+
// scheduler entries stay in the same shape scheduleGreedy() returns.
|
|
8
|
+
//
|
|
9
|
+
// Deliberately no DOM/DDV knowledge, no reactivity, no I/O. Consumed by the
|
|
10
|
+
// Pinia store (src/web/store.ts) as a computed derivation of raw beads +
|
|
11
|
+
// filter/group/window state.
|
|
12
|
+
|
|
13
|
+
import type { Bead } from "./store";
|
|
14
|
+
import { isShipped } from "../ship.mjs";
|
|
15
|
+
import { hasOpenBlocker } from "./highlights.ts";
|
|
16
|
+
|
|
17
|
+
// ── shapes ────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export interface ScheduleEntry {
|
|
20
|
+
id: string;
|
|
21
|
+
/** Whole span: the start of any wait before the work, to the landing. */
|
|
22
|
+
start_day: number;
|
|
23
|
+
end_day: number;
|
|
24
|
+
/** The work itself (bp-m0g). Absent on entries from an older scheduler. */
|
|
25
|
+
work_start_day?: number;
|
|
26
|
+
work_end_day?: number;
|
|
27
|
+
wait_before_days?: number;
|
|
28
|
+
wait_after_days?: number;
|
|
29
|
+
backlog?: boolean;
|
|
30
|
+
assignee?: string;
|
|
31
|
+
synthetic_assignee?: boolean;
|
|
32
|
+
unassigned?: boolean;
|
|
33
|
+
in_progress_at_start?: boolean;
|
|
34
|
+
truncated?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type Schedule = Map<string, ScheduleEntry>;
|
|
38
|
+
|
|
39
|
+
export type GroupBy =
|
|
40
|
+
| "epic"
|
|
41
|
+
| "assignee"
|
|
42
|
+
| "seat"
|
|
43
|
+
| "track"
|
|
44
|
+
| "status"
|
|
45
|
+
| "priority"
|
|
46
|
+
/** One flat list. Still a group internally ("__all") so every consumer
|
|
47
|
+
* keeps one code path; renderers drop the band. */
|
|
48
|
+
| "none";
|
|
49
|
+
|
|
50
|
+
export interface Filters {
|
|
51
|
+
types?: string[] | null;
|
|
52
|
+
priorities?: number[] | null;
|
|
53
|
+
labels?: string[] | null; // ALL of these must be present on the bead
|
|
54
|
+
assignees?: string[] | null;
|
|
55
|
+
/**
|
|
56
|
+
* When set, keep only these beads — plus the children of any watched
|
|
57
|
+
* epic, since watching an epic means "this piece of work", and an epic
|
|
58
|
+
* itself is never a row. Null/absent means no watch filtering.
|
|
59
|
+
*/
|
|
60
|
+
watchedIds?: string[] | null;
|
|
61
|
+
/**
|
|
62
|
+
* The statuses in view. This is the ONLY control over status.
|
|
63
|
+
*
|
|
64
|
+
* There used to be `includeDeferred` and `showClosed` booleans alongside
|
|
65
|
+
* it, applied first and unconditionally, so asking for `statuses:
|
|
66
|
+
* ["closed"]` with the toggle off returned nothing and said nothing about
|
|
67
|
+
* why (bp-67g.40). Two controls for one axis, one of them invisible in its
|
|
68
|
+
* effect, and no combination the toggles enabled that this could not
|
|
69
|
+
* express on its own.
|
|
70
|
+
*
|
|
71
|
+
* Null or empty means every status — including closed and deferred. The
|
|
72
|
+
* app pre-selects everything except those two on first load, so the
|
|
73
|
+
* default view is unchanged but its reason is visible in the panel rather
|
|
74
|
+
* than being an invisible default.
|
|
75
|
+
*/
|
|
76
|
+
statuses?: string[] | null;
|
|
77
|
+
/**
|
|
78
|
+
* Keep only beads with no open `blocks` dependency (bp-egy). The check is
|
|
79
|
+
* highlights.ts's hasOpenBlocker, the same one the blocked overlay uses,
|
|
80
|
+
* so "unblocked" here and "blocked" there can never disagree.
|
|
81
|
+
*/
|
|
82
|
+
unblocked?: boolean | null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface WindowSpec {
|
|
86
|
+
from: Date | null; // null = derive from data (all history)
|
|
87
|
+
to: Date | null; // null = derive from schedule horizon
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface Row {
|
|
91
|
+
index: number;
|
|
92
|
+
beadId: string;
|
|
93
|
+
title: string;
|
|
94
|
+
priority: number;
|
|
95
|
+
status: string;
|
|
96
|
+
issueType: string;
|
|
97
|
+
/** Resolved the same way groupId's "assignee" case does: the bead's own
|
|
98
|
+
* assignee, else the scheduler's, else null for unassigned. */
|
|
99
|
+
assignee: string | null;
|
|
100
|
+
/** The scheduler inferred this assignment rather than reading it off the
|
|
101
|
+
* bead — a guess, and never to be presented as recorded fact. */
|
|
102
|
+
syntheticAssignee: boolean;
|
|
103
|
+
groupId: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type BarKind =
|
|
107
|
+
| "shipped"
|
|
108
|
+
| "closedNoShip"
|
|
109
|
+
| "inProgressLive" // past-portion of a bead in progress at t=0
|
|
110
|
+
| "future"
|
|
111
|
+
| "futureBacklog"
|
|
112
|
+
| "unassigned" // future from the unassigned pool
|
|
113
|
+
| "waitUnclaimed" // the wait clock: before a pool pick's work (bp-6cg)
|
|
114
|
+
| "waitReview"; // the wait clock: after the work, before it lands
|
|
115
|
+
|
|
116
|
+
/** The wait clock's kinds: drawn dulled and truncated, not counted as bars. */
|
|
117
|
+
export function isWaitKind(kind: BarKind): boolean {
|
|
118
|
+
return kind === "waitUnclaimed" || kind === "waitReview";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* What each kind means, in the words of barsFor() below (bp-67g.48). Shown
|
|
123
|
+
* on hover in the Gantt footer and listed by the first-run walkthrough, so a
|
|
124
|
+
* kind cannot be drawn without being explained, or explained two ways.
|
|
125
|
+
*/
|
|
126
|
+
export const BAR_KIND_NOTES: Record<BarKind, string> = {
|
|
127
|
+
shipped: "Closed with ship evidence — a merge request or a commit on the bead. Real started_at → closed_at, with a ship marker.",
|
|
128
|
+
closedNoShip: "Closed with no ship evidence recorded: an administrative close, or work that shipped without saying so. Drawn dim.",
|
|
129
|
+
inProgressLive: "The part of an in-progress bead already spent: from its recorded start to today. What follows it, right of today, is the projection.",
|
|
130
|
+
future: "Projected: the scheduler's plan for ready work, its length the estimator's median for comparable shipped work.",
|
|
131
|
+
futureBacklog: "Projected, but backlog: not spec:ready, so scheduled after the ready work and drawn faint.",
|
|
132
|
+
unassigned: "Projected from the unassigned pool: nobody is on it, so the scheduler shows it where a free slot would take it.",
|
|
133
|
+
waitUnclaimed: "Waiting, before the work: this project's median time from filing to someone starting, added when the scheduler routes an unassigned bead. Drawn dull and cut short — its length is not to scale; the landing date is.",
|
|
134
|
+
waitReview: "Waiting, after the work: this project's median review time (or its lead-time floor where none is measured) before the bead lands. Drawn dull and cut short at the landing — not to scale; the date is.",
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export interface Bar {
|
|
138
|
+
rowIndex: number;
|
|
139
|
+
beadId: string;
|
|
140
|
+
startDay: number; // days from window start (t0)
|
|
141
|
+
endDay: number;
|
|
142
|
+
kind: BarKind;
|
|
143
|
+
synthetic?: boolean; // model-inferred assignee
|
|
144
|
+
shipMarker?: boolean;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface Group {
|
|
148
|
+
id: string;
|
|
149
|
+
label: string;
|
|
150
|
+
rowCount: number;
|
|
151
|
+
truncatedCount: number;
|
|
152
|
+
/**
|
|
153
|
+
* How many of the rows shown carry an assignee the scheduler inferred
|
|
154
|
+
* rather than one recorded on the bead. Counted over the rows actually
|
|
155
|
+
* displayed, like rowCount, so "7 beads · 2 inferred" describes the lane
|
|
156
|
+
* the reader is looking at. A lane where this is most of rowCount is the
|
|
157
|
+
* model's plan for someone, not their commitments (bp-67g.49).
|
|
158
|
+
*/
|
|
159
|
+
syntheticCount: number;
|
|
160
|
+
/**
|
|
161
|
+
* Progress across the WHOLE group population (all children, before any
|
|
162
|
+
* filter or window clipping — so hiding closed beads doesn't drop the
|
|
163
|
+
* denominator). Renderers use this for a group-header progress chip.
|
|
164
|
+
*/
|
|
165
|
+
progress: {
|
|
166
|
+
closed: number;
|
|
167
|
+
total: number;
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface ViewModelMeta {
|
|
172
|
+
t0: Date;
|
|
173
|
+
t1: Date;
|
|
174
|
+
totalDays: number;
|
|
175
|
+
todayDay: number;
|
|
176
|
+
visibleCount: number;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface ViewModel {
|
|
180
|
+
rows: Row[];
|
|
181
|
+
bars: Bar[];
|
|
182
|
+
groups: Group[];
|
|
183
|
+
meta: ViewModelMeta;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface AssembleOptions {
|
|
187
|
+
groupBy: GroupBy;
|
|
188
|
+
filters: Filters;
|
|
189
|
+
window: WindowSpec;
|
|
190
|
+
maxPerGroup?: number; // default 20; Infinity disables truncation
|
|
191
|
+
/**
|
|
192
|
+
* Group ids the caller has explicitly expanded. Truncation is lifted for
|
|
193
|
+
* these groups — all rows appear regardless of maxPerGroup. Empty by
|
|
194
|
+
* default so callers who don't wire an expand UI keep the old behavior.
|
|
195
|
+
*/
|
|
196
|
+
expandedGroups?: Set<string>;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── main entry point ──────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Assemble the reactive view model. Pure, O(n log n) in bead count — the log
|
|
203
|
+
* factor is the per-group sort. Runs comfortably under 50ms for ~500 beads.
|
|
204
|
+
*/
|
|
205
|
+
export function assembleViewModel(
|
|
206
|
+
beads: Bead[],
|
|
207
|
+
schedule: Schedule,
|
|
208
|
+
opts: AssembleOptions,
|
|
209
|
+
): ViewModel {
|
|
210
|
+
const filters = opts.filters ?? {};
|
|
211
|
+
const maxPerGroup = opts.maxPerGroup ?? 20;
|
|
212
|
+
|
|
213
|
+
const today = startOfDay(new Date());
|
|
214
|
+
const { t0, t1 } = resolveWindow(opts.window, beads, schedule, today);
|
|
215
|
+
const totalDays = daysBetween(t0, t1);
|
|
216
|
+
const todayDay = daysBetween(t0, today);
|
|
217
|
+
const byId = new Map(beads.map((b) => [b.id, b] as const));
|
|
218
|
+
|
|
219
|
+
// Progress totals per group id, computed BEFORE filter/window clipping.
|
|
220
|
+
// Denominator stays honest when the user hides closed beads or narrows
|
|
221
|
+
// the window — the chip in the group header still shows the true state.
|
|
222
|
+
const progressByGroup = new Map<string, { closed: number; total: number }>();
|
|
223
|
+
const bumpProgress = (gid: string, isClosed: boolean) => {
|
|
224
|
+
let p = progressByGroup.get(gid);
|
|
225
|
+
if (!p) {
|
|
226
|
+
p = { closed: 0, total: 0 };
|
|
227
|
+
progressByGroup.set(gid, p);
|
|
228
|
+
}
|
|
229
|
+
p.total++;
|
|
230
|
+
if (isClosed) p.closed++;
|
|
231
|
+
};
|
|
232
|
+
for (const b of beads) {
|
|
233
|
+
if (b.issue_type === "epic") continue;
|
|
234
|
+
const gid = groupId(b, opts.groupBy, schedule.get(b.id));
|
|
235
|
+
bumpProgress(gid, b.status === "closed");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 1. Visibility — same rules as cli.mjs:117-144. Epics are group headers,
|
|
239
|
+
// not rows. Filter passes come first (cheap), then window-clipping.
|
|
240
|
+
const visible: Bead[] = [];
|
|
241
|
+
for (const b of beads) {
|
|
242
|
+
if (b.issue_type === "epic") continue;
|
|
243
|
+
if (!passesFilters(b, filters, schedule.get(b.id), byId)) continue;
|
|
244
|
+
|
|
245
|
+
if (b.status === "closed") {
|
|
246
|
+
if (!b.closed_at) continue;
|
|
247
|
+
const closedDay = daysBetween(t0, new Date(b.closed_at));
|
|
248
|
+
if (closedDay < 0 || closedDay > totalDays) continue;
|
|
249
|
+
visible.push(b);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const sched = schedule.get(b.id);
|
|
254
|
+
if (sched) {
|
|
255
|
+
const s = todayDay + sched.start_day;
|
|
256
|
+
const e = todayDay + sched.end_day;
|
|
257
|
+
if (e < 0 || s > totalDays) continue;
|
|
258
|
+
visible.push(b);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
// No schedule entry, not closed: appears at horizon-end. Deferred beads
|
|
262
|
+
// land here whenever `deferred` is in the status selection — the
|
|
263
|
+
// scheduler skips them, so there is no schedule entry to window-check.
|
|
264
|
+
visible.push(b);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 2. Group visible beads.
|
|
268
|
+
const groupBuckets = new Map<string, Bead[]>();
|
|
269
|
+
const groupIdFor = (b: Bead) => groupId(b, opts.groupBy, schedule.get(b.id));
|
|
270
|
+
for (const b of visible) {
|
|
271
|
+
const g = groupIdFor(b);
|
|
272
|
+
let arr = groupBuckets.get(g);
|
|
273
|
+
if (!arr) {
|
|
274
|
+
arr = [];
|
|
275
|
+
groupBuckets.set(g, arr);
|
|
276
|
+
}
|
|
277
|
+
arr.push(b);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 3. Order groups: real IDs first (sorted), synthetic __placeholders last.
|
|
281
|
+
const groupOrder = [...groupBuckets.keys()].sort((a, b) => {
|
|
282
|
+
const aFake = a.startsWith("__");
|
|
283
|
+
const bFake = b.startsWith("__");
|
|
284
|
+
if (aFake !== bFake) return aFake ? 1 : -1;
|
|
285
|
+
return a.localeCompare(b);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// 4. Sort within group by start-day, then priority, then id (stable-ish).
|
|
289
|
+
const startDayOf = (b: Bead): number => {
|
|
290
|
+
if (b.status === "closed") {
|
|
291
|
+
const t = b.started_at ?? b.closed_at;
|
|
292
|
+
return t ? daysBetween(t0, new Date(t)) : totalDays + 999;
|
|
293
|
+
}
|
|
294
|
+
const s = schedule.get(b.id);
|
|
295
|
+
if (s) return todayDay + s.start_day;
|
|
296
|
+
return totalDays + 999;
|
|
297
|
+
};
|
|
298
|
+
for (const arr of groupBuckets.values()) {
|
|
299
|
+
arr.sort(
|
|
300
|
+
(a, b) =>
|
|
301
|
+
startDayOf(a) - startDayOf(b) ||
|
|
302
|
+
(a.priority ?? 4) - (b.priority ?? 4) ||
|
|
303
|
+
a.id.localeCompare(b.id),
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 5. Truncate: keep past-shipped + in-flight + spec:ready before backlog.
|
|
308
|
+
// Groups the caller has explicitly expanded skip this — the user has
|
|
309
|
+
// asked to see everything.
|
|
310
|
+
const truncatedBy = new Map<string, number>();
|
|
311
|
+
const expanded = opts.expandedGroups ?? new Set<string>();
|
|
312
|
+
if (Number.isFinite(maxPerGroup)) {
|
|
313
|
+
for (const [g, arr] of groupBuckets) {
|
|
314
|
+
if (expanded.has(g)) continue;
|
|
315
|
+
if (arr.length <= maxPerGroup) continue;
|
|
316
|
+
const rank = (b: Bead): number => {
|
|
317
|
+
if (b.status === "closed") return 0;
|
|
318
|
+
if (b.status === "in_progress" || b.status === "in_review") return 1;
|
|
319
|
+
if ((b.labels ?? []).includes("spec:ready")) return 2;
|
|
320
|
+
return 3;
|
|
321
|
+
};
|
|
322
|
+
arr.sort(
|
|
323
|
+
(a, b) => rank(a) - rank(b) || startDayOf(a) - startDayOf(b),
|
|
324
|
+
);
|
|
325
|
+
const kept = arr.slice(0, maxPerGroup);
|
|
326
|
+
truncatedBy.set(g, arr.length - maxPerGroup);
|
|
327
|
+
kept.sort(
|
|
328
|
+
(a, b) =>
|
|
329
|
+
startDayOf(a) - startDayOf(b) ||
|
|
330
|
+
(a.priority ?? 4) - (b.priority ?? 4) ||
|
|
331
|
+
a.id.localeCompare(b.id),
|
|
332
|
+
);
|
|
333
|
+
groupBuckets.set(g, kept);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// 6. Materialize rows + bars in group order.
|
|
338
|
+
const rows: Row[] = [];
|
|
339
|
+
const bars: Bar[] = [];
|
|
340
|
+
const groups: Group[] = [];
|
|
341
|
+
|
|
342
|
+
for (const gid of groupOrder) {
|
|
343
|
+
const items = groupBuckets.get(gid) ?? [];
|
|
344
|
+
let syntheticCount = 0;
|
|
345
|
+
for (const b of items) {
|
|
346
|
+
const rowIndex = rows.length;
|
|
347
|
+
const sched = schedule.get(b.id);
|
|
348
|
+
const assigned = resolveAssignee(b, sched);
|
|
349
|
+
if (assigned.synthetic) syntheticCount++;
|
|
350
|
+
rows.push({
|
|
351
|
+
index: rowIndex,
|
|
352
|
+
beadId: b.id,
|
|
353
|
+
title: b.title,
|
|
354
|
+
priority: b.priority ?? 4,
|
|
355
|
+
status: b.status,
|
|
356
|
+
issueType: b.issue_type,
|
|
357
|
+
assignee: assigned.handle,
|
|
358
|
+
syntheticAssignee: assigned.synthetic,
|
|
359
|
+
groupId: gid,
|
|
360
|
+
});
|
|
361
|
+
for (const bar of barsFor(b, sched, t0, todayDay, totalDays)) {
|
|
362
|
+
bars.push({ ...bar, rowIndex, beadId: b.id });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
groups.push({
|
|
366
|
+
id: gid,
|
|
367
|
+
label: groupLabel(gid, opts.groupBy, byId),
|
|
368
|
+
rowCount: items.length,
|
|
369
|
+
truncatedCount: truncatedBy.get(gid) ?? 0,
|
|
370
|
+
syntheticCount,
|
|
371
|
+
progress: progressByGroup.get(gid) ?? { closed: 0, total: 0 },
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
rows,
|
|
377
|
+
bars,
|
|
378
|
+
groups,
|
|
379
|
+
meta: { t0, t1, totalDays, todayDay, visibleCount: rows.length },
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ── window resolution ─────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
function resolveWindow(
|
|
386
|
+
w: WindowSpec,
|
|
387
|
+
beads: Bead[],
|
|
388
|
+
schedule: Schedule,
|
|
389
|
+
today: Date,
|
|
390
|
+
): { t0: Date; t1: Date } {
|
|
391
|
+
let t0 = w.from ? startOfDay(w.from) : null;
|
|
392
|
+
let t1 = w.to ? startOfDay(w.to) : null;
|
|
393
|
+
if (t0 && t1) return { t0, t1 };
|
|
394
|
+
|
|
395
|
+
// Derive from data. Scan for min started_at / max end.
|
|
396
|
+
let minMs = today.getTime();
|
|
397
|
+
let maxMs = today.getTime();
|
|
398
|
+
for (const b of beads) {
|
|
399
|
+
if (b.started_at) minMs = Math.min(minMs, +new Date(b.started_at));
|
|
400
|
+
if (b.closed_at) maxMs = Math.max(maxMs, +new Date(b.closed_at));
|
|
401
|
+
}
|
|
402
|
+
for (const s of schedule.values()) {
|
|
403
|
+
const end = today.getTime() + s.end_day * 86_400_000;
|
|
404
|
+
maxMs = Math.max(maxMs, end);
|
|
405
|
+
}
|
|
406
|
+
if (!t0) t0 = startOfDay(new Date(minMs));
|
|
407
|
+
if (!t1) t1 = startOfDay(new Date(maxMs));
|
|
408
|
+
// Guarantee t1 > t0
|
|
409
|
+
if (t1.getTime() <= t0.getTime()) t1 = addDays(t0, 1);
|
|
410
|
+
return { t0, t1 };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ── filters ───────────────────────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
function passesFilters(
|
|
416
|
+
b: Bead,
|
|
417
|
+
f: Filters,
|
|
418
|
+
sched: ScheduleEntry | undefined,
|
|
419
|
+
byId: Map<string, Bead> | null,
|
|
420
|
+
): boolean {
|
|
421
|
+
if (f.unblocked && byId && hasOpenBlocker(b, byId)) return false;
|
|
422
|
+
if (f.types && f.types.length && !f.types.includes(b.issue_type))
|
|
423
|
+
return false;
|
|
424
|
+
if (
|
|
425
|
+
f.priorities &&
|
|
426
|
+
f.priorities.length &&
|
|
427
|
+
!f.priorities.includes(b.priority ?? 4)
|
|
428
|
+
)
|
|
429
|
+
return false;
|
|
430
|
+
if (f.statuses && f.statuses.length && !f.statuses.includes(b.status))
|
|
431
|
+
return false;
|
|
432
|
+
if (f.assignees && f.assignees.length) {
|
|
433
|
+
if (!f.assignees.includes(assigneeKey(b, sched))) return false;
|
|
434
|
+
}
|
|
435
|
+
if (f.watchedIds) {
|
|
436
|
+
const w = new Set(f.watchedIds);
|
|
437
|
+
if (!w.has(b.id) && !(b.parent && w.has(b.parent))) return false;
|
|
438
|
+
}
|
|
439
|
+
if (f.labels && f.labels.length) {
|
|
440
|
+
const bl = new Set(b.labels ?? []);
|
|
441
|
+
for (const l of f.labels) if (!bl.has(l)) return false;
|
|
442
|
+
}
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ── assignee resolution ───────────────────────────────────────────────────
|
|
447
|
+
|
|
448
|
+
/** Group/filter key for a bead with nobody on it. */
|
|
449
|
+
export const UNASSIGNED = "__unassigned";
|
|
450
|
+
|
|
451
|
+
export interface ResolvedAssignee {
|
|
452
|
+
handle: string | null;
|
|
453
|
+
/** The scheduler inferred this rather than reading it off the bead. */
|
|
454
|
+
synthetic: boolean;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* What a synthetic assignment is, for the marker beside one (bp-67g.47).
|
|
459
|
+
* One sentence in one place, next to the resolver that produces the flag.
|
|
460
|
+
*/
|
|
461
|
+
export const SYNTHETIC_ASSIGNEE_NOTE =
|
|
462
|
+
"Nobody is recorded on this bead. The scheduler gave it to this person to plan a lane for it — preferring someone whose roster affinities (labels, then areas) match, then anyone with a free slot — and marked the assignment synthetic. The model's guess, not a decision anyone made.";
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* THE definition of a bead's assignee, for every consumer.
|
|
466
|
+
*
|
|
467
|
+
* Grouping, filtering, the filter's option list and the grid card chip must
|
|
468
|
+
* all agree, or the same bead lands in one person's group while the filter
|
|
469
|
+
* for that person hides it. They used to disagree: everything resolved
|
|
470
|
+
* through the scheduler except the filter, which read b.assignee alone — and
|
|
471
|
+
* since most beads carry no recorded assignee, that hid nearly all of a
|
|
472
|
+
* person's work and dumped it under "(unassigned)" instead.
|
|
473
|
+
*/
|
|
474
|
+
export function resolveAssignee(
|
|
475
|
+
b: Bead,
|
|
476
|
+
sched: ScheduleEntry | undefined,
|
|
477
|
+
): ResolvedAssignee {
|
|
478
|
+
if (b.assignee) return { handle: b.assignee, synthetic: false };
|
|
479
|
+
if (sched?.assignee) {
|
|
480
|
+
return { handle: sched.assignee, synthetic: !!sched.synthetic_assignee };
|
|
481
|
+
}
|
|
482
|
+
return { handle: null, synthetic: false };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** resolveAssignee as a group/filter key, with the unassigned placeholder. */
|
|
486
|
+
export function assigneeKey(
|
|
487
|
+
b: Bead,
|
|
488
|
+
sched: ScheduleEntry | undefined,
|
|
489
|
+
): string {
|
|
490
|
+
return resolveAssignee(b, sched).handle ?? UNASSIGNED;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ── grouping ──────────────────────────────────────────────────────────────
|
|
494
|
+
|
|
495
|
+
function groupId(
|
|
496
|
+
b: Bead,
|
|
497
|
+
mode: GroupBy,
|
|
498
|
+
sched: ScheduleEntry | undefined,
|
|
499
|
+
): string {
|
|
500
|
+
switch (mode) {
|
|
501
|
+
case "epic":
|
|
502
|
+
return b.parent || "__no_epic";
|
|
503
|
+
case "assignee":
|
|
504
|
+
return assigneeKey(b, sched);
|
|
505
|
+
case "seat": {
|
|
506
|
+
const s = (b.labels ?? []).find((l) => l.startsWith("seat::"));
|
|
507
|
+
return s ?? "__no_seat";
|
|
508
|
+
}
|
|
509
|
+
case "track": {
|
|
510
|
+
const t = (b.labels ?? []).find((l) => l.startsWith("track::"));
|
|
511
|
+
return t ?? "__no_track";
|
|
512
|
+
}
|
|
513
|
+
case "status":
|
|
514
|
+
return b.status || "__unknown_status";
|
|
515
|
+
case "priority":
|
|
516
|
+
return `P${b.priority ?? 4}`;
|
|
517
|
+
case "none":
|
|
518
|
+
default:
|
|
519
|
+
return "__all";
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function groupLabel(
|
|
524
|
+
id: string,
|
|
525
|
+
mode: GroupBy,
|
|
526
|
+
byId: Map<string, Bead>,
|
|
527
|
+
): string {
|
|
528
|
+
const placeholders: Record<string, string> = {
|
|
529
|
+
__no_epic: "(no epic)",
|
|
530
|
+
__no_seat: "(no seat)",
|
|
531
|
+
__no_track: "(no track)",
|
|
532
|
+
__unassigned: "(unassigned)",
|
|
533
|
+
__unknown_status: "(unknown status)",
|
|
534
|
+
__all: "(all)",
|
|
535
|
+
};
|
|
536
|
+
if (placeholders[id]) return placeholders[id];
|
|
537
|
+
if (mode === "epic") {
|
|
538
|
+
const epic = byId.get(id);
|
|
539
|
+
return epic ? `${id} · ${truncateTitle(epic.title, 44)}` : id;
|
|
540
|
+
}
|
|
541
|
+
return id;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function truncateTitle(s: string, n: number): string {
|
|
545
|
+
if (!s) return "";
|
|
546
|
+
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ── bars ──────────────────────────────────────────────────────────────────
|
|
550
|
+
|
|
551
|
+
interface RawBar {
|
|
552
|
+
startDay: number;
|
|
553
|
+
endDay: number;
|
|
554
|
+
kind: BarKind;
|
|
555
|
+
synthetic?: boolean;
|
|
556
|
+
shipMarker?: boolean;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function barsFor(
|
|
560
|
+
b: Bead,
|
|
561
|
+
sched: ScheduleEntry | undefined,
|
|
562
|
+
t0: Date,
|
|
563
|
+
todayDay: number,
|
|
564
|
+
totalDays: number,
|
|
565
|
+
): RawBar[] {
|
|
566
|
+
// Closed bars are the measured span, started_at → closed_at, in fractional
|
|
567
|
+
// days (bp-vsr). They used to be rounded to whole days with a one-day
|
|
568
|
+
// minimum, which said "a day" about work that took seventeen minutes and
|
|
569
|
+
// made a two-hour bead and a two-day bead the same width. There is no
|
|
570
|
+
// floor in the data any more: a bar's minimum WIDTH is a rendering matter
|
|
571
|
+
// (a few pixels, in BeadsGantt) and shrinks as the viewport zooms in,
|
|
572
|
+
// while the ship marker is what says "this shipped" at any zoom.
|
|
573
|
+
if (b.status === "closed") {
|
|
574
|
+
if (!b.closed_at) return [];
|
|
575
|
+
const start = b.started_at ? new Date(b.started_at) : new Date(b.closed_at);
|
|
576
|
+
const end = new Date(b.closed_at);
|
|
577
|
+
const s = fractionalDaysBetween(t0, start);
|
|
578
|
+
const e = Math.max(fractionalDaysBetween(t0, end), s);
|
|
579
|
+
const sc = clamp(s, 0, totalDays);
|
|
580
|
+
const ec = clamp(e, 0, totalDays);
|
|
581
|
+
const shipped = isShipped(b);
|
|
582
|
+
return [
|
|
583
|
+
{
|
|
584
|
+
startDay: sc,
|
|
585
|
+
endDay: ec,
|
|
586
|
+
kind: shipped ? "shipped" : "closedNoShip",
|
|
587
|
+
shipMarker: shipped,
|
|
588
|
+
},
|
|
589
|
+
];
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (!sched) return [];
|
|
593
|
+
|
|
594
|
+
// The bar is the WORK (bp-m0g): the bright part. The waits around it set
|
|
595
|
+
// the landing date (end_day) and are their own bars (bp-6cg), drawn dulled
|
|
596
|
+
// and truncated by the Gantt so the picture is mostly cost and the landing
|
|
597
|
+
// end is still where it lands.
|
|
598
|
+
const startDay = clamp(todayDay + (sched.work_start_day ?? sched.start_day), 0, totalDays);
|
|
599
|
+
const endDay = clamp(todayDay + (sched.work_end_day ?? sched.end_day), 0, totalDays);
|
|
600
|
+
const kindFuture: BarKind = sched.unassigned
|
|
601
|
+
? "unassigned"
|
|
602
|
+
: sched.backlog
|
|
603
|
+
? "futureBacklog"
|
|
604
|
+
: "future";
|
|
605
|
+
const waits: RawBar[] = [];
|
|
606
|
+
if (sched.work_start_day !== undefined && sched.work_start_day - sched.start_day > 1e-6) {
|
|
607
|
+
waits.push({
|
|
608
|
+
startDay: clamp(todayDay + sched.start_day, 0, totalDays),
|
|
609
|
+
endDay: startDay,
|
|
610
|
+
kind: "waitUnclaimed",
|
|
611
|
+
synthetic: sched.synthetic_assignee,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
if (sched.work_end_day !== undefined && sched.end_day - sched.work_end_day > 1e-6) {
|
|
615
|
+
waits.push({
|
|
616
|
+
startDay: endDay,
|
|
617
|
+
endDay: clamp(todayDay + sched.end_day, 0, totalDays),
|
|
618
|
+
kind: "waitReview",
|
|
619
|
+
synthetic: sched.synthetic_assignee,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Projected spans are the scheduler's: whole-day starts (it simulates a
|
|
624
|
+
// day at a time, see docs/model.md) and the estimator's fractional length.
|
|
625
|
+
// No half-day floor here either, for the same reason as above.
|
|
626
|
+
if (sched.in_progress_at_start && startDay < todayDay) {
|
|
627
|
+
// Split into a live-past bar + a projected-future bar.
|
|
628
|
+
return [
|
|
629
|
+
{
|
|
630
|
+
startDay,
|
|
631
|
+
endDay: todayDay,
|
|
632
|
+
kind: "inProgressLive",
|
|
633
|
+
synthetic: sched.synthetic_assignee,
|
|
634
|
+
},
|
|
635
|
+
{
|
|
636
|
+
startDay: todayDay,
|
|
637
|
+
endDay: Math.max(endDay, todayDay),
|
|
638
|
+
kind: kindFuture,
|
|
639
|
+
synthetic: sched.synthetic_assignee,
|
|
640
|
+
},
|
|
641
|
+
...waits,
|
|
642
|
+
];
|
|
643
|
+
}
|
|
644
|
+
return [
|
|
645
|
+
{
|
|
646
|
+
startDay,
|
|
647
|
+
endDay: Math.max(endDay, startDay),
|
|
648
|
+
kind: kindFuture,
|
|
649
|
+
synthetic: sched.synthetic_assignee,
|
|
650
|
+
},
|
|
651
|
+
...waits,
|
|
652
|
+
];
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// ── date helpers (duplicated from cli.mjs on purpose — pure module) ───────
|
|
656
|
+
|
|
657
|
+
function startOfDay(d: Date): Date {
|
|
658
|
+
const x = new Date(d);
|
|
659
|
+
x.setHours(0, 0, 0, 0);
|
|
660
|
+
return x;
|
|
661
|
+
}
|
|
662
|
+
function addDays(d: Date, n: number): Date {
|
|
663
|
+
const x = new Date(d);
|
|
664
|
+
x.setDate(x.getDate() + n);
|
|
665
|
+
return startOfDay(x);
|
|
666
|
+
}
|
|
667
|
+
function daysBetween(a: Date, b: Date): number {
|
|
668
|
+
return Math.round((b.getTime() - a.getTime()) / 86_400_000);
|
|
669
|
+
}
|
|
670
|
+
/** The same distance unrounded: for spans that are measured, not scheduled. */
|
|
671
|
+
function fractionalDaysBetween(a: Date, b: Date): number {
|
|
672
|
+
return (b.getTime() - a.getTime()) / 86_400_000;
|
|
673
|
+
}
|
|
674
|
+
function clamp(v: number, lo: number, hi: number): number {
|
|
675
|
+
return Math.max(lo, Math.min(hi, v));
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ── project-wide groups (bp-67g.36) ───────────────────────────────────────
|
|
679
|
+
|
|
680
|
+
/** A group as the project-wide Progress block needs it: who, and how far. */
|
|
681
|
+
export interface ProjectGroup {
|
|
682
|
+
id: string;
|
|
683
|
+
label: string;
|
|
684
|
+
progress: { closed: number; total: number };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Every group the current group-by produces, over EVERY non-epic bead, with
|
|
689
|
+
* its progress. No filter, no window.
|
|
690
|
+
*
|
|
691
|
+
* assembleViewModel counts progress before clipping too, but it only emits a
|
|
692
|
+
* group that still has a visible row — so on the incytes view a window that
|
|
693
|
+
* clipped every bead of a group made that group vanish from Progress while
|
|
694
|
+
* the counts of its neighbours stayed project-wide. Two scopes on one panel.
|
|
695
|
+
* This is the single-scope source: the incytes Progress block reads it, and
|
|
696
|
+
* filters and window do not reach it at all.
|
|
697
|
+
*/
|
|
698
|
+
export function projectGroups(
|
|
699
|
+
beads: readonly Bead[],
|
|
700
|
+
schedule: Schedule,
|
|
701
|
+
groupBy: GroupBy,
|
|
702
|
+
): ProjectGroup[] {
|
|
703
|
+
const byId = new Map(beads.map((b) => [b.id, b] as const));
|
|
704
|
+
const out = new Map<string, ProjectGroup>();
|
|
705
|
+
for (const b of beads) {
|
|
706
|
+
if (b.issue_type === "epic") continue;
|
|
707
|
+
const gid = groupId(b, groupBy, schedule.get(b.id));
|
|
708
|
+
let g = out.get(gid);
|
|
709
|
+
if (!g) {
|
|
710
|
+
g = { id: gid, label: groupLabel(gid, groupBy, byId), progress: { closed: 0, total: 0 } };
|
|
711
|
+
out.set(gid, g);
|
|
712
|
+
}
|
|
713
|
+
g.progress.total++;
|
|
714
|
+
if (b.status === "closed") g.progress.closed++;
|
|
715
|
+
}
|
|
716
|
+
return [...out.values()];
|
|
717
|
+
}
|