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
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// beadcyte — single-SVG Gantt of bead history + projected future.
|
|
3
|
+
//
|
|
4
|
+
// Past bars: real `started_at → closed_at` for shipped beads (colored by
|
|
5
|
+
// outcome). Future bars: greedy WIP-cap scheduler (src/scheduler.mjs) using a
|
|
6
|
+
// historical-median duration estimator (src/estimator.mjs). Grouping default:
|
|
7
|
+
// parent epic (unparented → "no epic" swimlane at bottom). Non-spec:ready
|
|
8
|
+
// backlog is drawn faint; a bar whose assignee the scheduler inferred is
|
|
9
|
+
// diagonal-hatched — the same vocabulary as the interactive Gantt.
|
|
10
|
+
//
|
|
11
|
+
// beadcyte --out /tmp/gantt.svg
|
|
12
|
+
// beadcyte --from -60d --to +60d --group-by assignee
|
|
13
|
+
// beadcyte --roster ./roster.json # per-project WIP caps + affinities
|
|
14
|
+
// beadcyte --roster ./.beadcyte/roster.json # conventional default
|
|
15
|
+
//
|
|
16
|
+
// Deliberately zero runtime deps beyond node + `bd`. Reads the CWD's beads DB
|
|
17
|
+
// via `bd list --all --limit 0 --json` — run from any beads project.
|
|
18
|
+
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import { writeFileSync } from "node:fs";
|
|
21
|
+
import { execSync } from "node:child_process";
|
|
22
|
+
import { existsSync } from "node:fs";
|
|
23
|
+
import { buildEstimator } from "./estimator.mjs";
|
|
24
|
+
import { resolveRosterPath, ROSTER_CANDIDATES } from "./roster-path.mjs";
|
|
25
|
+
import { scheduleGreedy } from "./scheduler.mjs";
|
|
26
|
+
import { stageWaits } from "./stage-waits.mjs";
|
|
27
|
+
import { isShipped } from "./ship.mjs";
|
|
28
|
+
import { formatDays } from "./format.mjs";
|
|
29
|
+
import { computeVelocity, calibrateRoster } from "./velocity.mjs";
|
|
30
|
+
|
|
31
|
+
// ── args ──────────────────────────────────────────────────────────────────
|
|
32
|
+
const args = parseArgs(process.argv.slice(2));
|
|
33
|
+
if (args.help || args.h) {
|
|
34
|
+
printHelp();
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
const fromDays = parseRelDays(args.from ?? "-90d");
|
|
38
|
+
const toDays = parseRelDays(args.to ?? "+90d");
|
|
39
|
+
const groupBy = args["group-by"] ?? "epic";
|
|
40
|
+
const includeDeferred = args["include-deferred"] === "true";
|
|
41
|
+
const maxPerGroup = parseInt(args["max-per-group"] ?? "20", 10);
|
|
42
|
+
const outPath = args.out ?? "./beadcyte.svg";
|
|
43
|
+
const rosterPath = args.roster;
|
|
44
|
+
const velocityLookback = parseInt(args["velocity-lookback"] ?? "60", 10);
|
|
45
|
+
const noCalibrate = args["no-calibrate"] === "true";
|
|
46
|
+
|
|
47
|
+
const today = startOfDay(new Date());
|
|
48
|
+
const t0 = addDays(today, fromDays);
|
|
49
|
+
const t1 = addDays(today, toDays);
|
|
50
|
+
const totalDays = daysBetween(t0, t1);
|
|
51
|
+
|
|
52
|
+
// ── data ──────────────────────────────────────────────────────────────────
|
|
53
|
+
console.error("[beadcyte] loading beads…");
|
|
54
|
+
const beads = JSON.parse(
|
|
55
|
+
execSync("bd list --all --limit 0 --json", {
|
|
56
|
+
encoding: "utf8",
|
|
57
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
console.error(`[beadcyte] loaded ${beads.length} beads`);
|
|
61
|
+
|
|
62
|
+
// Roster (per-project WIP caps + affinities). Conventional path is
|
|
63
|
+
// `./.beadcyte/roster.json`, override with `--roster`. Shape:
|
|
64
|
+
// {
|
|
65
|
+
// "cap": 5, // default cap for humans
|
|
66
|
+
// "humans": [
|
|
67
|
+
// { "handle": "alice", "cap": 3,
|
|
68
|
+
// "labels": ["component::backend"], // exact-match label affinities
|
|
69
|
+
// "areas": ["schema", "sql", "backend"] // fuzzy title/label keyword affinities
|
|
70
|
+
// },
|
|
71
|
+
// ...
|
|
72
|
+
// ]
|
|
73
|
+
// }
|
|
74
|
+
// If missing, every real assignee-string on a bead becomes a human with the
|
|
75
|
+
// default cap and no affinities — the model still runs, just with no synthetic
|
|
76
|
+
// pool routing.
|
|
77
|
+
let roster = { humans: [], cap: 5 };
|
|
78
|
+
const foundRoster = resolveRosterPath(process.cwd(), rosterPath);
|
|
79
|
+
if (foundRoster) {
|
|
80
|
+
roster = JSON.parse(readFileSync(foundRoster.path, "utf8"));
|
|
81
|
+
} else {
|
|
82
|
+
if (rosterPath) {
|
|
83
|
+
console.error(
|
|
84
|
+
`[beadcyte] --roster ${rosterPath} does not exist; deriving from beads`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const seen = new Set();
|
|
88
|
+
for (const b of beads) if (b.assignee) seen.add(b.assignee);
|
|
89
|
+
roster.humans = [...seen].map((handle) => ({ handle }));
|
|
90
|
+
console.error(
|
|
91
|
+
`[beadcyte] no roster at ${ROSTER_CANDIDATES.join(" or ")}; derived ` +
|
|
92
|
+
`${roster.humans.length} humans from beads (default cap ${roster.cap})`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Build estimator from shipped history — we need its median cycle time
|
|
97
|
+
// for the Little's-Law calibration below, so build it first.
|
|
98
|
+
const { estimate, stats } = buildEstimator(beads);
|
|
99
|
+
|
|
100
|
+
// Reality calibration — measure per-human throughput over the last N days
|
|
101
|
+
// (default 60) and clamp each human's WIP cap using Little's Law
|
|
102
|
+
// (WIP = throughput × cycle time). Without this the projection assumes
|
|
103
|
+
// everyone runs at max WIP forever, which turns 4 devs × cap 5 into 20
|
|
104
|
+
// simultaneous in-flight beads even when the team ships ~10/month.
|
|
105
|
+
// --no-calibrate skips this for brand-new projects with no ship history.
|
|
106
|
+
const velocity = computeVelocity(beads, { lookbackDays: velocityLookback });
|
|
107
|
+
const rawRoster = roster;
|
|
108
|
+
if (!noCalibrate) {
|
|
109
|
+
roster = calibrateRoster(rawRoster, velocity, {
|
|
110
|
+
medianCycleDays: stats.median_lead_days || stats.global_median_days,
|
|
111
|
+
});
|
|
112
|
+
const nudges = roster.humans
|
|
113
|
+
.filter((h) => h._rawCap !== h.cap)
|
|
114
|
+
.map((h) => `${h.handle}=${h._rawCap}→${h.cap}`);
|
|
115
|
+
if (nudges.length) {
|
|
116
|
+
console.error(
|
|
117
|
+
`[beadcyte] calibration (${velocityLookback}d, ${velocity.totalClosedInWindow} ships, lead ${formatDays(stats.median_lead_days)}): ${nudges.join(", ")}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Schedule the future
|
|
123
|
+
const { schedule, unscheduled, unassignedBacklog, unassignedProjection } =
|
|
124
|
+
scheduleGreedy(beads, roster, estimate, t1, {
|
|
125
|
+
waits: stageWaits(beads),
|
|
126
|
+
minLeadDays: stats.median_lead_days || 0,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Merge projections into the primary schedule map for uniform lookup
|
|
130
|
+
for (const [id, s] of unassignedProjection) {
|
|
131
|
+
if (!schedule.has(id)) schedule.set(id, s);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── grouping ──────────────────────────────────────────────────────────────
|
|
135
|
+
const byId = new Map(beads.map((b) => [b.id, b]));
|
|
136
|
+
const groupFor = (b) => {
|
|
137
|
+
if (groupBy === "epic") return b.parent || "__no_epic";
|
|
138
|
+
if (groupBy === "assignee") {
|
|
139
|
+
if (b.assignee) return b.assignee;
|
|
140
|
+
const s = schedule.get(b.id);
|
|
141
|
+
if (s?.assignee) return s.assignee; // synthetic dispatch lands here
|
|
142
|
+
return "__unassigned";
|
|
143
|
+
}
|
|
144
|
+
if (groupBy === "seat") {
|
|
145
|
+
const s = (b.labels ?? []).find((l) => l.startsWith("seat::"));
|
|
146
|
+
return s ?? "__no_seat";
|
|
147
|
+
}
|
|
148
|
+
if (groupBy === "track") {
|
|
149
|
+
const t = (b.labels ?? []).find((l) => l.startsWith("track::"));
|
|
150
|
+
return t ?? "__no_track";
|
|
151
|
+
}
|
|
152
|
+
return "__all";
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Filter to what actually appears in the window
|
|
156
|
+
const visible = [];
|
|
157
|
+
for (const b of beads) {
|
|
158
|
+
if (b.status === "deferred" && !includeDeferred) continue;
|
|
159
|
+
if (b.issue_type === "epic") continue; // epics are group headers, not rows
|
|
160
|
+
|
|
161
|
+
// Past: closed and closed_at within window
|
|
162
|
+
if (b.status === "closed") {
|
|
163
|
+
if (!b.closed_at) continue;
|
|
164
|
+
const closedDay = daysBetween(t0, new Date(b.closed_at));
|
|
165
|
+
if (closedDay < 0 || closedDay > totalDays) continue;
|
|
166
|
+
visible.push(b);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
// Future: has a schedule entry, in-window
|
|
170
|
+
const sched = schedule.get(b.id);
|
|
171
|
+
if (sched) {
|
|
172
|
+
const s = daysBetween(t0, today) + sched.start_day;
|
|
173
|
+
const e = daysBetween(t0, today) + sched.end_day;
|
|
174
|
+
if (e < 0 || s > totalDays) continue;
|
|
175
|
+
visible.push(b);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
// Unscheduled but still "in the future queue" — belongs on the unassigned
|
|
179
|
+
// backlog rail if we're rendering that
|
|
180
|
+
if (unscheduled.includes(b.id) || unassignedBacklog.includes(b)) {
|
|
181
|
+
// Show them stacked at horizon end (no schedule guess)
|
|
182
|
+
visible.push(b);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Group visible beads
|
|
187
|
+
const groups = new Map();
|
|
188
|
+
for (const b of visible) {
|
|
189
|
+
const g = groupFor(b);
|
|
190
|
+
if (!groups.has(g)) groups.set(g, []);
|
|
191
|
+
groups.get(g).push(b);
|
|
192
|
+
}
|
|
193
|
+
// Sort groups: real epics first (by id creation order, then no_epic last)
|
|
194
|
+
const groupOrder = [...groups.keys()].sort((a, b) => {
|
|
195
|
+
const aFake = a.startsWith("__");
|
|
196
|
+
const bFake = b.startsWith("__");
|
|
197
|
+
if (aFake !== bFake) return aFake ? 1 : -1;
|
|
198
|
+
return a.localeCompare(b);
|
|
199
|
+
});
|
|
200
|
+
// Sort within group: by start day, then priority
|
|
201
|
+
const startDayOf = (b) => {
|
|
202
|
+
if (b.status === "closed") return daysBetween(t0, new Date(b.started_at ?? b.closed_at));
|
|
203
|
+
const s = schedule.get(b.id);
|
|
204
|
+
if (s) return daysBetween(t0, today) + s.start_day;
|
|
205
|
+
return totalDays + 999; // unscheduled → far right
|
|
206
|
+
};
|
|
207
|
+
for (const arr of groups.values()) {
|
|
208
|
+
arr.sort((a, b) => startDayOf(a) - startDayOf(b) || (a.priority ?? 4) - (b.priority ?? 4));
|
|
209
|
+
}
|
|
210
|
+
// Cap rows per group. Prefer keeping: in-flight + shipped-in-window +
|
|
211
|
+
// spec:ready future, drop backlog-tail if we overflow. Records the drop count
|
|
212
|
+
// per group for a "+N more" indicator.
|
|
213
|
+
const truncatedBy = new Map();
|
|
214
|
+
for (const [g, arr] of groups) {
|
|
215
|
+
if (arr.length <= maxPerGroup) continue;
|
|
216
|
+
// Priority: past-shipped in window > in-flight > spec:ready > backlog
|
|
217
|
+
const rank = (b) => {
|
|
218
|
+
if (b.status === "closed") return 0;
|
|
219
|
+
if (b.status === "in_progress" || b.status === "in_review") return 1;
|
|
220
|
+
if ((b.labels ?? []).includes("spec:ready")) return 2;
|
|
221
|
+
return 3;
|
|
222
|
+
};
|
|
223
|
+
arr.sort((a, b) => rank(a) - rank(b) || startDayOf(a) - startDayOf(b));
|
|
224
|
+
const kept = arr.slice(0, maxPerGroup);
|
|
225
|
+
truncatedBy.set(g, arr.length - maxPerGroup);
|
|
226
|
+
// Re-sort kept by time for display
|
|
227
|
+
kept.sort((a, b) => startDayOf(a) - startDayOf(b) || (a.priority ?? 4) - (b.priority ?? 4));
|
|
228
|
+
groups.set(g, kept);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── layout constants ──────────────────────────────────────────────────────
|
|
232
|
+
const PX_PER_DAY = 7;
|
|
233
|
+
const LEFT_COL = 340;
|
|
234
|
+
const RIGHT_PAD = 24;
|
|
235
|
+
const ROW_H = 20;
|
|
236
|
+
const GROUP_H = 26;
|
|
237
|
+
const HEADER_H = 56;
|
|
238
|
+
const LEGEND_H = 120;
|
|
239
|
+
const PLOT_W = totalDays * PX_PER_DAY;
|
|
240
|
+
const TOTAL_W = LEFT_COL + PLOT_W + RIGHT_PAD;
|
|
241
|
+
|
|
242
|
+
let plotBodyH = 0;
|
|
243
|
+
for (const g of groupOrder) plotBodyH += GROUP_H + groups.get(g).length * ROW_H + 6;
|
|
244
|
+
const TOTAL_H = HEADER_H + plotBodyH + LEGEND_H;
|
|
245
|
+
|
|
246
|
+
// ── colors ────────────────────────────────────────────────────────────────
|
|
247
|
+
const C = {
|
|
248
|
+
bg: "#0b1220",
|
|
249
|
+
panel: "#111a2b",
|
|
250
|
+
gridWeak: "#1f2a44",
|
|
251
|
+
gridStrong: "#334366",
|
|
252
|
+
text: "#e5e7eb",
|
|
253
|
+
textDim: "#94a3b8",
|
|
254
|
+
today: "#ef4444",
|
|
255
|
+
shipped: "#4ade80",
|
|
256
|
+
shippedStroke: "#166534",
|
|
257
|
+
closedNoShip: "#64748b",
|
|
258
|
+
inProgress: "#60a5fa",
|
|
259
|
+
inReview: "#fbbf24",
|
|
260
|
+
blocked: "#f87171",
|
|
261
|
+
future: "#a78bfa",
|
|
262
|
+
epicHeader: "#c084fc",
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// ── SVG builder ───────────────────────────────────────────────────────────
|
|
266
|
+
const parts = [];
|
|
267
|
+
const svgOpen = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${TOTAL_W} ${TOTAL_H}" width="${TOTAL_W}" height="${TOTAL_H}" font-family="ui-sans-serif,-apple-system,Segoe UI,Roboto,sans-serif" font-size="11">`;
|
|
268
|
+
parts.push(svgOpen);
|
|
269
|
+
|
|
270
|
+
// Defs: diagonal hatch for a synthetic assignee + subtle stripe for
|
|
271
|
+
// in_progress. The hatch is stripes of the future colour with nothing between
|
|
272
|
+
// them, so a bar takes its opacity exactly as a flat fill would and a hatched
|
|
273
|
+
// backlog bar is a faint hatch, not a different colour.
|
|
274
|
+
parts.push(`<defs>
|
|
275
|
+
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
|
|
276
|
+
<rect width="3" height="6" fill="${C.future}"/>
|
|
277
|
+
</pattern>
|
|
278
|
+
<pattern id="live" width="8" height="8" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
|
|
279
|
+
<rect width="8" height="8" fill="${C.inProgress}"/>
|
|
280
|
+
<line x1="0" y1="0" x2="0" y2="8" stroke="#93c5fd" stroke-width="2"/>
|
|
281
|
+
</pattern>
|
|
282
|
+
</defs>`);
|
|
283
|
+
|
|
284
|
+
// Background
|
|
285
|
+
parts.push(`<rect width="${TOTAL_W}" height="${TOTAL_H}" fill="${C.bg}"/>`);
|
|
286
|
+
parts.push(`<rect x="${LEFT_COL}" y="${HEADER_H}" width="${PLOT_W}" height="${plotBodyH}" fill="${C.panel}"/>`);
|
|
287
|
+
|
|
288
|
+
// Header: title + date axis
|
|
289
|
+
parts.push(`<text x="16" y="22" fill="${C.text}" font-size="14" font-weight="600">Beads timeline · ${fmtDate(t0)} → ${fmtDate(t1)}</text>`);
|
|
290
|
+
parts.push(`<text x="16" y="40" fill="${C.textDim}">group by ${groupBy} · ${visible.length} beads in window · generated ${fmtDate(today)}</text>`);
|
|
291
|
+
|
|
292
|
+
// Date grid + week/month ticks
|
|
293
|
+
const weekMs = 7 * 86_400_000;
|
|
294
|
+
for (let d = 0; d <= totalDays; d++) {
|
|
295
|
+
const x = LEFT_COL + d * PX_PER_DAY;
|
|
296
|
+
const date = addDays(t0, d);
|
|
297
|
+
const isMonthStart = date.getDate() === 1;
|
|
298
|
+
const isWeekStart = date.getDay() === 1;
|
|
299
|
+
if (isMonthStart) {
|
|
300
|
+
parts.push(`<line x1="${x}" y1="${HEADER_H}" x2="${x}" y2="${HEADER_H + plotBodyH}" stroke="${C.gridStrong}" stroke-width="1"/>`);
|
|
301
|
+
parts.push(`<text x="${x + 3}" y="${HEADER_H - 4}" fill="${C.text}" font-size="10" font-weight="600">${fmtMonth(date)}</text>`);
|
|
302
|
+
} else if (isWeekStart) {
|
|
303
|
+
parts.push(`<line x1="${x}" y1="${HEADER_H}" x2="${x}" y2="${HEADER_H + plotBodyH}" stroke="${C.gridWeak}" stroke-width="0.5"/>`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Today marker
|
|
308
|
+
const todayX = LEFT_COL + daysBetween(t0, today) * PX_PER_DAY;
|
|
309
|
+
parts.push(`<line x1="${todayX}" y1="${HEADER_H}" x2="${todayX}" y2="${HEADER_H + plotBodyH}" stroke="${C.today}" stroke-width="1.5" stroke-dasharray="3,3"/>`);
|
|
310
|
+
parts.push(`<text x="${todayX + 4}" y="${HEADER_H - 42}" fill="${C.today}" font-size="10" font-weight="600">TODAY</text>`);
|
|
311
|
+
|
|
312
|
+
// Rows
|
|
313
|
+
let y = HEADER_H + 4;
|
|
314
|
+
for (const g of groupOrder) {
|
|
315
|
+
const items = groups.get(g);
|
|
316
|
+
const epicBead = byId.get(g);
|
|
317
|
+
const label =
|
|
318
|
+
g === "__no_epic"
|
|
319
|
+
? "(no epic)"
|
|
320
|
+
: g === "__unassigned"
|
|
321
|
+
? "(unassigned)"
|
|
322
|
+
: g === "__no_seat"
|
|
323
|
+
? "(no seat)"
|
|
324
|
+
: g === "__no_track"
|
|
325
|
+
? "(no track)"
|
|
326
|
+
: epicBead
|
|
327
|
+
? `${g} · ${trunc(epicBead.title, 44)}`
|
|
328
|
+
: g;
|
|
329
|
+
|
|
330
|
+
// Group header stripe
|
|
331
|
+
parts.push(`<rect x="0" y="${y}" width="${TOTAL_W}" height="${GROUP_H}" fill="${C.gridWeak}" opacity="0.4"/>`);
|
|
332
|
+
parts.push(`<text x="10" y="${y + 17}" fill="${C.epicHeader}" font-size="12" font-weight="600">${escapeXml(label)}</text>`);
|
|
333
|
+
const dropped = truncatedBy.get(g) ?? 0;
|
|
334
|
+
const countLabel = dropped
|
|
335
|
+
? `${items.length} shown · ${dropped} more hidden`
|
|
336
|
+
: `${items.length} bead${items.length === 1 ? "" : "s"}`;
|
|
337
|
+
parts.push(`<text x="${TOTAL_W - RIGHT_PAD}" y="${y + 17}" fill="${C.textDim}" font-size="10" text-anchor="end">${countLabel}</text>`);
|
|
338
|
+
y += GROUP_H;
|
|
339
|
+
|
|
340
|
+
for (const b of items) {
|
|
341
|
+
const rowY = y;
|
|
342
|
+
y += ROW_H;
|
|
343
|
+
|
|
344
|
+
// Left label
|
|
345
|
+
const idText = b.id;
|
|
346
|
+
const title = trunc(b.title, 38);
|
|
347
|
+
const prio = b.priority ?? 4;
|
|
348
|
+
parts.push(`<text x="18" y="${rowY + 14}" fill="${C.textDim}" font-size="10" font-family="ui-monospace,monospace">${escapeXml(idText)}</text>`);
|
|
349
|
+
parts.push(`<text x="112" y="${rowY + 14}" fill="${C.text}" font-size="11">${escapeXml(title)}</text>`);
|
|
350
|
+
parts.push(`<text x="${LEFT_COL - 8}" y="${rowY + 14}" fill="${C.textDim}" font-size="9" text-anchor="end">P${prio}</text>`);
|
|
351
|
+
|
|
352
|
+
// Bar(s)
|
|
353
|
+
const bars = barsFor(b, schedule, t0, today, totalDays);
|
|
354
|
+
for (const bar of bars) {
|
|
355
|
+
const x = LEFT_COL + bar.startDay * PX_PER_DAY;
|
|
356
|
+
const w = Math.max(2, (bar.endDay - bar.startDay) * PX_PER_DAY);
|
|
357
|
+
const barY = rowY + 4;
|
|
358
|
+
const barH = ROW_H - 8;
|
|
359
|
+
const fill = bar.fill;
|
|
360
|
+
const stroke = bar.stroke ?? "none";
|
|
361
|
+
const opacity = bar.opacity ?? 1;
|
|
362
|
+
const dashAttr = bar.strokeDash ? ` stroke-dasharray="${bar.strokeDash}"` : "";
|
|
363
|
+
parts.push(`<rect x="${x}" y="${barY}" width="${w}" height="${barH}" fill="${fill}" stroke="${stroke}" stroke-width="0.75"${dashAttr} opacity="${opacity}" rx="2"/>`);
|
|
364
|
+
if (bar.marker === "ship") {
|
|
365
|
+
parts.push(`<circle cx="${x + w}" cy="${barY + barH / 2}" r="3" fill="${C.shipped}" stroke="#052e16" stroke-width="1"/>`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
y += 6;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Legend
|
|
373
|
+
const legY = HEADER_H + plotBodyH + 20;
|
|
374
|
+
parts.push(`<text x="16" y="${legY}" fill="${C.text}" font-size="12" font-weight="600">Legend</text>`);
|
|
375
|
+
const legendItems = [
|
|
376
|
+
{ fill: C.shipped, label: "shipped (past)" },
|
|
377
|
+
{ fill: C.closedNoShip, label: "closed w/o ship" },
|
|
378
|
+
{ fill: "url(#live)", label: "in progress today" },
|
|
379
|
+
{ fill: C.inReview, label: "in review" },
|
|
380
|
+
{ fill: C.future, label: "future · spec:ready" },
|
|
381
|
+
{ fill: C.future, label: "future · backlog (faint)", opacity: 0.35 },
|
|
382
|
+
{ fill: "url(#hatch)", label: "synthetic assignee (hatched)" },
|
|
383
|
+
{ fill: C.blocked, label: "blocked" },
|
|
384
|
+
];
|
|
385
|
+
legendItems.forEach((it, i) => {
|
|
386
|
+
const lx = 16 + (i % 4) * 200;
|
|
387
|
+
const ly = legY + 14 + Math.floor(i / 4) * 20;
|
|
388
|
+
const dash = it.strokeDash ? ` stroke-dasharray="${it.strokeDash}"` : "";
|
|
389
|
+
const op = it.opacity != null ? ` opacity="${it.opacity}"` : "";
|
|
390
|
+
const stroke = it.stroke ?? "#333";
|
|
391
|
+
parts.push(`<rect x="${lx}" y="${ly}" width="16" height="12" fill="${it.fill}" stroke="${stroke}" stroke-width="1"${dash}${op} rx="2"/>`);
|
|
392
|
+
parts.push(`<text x="${lx + 22}" y="${ly + 10}" fill="${C.textDim}" font-size="11">${it.label}</text>`);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// Model footer
|
|
396
|
+
const footerY = legY + 60;
|
|
397
|
+
const modelLine =
|
|
398
|
+
`estimator: median of last ${stats.shipped_sample_size} shipped beads (with ship metadata); ` +
|
|
399
|
+
`bucketed (type,priority) for ${stats.used_type_pri}, fell back (type) for ${stats.used_type}, ` +
|
|
400
|
+
`global for ${stats.used_global}. Global median = ${formatDays(stats.global_median_days)}, ` +
|
|
401
|
+
`p90 = ${formatDays(stats.global_p90_days)}.`;
|
|
402
|
+
parts.push(`<text x="16" y="${footerY}" fill="${C.textDim}" font-size="10">${escapeXml(modelLine)}</text>`);
|
|
403
|
+
// Roster line names each human's calibrated cap alongside their raw cap
|
|
404
|
+
// (parenthesized) + observed weekly rate, so the reader can see how the
|
|
405
|
+
// projection was calibrated against reality.
|
|
406
|
+
const humanBlurbs = (roster.humans ?? []).map((h) => {
|
|
407
|
+
const cap = h.cap ?? roster.cap ?? 5;
|
|
408
|
+
if (h._rawCap !== undefined && h._rawCap !== cap) {
|
|
409
|
+
return `${h.handle}=${cap} (raw ${h._rawCap}, ${h._closesPerWeek.toFixed(1)}/wk)`;
|
|
410
|
+
}
|
|
411
|
+
if (h._closesPerWeek > 0) return `${h.handle}=${cap} (${h._closesPerWeek.toFixed(1)}/wk)`;
|
|
412
|
+
return `${h.handle}=${cap}`;
|
|
413
|
+
});
|
|
414
|
+
const calibrationHint = noCalibrate
|
|
415
|
+
? "calibration OFF"
|
|
416
|
+
: `calibrated to last ${velocityLookback}d (${velocity.totalClosedInWindow} ships, ${velocity.activeCount} active)`;
|
|
417
|
+
parts.push(`<text x="16" y="${footerY + 14}" fill="${C.textDim}" font-size="10">scheduler: greedy WIP-cap · ${calibrationHint} · ${humanBlurbs.join(", ")}. Deps: blocks-edges; spec:ready gates dispatch.</text>`);
|
|
418
|
+
parts.push(`<text x="16" y="${footerY + 28}" fill="${C.textDim}" font-size="10">${unscheduled.length} beads not scheduled within window (blocked, no assignee, or past horizon).</text>`);
|
|
419
|
+
|
|
420
|
+
parts.push(`</svg>`);
|
|
421
|
+
writeFileSync(outPath, parts.join("\n"));
|
|
422
|
+
console.error(`[beadcyte] wrote ${outPath} (${(parts.join("").length / 1024).toFixed(1)} KB)`);
|
|
423
|
+
console.error(`[beadcyte] estimator: type-pri=${stats.used_type_pri}, type=${stats.used_type}, global=${stats.used_global}`);
|
|
424
|
+
console.error(`[beadcyte] unscheduled: ${unscheduled.length}`);
|
|
425
|
+
|
|
426
|
+
// ── helpers ───────────────────────────────────────────────────────────────
|
|
427
|
+
function barsFor(b, schedule, t0, today, totalDays) {
|
|
428
|
+
if (b.status === "closed") {
|
|
429
|
+
// Past bar. `started_at` is often minutes before `closed_at` (bd close
|
|
430
|
+
// right after claim), so enforce a minimum visual duration of 1 day —
|
|
431
|
+
// the bar is a rendering hint, the ship marker is the truth.
|
|
432
|
+
const start = b.started_at ? new Date(b.started_at) : new Date(b.closed_at);
|
|
433
|
+
const end = new Date(b.closed_at);
|
|
434
|
+
const s = daysBetween(t0, start);
|
|
435
|
+
const eRaw = daysBetween(t0, end);
|
|
436
|
+
const e = Math.max(eRaw, s + 1); // min 1-day bar
|
|
437
|
+
const sc = clamp(s, 0, totalDays);
|
|
438
|
+
const ec = clamp(e, 0, totalDays);
|
|
439
|
+
const shipped = isShipped(b);
|
|
440
|
+
return [
|
|
441
|
+
{
|
|
442
|
+
startDay: sc,
|
|
443
|
+
endDay: Math.max(ec, sc + 0.5),
|
|
444
|
+
fill: shipped ? C.shipped : C.closedNoShip,
|
|
445
|
+
stroke: shipped ? C.shippedStroke : "none",
|
|
446
|
+
marker: shipped ? "ship" : null,
|
|
447
|
+
opacity: shipped ? 0.95 : 0.5,
|
|
448
|
+
},
|
|
449
|
+
];
|
|
450
|
+
}
|
|
451
|
+
const sched = schedule.get(b.id);
|
|
452
|
+
if (sched) {
|
|
453
|
+
const todayDay = daysBetween(t0, today);
|
|
454
|
+
// Unassigned projections use absolute day indices (from today); regular
|
|
455
|
+
// schedule entries use relative to today. Detect by presence of `unassigned`.
|
|
456
|
+
const relToday = sched.unassigned ? todayDay : todayDay;
|
|
457
|
+
const startDay = clamp(relToday + sched.start_day, 0, totalDays);
|
|
458
|
+
const endDay = clamp(relToday + sched.end_day, 0, totalDays);
|
|
459
|
+
const bars = [];
|
|
460
|
+
// Same vocabulary as the interactive Gantt (BeadsGantt.vue): backlog is
|
|
461
|
+
// faint, an inferred assignee is hatched, and the two compose — a
|
|
462
|
+
// hatched backlog bar is a faint hatch. The dashed stroke this replaces
|
|
463
|
+
// was the epic colour on the future colour, a few degrees of hue apart
|
|
464
|
+
// on a 12px bar, and read as nothing.
|
|
465
|
+
const fill = sched.synthetic_assignee ? "url(#hatch)" : C.future;
|
|
466
|
+
|
|
467
|
+
if (sched.in_progress_at_start && startDay < todayDay) {
|
|
468
|
+
bars.push({
|
|
469
|
+
startDay,
|
|
470
|
+
endDay: todayDay,
|
|
471
|
+
fill: "url(#live)",
|
|
472
|
+
});
|
|
473
|
+
bars.push({
|
|
474
|
+
startDay: todayDay,
|
|
475
|
+
endDay: Math.max(endDay, todayDay + 0.5),
|
|
476
|
+
fill,
|
|
477
|
+
opacity: sched.backlog ? 0.35 : 0.75,
|
|
478
|
+
});
|
|
479
|
+
} else {
|
|
480
|
+
bars.push({
|
|
481
|
+
startDay,
|
|
482
|
+
endDay: Math.max(endDay, startDay + 0.5),
|
|
483
|
+
fill,
|
|
484
|
+
opacity: sched.backlog ? 0.35 : sched.unassigned ? 0.45 : 0.85,
|
|
485
|
+
stroke: sched.unassigned ? "#4c1d95" : "none",
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
return bars;
|
|
489
|
+
}
|
|
490
|
+
return [];
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function parseArgs(argv) {
|
|
494
|
+
const out = {};
|
|
495
|
+
for (let i = 0; i < argv.length; i++) {
|
|
496
|
+
const a = argv[i];
|
|
497
|
+
if (!a.startsWith("--")) continue;
|
|
498
|
+
const k = a.slice(2);
|
|
499
|
+
const v = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true";
|
|
500
|
+
out[k] = v;
|
|
501
|
+
}
|
|
502
|
+
return out;
|
|
503
|
+
}
|
|
504
|
+
function parseRelDays(s) {
|
|
505
|
+
const m = String(s).match(/^([+-]?\d+)d$/);
|
|
506
|
+
if (!m) throw new Error(`bad --from/--to: ${s} (want e.g. -90d, +30d)`);
|
|
507
|
+
return parseInt(m[1], 10);
|
|
508
|
+
}
|
|
509
|
+
function startOfDay(d) {
|
|
510
|
+
const x = new Date(d);
|
|
511
|
+
x.setHours(0, 0, 0, 0);
|
|
512
|
+
return x;
|
|
513
|
+
}
|
|
514
|
+
function addDays(d, n) {
|
|
515
|
+
const x = new Date(d);
|
|
516
|
+
x.setDate(x.getDate() + n);
|
|
517
|
+
return startOfDay(x);
|
|
518
|
+
}
|
|
519
|
+
function daysBetween(a, b) {
|
|
520
|
+
return Math.round((b.getTime() - a.getTime()) / 86_400_000);
|
|
521
|
+
}
|
|
522
|
+
function clamp(v, lo, hi) {
|
|
523
|
+
return Math.max(lo, Math.min(hi, v));
|
|
524
|
+
}
|
|
525
|
+
function fmtDate(d) {
|
|
526
|
+
return d.toISOString().slice(0, 10);
|
|
527
|
+
}
|
|
528
|
+
function fmtMonth(d) {
|
|
529
|
+
return d.toLocaleDateString("en-US", { month: "short", year: "2-digit" });
|
|
530
|
+
}
|
|
531
|
+
function trunc(s, n) {
|
|
532
|
+
if (!s) return "";
|
|
533
|
+
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
|
534
|
+
}
|
|
535
|
+
function escapeXml(s) {
|
|
536
|
+
return String(s ?? "")
|
|
537
|
+
.replace(/&/g, "&")
|
|
538
|
+
.replace(/</g, "<")
|
|
539
|
+
.replace(/>/g, ">")
|
|
540
|
+
.replace(/"/g, """)
|
|
541
|
+
.replace(/'/g, "'");
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function printHelp() {
|
|
545
|
+
console.log(`beadcyte — Gantt of bead history + projected future
|
|
546
|
+
|
|
547
|
+
Usage:
|
|
548
|
+
beadcyte [--from -Nd] [--to +Nd] [--group-by MODE] [--out PATH] [flags]
|
|
549
|
+
|
|
550
|
+
Runs against the beads DB in the CURRENT WORKING DIRECTORY (bd list --all).
|
|
551
|
+
Emits a single self-contained SVG.
|
|
552
|
+
|
|
553
|
+
Flags:
|
|
554
|
+
--from -Nd Window start relative to today (default: -90d)
|
|
555
|
+
--to +Nd Window end relative to today (default: +90d)
|
|
556
|
+
--group-by MODE epic (default) | assignee | seat | track
|
|
557
|
+
--max-per-group N Rows per swimlane before truncation (default: 20)
|
|
558
|
+
--include-deferred Include status=deferred beads (default: false)
|
|
559
|
+
--roster PATH Per-project WIP caps + affinities
|
|
560
|
+
(default: .beadcyte/roster.json)
|
|
561
|
+
--velocity-lookback N Days of history for close-rate calibration
|
|
562
|
+
(default: 60)
|
|
563
|
+
--no-calibrate Skip calibration — trust raw WIP caps as-is
|
|
564
|
+
(useful for brand-new projects with no shipped
|
|
565
|
+
history yet)
|
|
566
|
+
--out PATH SVG output path (default: ./beads-gantt.svg)
|
|
567
|
+
-h, --help This message
|
|
568
|
+
|
|
569
|
+
Roster JSON shape:
|
|
570
|
+
{ "cap": 5, "humans": [
|
|
571
|
+
{ "handle": "alice", "cap": 3,
|
|
572
|
+
"labels": ["component::backend"],
|
|
573
|
+
"areas": ["schema", "sql", "backend"] } ] }
|
|
574
|
+
|
|
575
|
+
If no roster file exists, one is derived from bead assignees at the default cap.
|
|
576
|
+
`);
|
|
577
|
+
}
|