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,208 @@
|
|
|
1
|
+
// frontier.ts — the plan's edge for one person (bp-wfe).
|
|
2
|
+
//
|
|
3
|
+
// Two things, both pure:
|
|
4
|
+
//
|
|
5
|
+
// - the LANE: over the next N days, which beads start for this person and
|
|
6
|
+
// which land, read straight off the two-clock schedule (the same entries
|
|
7
|
+
// the Gantt draws), with synthetic assignments marked as such;
|
|
8
|
+
// - PROPOSALS: what this person could take next, ranked with the reasons
|
|
9
|
+
// stated — the scheduler's own routing (one definition, exported from
|
|
10
|
+
// scheduler.mjs: affinityMarksFor / beadAffinityMatches), triage score,
|
|
11
|
+
// unblocking power, priority, and whether they have room today — and,
|
|
12
|
+
// where someone else is over cap, their not-yet-started beads offered
|
|
13
|
+
// across as reassignments.
|
|
14
|
+
//
|
|
15
|
+
// DECISION (owner, 2026-09-05): the planner may propose reassignments across
|
|
16
|
+
// people, but every proposal is confirmed by the owner. So a proposal is a
|
|
17
|
+
// what-if change (bp-q56), never a write: accepting it stages it, the diff
|
|
18
|
+
// shows what moves, and confirm (bp-2fq) is where bd is touched.
|
|
19
|
+
|
|
20
|
+
import type { Bead } from "./store";
|
|
21
|
+
import type { Schedule } from "./view-model";
|
|
22
|
+
import type { TriageResult } from "./triage";
|
|
23
|
+
import type { WhatIfChange } from "./whatif";
|
|
24
|
+
import { resolveAssignee } from "./view-model.ts";
|
|
25
|
+
import { FINISHED_STATUSES, IN_FLIGHT_STATUSES, NOT_WORKABLE_STATUSES, READY_LABEL, hasOpenBlocker } from "./highlights.ts";
|
|
26
|
+
import { affinityMarksFor, beadAffinityMatches } from "../scheduler.mjs";
|
|
27
|
+
import { DEFAULT_CAP } from "./wip.ts";
|
|
28
|
+
|
|
29
|
+
// ── the lane ──────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export interface FrontierMarker {
|
|
32
|
+
id: string;
|
|
33
|
+
title: string;
|
|
34
|
+
/** "starts": the work begins for them; "lands": the bead is projected done. */
|
|
35
|
+
kind: "starts" | "lands";
|
|
36
|
+
/** Days from today, fractional. */
|
|
37
|
+
day: number;
|
|
38
|
+
/** The assignment is the scheduler's, not recorded on the bead. */
|
|
39
|
+
synthetic: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface FrontierLane {
|
|
43
|
+
days: number;
|
|
44
|
+
markers: FrontierMarker[];
|
|
45
|
+
starts: number;
|
|
46
|
+
lands: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const isEpic = (b: Bead) => b.issue_type === "epic";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The next `days` days for one person. A bead already in flight has no
|
|
53
|
+
* "starts" marker (it started); a bead landing past the horizon has no
|
|
54
|
+
* "lands" marker but may still start inside it.
|
|
55
|
+
*/
|
|
56
|
+
export function frontierLane(handle: string, beads: readonly Bead[], schedule: Schedule | null, days: number): FrontierLane {
|
|
57
|
+
const markers: FrontierMarker[] = [];
|
|
58
|
+
if (schedule) {
|
|
59
|
+
for (const b of beads) {
|
|
60
|
+
if (isEpic(b) || FINISHED_STATUSES.includes(b.status)) continue;
|
|
61
|
+
const sched = schedule.get(b.id);
|
|
62
|
+
if (!sched) continue;
|
|
63
|
+
const who = resolveAssignee(b, sched);
|
|
64
|
+
if (who.handle !== handle) continue;
|
|
65
|
+
const start = sched.work_start_day ?? sched.start_day;
|
|
66
|
+
if (!IN_FLIGHT_STATUSES.includes(b.status) && start >= 0 && start <= days) {
|
|
67
|
+
markers.push({ id: b.id, title: b.title, kind: "starts", day: start, synthetic: who.synthetic });
|
|
68
|
+
}
|
|
69
|
+
if (sched.end_day >= 0 && sched.end_day <= days) {
|
|
70
|
+
markers.push({ id: b.id, title: b.title, kind: "lands", day: sched.end_day, synthetic: who.synthetic });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
markers.sort((a, b) => a.day - b.day || a.id.localeCompare(b.id));
|
|
75
|
+
return {
|
|
76
|
+
days,
|
|
77
|
+
markers,
|
|
78
|
+
starts: markers.filter((m) => m.kind === "starts").length,
|
|
79
|
+
lands: markers.filter((m) => m.kind === "lands").length,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** "3 start, 2 land in the next 14 days", or the honest empty sentence. */
|
|
84
|
+
export function describeLane(lane: FrontierLane): string {
|
|
85
|
+
if (!lane.markers.length) return `nothing starts or lands for them in the next ${lane.days} days`;
|
|
86
|
+
return `${lane.starts} start${lane.starts === 1 ? "s" : ""}, ${lane.lands} land${lane.lands === 1 ? "s" : ""} in the next ${lane.days} days`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── proposals ─────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
export interface RosterHumanForProposals {
|
|
92
|
+
handle: string;
|
|
93
|
+
cap?: number;
|
|
94
|
+
labels?: string[];
|
|
95
|
+
areas?: string[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface Proposal {
|
|
99
|
+
bead: Bead;
|
|
100
|
+
score: number;
|
|
101
|
+
/** Stated, in the order they were weighed; shown beside the row. */
|
|
102
|
+
reasons: string[];
|
|
103
|
+
/** The person it would come from, when it is a reassignment. */
|
|
104
|
+
from: string | null;
|
|
105
|
+
/** Accepting = staging this in the what-if. */
|
|
106
|
+
change: WhatIfChange;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface ProposalOptions {
|
|
110
|
+
limit?: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Rank what `handle` could take next. Candidates are workable (not
|
|
115
|
+
* deferred/finished/in flight), unblocked, non-epic beads that are unassigned,
|
|
116
|
+
* or assigned to someone over their cap and not yet started — those are the
|
|
117
|
+
* reassignments the owner decided the planner may propose. spec:ready is a
|
|
118
|
+
* reason, not a gate, as in the scheduler's own pool pick. Weights are
|
|
119
|
+
* stated in code because they are the argument; the reasons repeat them in
|
|
120
|
+
* words.
|
|
121
|
+
*/
|
|
122
|
+
export function proposeNext(
|
|
123
|
+
handle: string,
|
|
124
|
+
beads: readonly Bead[],
|
|
125
|
+
humans: readonly RosterHumanForProposals[],
|
|
126
|
+
schedule: Schedule | null,
|
|
127
|
+
triage: Map<string, TriageResult> | null,
|
|
128
|
+
opts: ProposalOptions = {},
|
|
129
|
+
): Proposal[] {
|
|
130
|
+
const limit = opts.limit ?? 5;
|
|
131
|
+
const byId = new Map(beads.map((b) => [b.id, b] as const));
|
|
132
|
+
const me = humans.find((h) => h.handle === handle);
|
|
133
|
+
const marks = me ? affinityMarksFor(me) : new Set<string>();
|
|
134
|
+
|
|
135
|
+
// In flight and cap per person, for room and for who is over.
|
|
136
|
+
const inFlight = new Map<string, number>();
|
|
137
|
+
for (const b of beads) {
|
|
138
|
+
if (b.assignee && IN_FLIGHT_STATUSES.includes(b.status)) inFlight.set(b.assignee, (inFlight.get(b.assignee) ?? 0) + 1);
|
|
139
|
+
}
|
|
140
|
+
const capOf = (h: string) => humans.find((x) => x.handle === h)?.cap ?? DEFAULT_CAP;
|
|
141
|
+
const overBy = (h: string) => Math.max(0, (inFlight.get(h) ?? 0) - capOf(h));
|
|
142
|
+
const room = capOf(handle) - (inFlight.get(handle) ?? 0);
|
|
143
|
+
|
|
144
|
+
// Unblocking power: open beads that name this one as a blocker.
|
|
145
|
+
const dependents = new Map<string, number>();
|
|
146
|
+
for (const b of beads) {
|
|
147
|
+
if (FINISHED_STATUSES.includes(b.status)) continue;
|
|
148
|
+
for (const d of b.dependencies ?? []) {
|
|
149
|
+
if (d.type === "blocks") dependents.set(d.depends_on_id, (dependents.get(d.depends_on_id) ?? 0) + 1);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const out: Proposal[] = [];
|
|
154
|
+
for (const b of beads) {
|
|
155
|
+
if (isEpic(b) || b.assignee === handle) continue;
|
|
156
|
+
if (FINISHED_STATUSES.includes(b.status) || NOT_WORKABLE_STATUSES.includes(b.status) || IN_FLIGHT_STATUSES.includes(b.status)) continue;
|
|
157
|
+
if (hasOpenBlocker(b, byId)) continue;
|
|
158
|
+
const from = b.assignee ?? null;
|
|
159
|
+
if (from && overBy(from) === 0) continue; // theirs, and they have room: not ours to propose
|
|
160
|
+
|
|
161
|
+
const reasons: string[] = [];
|
|
162
|
+
let score = 0;
|
|
163
|
+
const sched = schedule?.get(b.id);
|
|
164
|
+
if (sched?.synthetic_assignee && sched.assignee === handle) {
|
|
165
|
+
score += 3;
|
|
166
|
+
reasons.push("the scheduler routes it here");
|
|
167
|
+
}
|
|
168
|
+
if ((b.labels ?? []).includes(READY_LABEL)) {
|
|
169
|
+
score += 1;
|
|
170
|
+
reasons.push(READY_LABEL);
|
|
171
|
+
}
|
|
172
|
+
const hits = beadAffinityMatches(b, marks);
|
|
173
|
+
if (hits.length) {
|
|
174
|
+
score += 2;
|
|
175
|
+
reasons.push(`affinity ${hits.join(", ")}`);
|
|
176
|
+
}
|
|
177
|
+
const t = triage?.get(b.id)?.score ?? 0;
|
|
178
|
+
if (t > 0) {
|
|
179
|
+
score += 2 * t;
|
|
180
|
+
reasons.push(`triage ${(t * 100).toFixed(0)}%`);
|
|
181
|
+
}
|
|
182
|
+
const unblocks = dependents.get(b.id) ?? 0;
|
|
183
|
+
if (unblocks > 0) {
|
|
184
|
+
score += Math.min(2, unblocks * 0.5);
|
|
185
|
+
reasons.push(`unblocks ${unblocks}`);
|
|
186
|
+
}
|
|
187
|
+
const p = b.priority ?? 4;
|
|
188
|
+
score += (4 - p) * 0.25;
|
|
189
|
+
if (p <= 1) reasons.push(`P${p}`);
|
|
190
|
+
if (from) {
|
|
191
|
+
score += 0.5;
|
|
192
|
+
reasons.push(`from ${from}, ${overBy(from)} over cap`);
|
|
193
|
+
}
|
|
194
|
+
if (room > 0) reasons.push(`fits: ${room} slot${room === 1 ? "" : "s"} free`);
|
|
195
|
+
else {
|
|
196
|
+
score *= 0.5;
|
|
197
|
+
reasons.push(room === 0 ? "no room today: at cap" : `no room today: ${-room} over cap`);
|
|
198
|
+
}
|
|
199
|
+
out.push({ bead: b, score, reasons, from, change: { kind: "reassign", id: b.id, assignee: handle } });
|
|
200
|
+
}
|
|
201
|
+
out.sort((a, b) => b.score - a.score || (a.bead.priority ?? 4) - (b.bead.priority ?? 4) || a.bead.id.localeCompare(b.bead.id));
|
|
202
|
+
return out.slice(0, limit);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The reasons as one line for a row note. */
|
|
206
|
+
export function describeProposal(p: Proposal): string {
|
|
207
|
+
return p.reasons.join(" · ");
|
|
208
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// gantt-viewport.ts — the Gantt's horizontal viewport (bp-c6q).
|
|
2
|
+
//
|
|
3
|
+
// The scheduling window is [t0, t1]; the viewport is the part of it on
|
|
4
|
+
// screen, in days from t0. Until now the whole window was always on screen,
|
|
5
|
+
// so a wide preset ("all", "history") squeezed a year into the plot column
|
|
6
|
+
// at two or three pixels a day. Now the window is drawn at a readable
|
|
7
|
+
// density and the rest scrolls:
|
|
8
|
+
//
|
|
9
|
+
// FIT when the window fits at MIN_PX_PER_DAY or better, the viewport is
|
|
10
|
+
// the window and nothing scrolls — the Gantt looks exactly as before.
|
|
11
|
+
// DENSE otherwise the viewport spans plotWidth / MIN_PX_PER_DAY days,
|
|
12
|
+
// centred on today when today is inside the window and on the
|
|
13
|
+
// window's end otherwise (the present is the interesting end), and
|
|
14
|
+
// the plot gains a scrollbar, drag-to-pan on the axis and wheel zoom.
|
|
15
|
+
//
|
|
16
|
+
// Why not DDV's usePannedAndZoomedDomain: it exists (rc.4 exports it, rc.5
|
|
17
|
+
// fixes bugs in it), but its wheel handler zooms and calls preventDefault on
|
|
18
|
+
// every wheel event over its target — attached to the plot it would stop the
|
|
19
|
+
// page scrolling over a tall chart — its pan is unbounded, it mutates the
|
|
20
|
+
// source domain object, and its adjusted domain is read-only, so a
|
|
21
|
+
// scrollbar or a jump-to-today could not drive it. The scaler and the domain
|
|
22
|
+
// helpers are DDV's; this file is only the bounded arithmetic, kept pure so
|
|
23
|
+
// the rules are testable.
|
|
24
|
+
|
|
25
|
+
export const MIN_PX_PER_DAY = 6; // a week is 42px: labels stay readable
|
|
26
|
+
export const MAX_PX_PER_DAY = 240; // the zoom-in limit: an hour is 10px
|
|
27
|
+
|
|
28
|
+
export interface Viewport {
|
|
29
|
+
/** Days from t0 at the left edge of the plot. */
|
|
30
|
+
startDay: number;
|
|
31
|
+
/** Days visible across the plot. */
|
|
32
|
+
spanDays: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function fitsWithoutScroll(totalDays: number, plotWidth: number): boolean {
|
|
36
|
+
return plotWidth / Math.max(1, totalDays) >= MIN_PX_PER_DAY;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Keep the span within the zoom limits and the start within the window. */
|
|
40
|
+
export function clampViewport(v: Viewport, totalDays: number, plotWidth: number): Viewport {
|
|
41
|
+
const whole = Math.max(1, totalDays);
|
|
42
|
+
const minSpan = plotWidth > 0 ? Math.min(plotWidth / MAX_PX_PER_DAY, whole) : 1;
|
|
43
|
+
const spanDays = Math.min(Math.max(v.spanDays, minSpan), whole);
|
|
44
|
+
const startDay = Math.min(Math.max(v.startDay, 0), Math.max(0, whole - spanDays));
|
|
45
|
+
return { startDay, spanDays };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What a fresh window shows: see the header. */
|
|
49
|
+
export function initialViewport(totalDays: number, plotWidth: number, todayDay: number): Viewport {
|
|
50
|
+
const whole = Math.max(1, totalDays);
|
|
51
|
+
if (plotWidth <= 0 || fitsWithoutScroll(whole, plotWidth)) return { startDay: 0, spanDays: whole };
|
|
52
|
+
const spanDays = plotWidth / MIN_PX_PER_DAY;
|
|
53
|
+
const anchor = todayDay >= 0 && todayDay <= whole ? todayDay : whole;
|
|
54
|
+
return clampViewport({ startDay: anchor - spanDays / 2, spanDays }, whole, plotWidth);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function pxPerDay(v: Viewport, plotWidth: number): number {
|
|
58
|
+
return plotWidth / Math.max(1e-9, v.spanDays);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Move the viewport by a pixel distance: positive reveals later days. */
|
|
62
|
+
export function panViewportPx(v: Viewport, dxPx: number, totalDays: number, plotWidth: number): Viewport {
|
|
63
|
+
return clampViewport({ startDay: v.startDay + dxPx / pxPerDay(v, plotWidth), spanDays: v.spanDays }, totalDays, plotWidth);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Scale the span by `factor` (> 1 zooms out) keeping the day under
|
|
68
|
+
* `anchorFrac` of the plot width where it is, so the point under the pointer
|
|
69
|
+
* stays under the pointer.
|
|
70
|
+
*/
|
|
71
|
+
export function zoomViewport(v: Viewport, factor: number, anchorFrac: number, totalDays: number, plotWidth: number): Viewport {
|
|
72
|
+
const f = Math.min(1, Math.max(0, anchorFrac));
|
|
73
|
+
const anchorDay = v.startDay + f * v.spanDays;
|
|
74
|
+
const spanDays = v.spanDays * factor;
|
|
75
|
+
return clampViewport({ startDay: anchorDay - f * spanDays, spanDays }, totalDays, plotWidth);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function centreViewport(v: Viewport, day: number, totalDays: number, plotWidth: number): Viewport {
|
|
79
|
+
return clampViewport({ startDay: day - v.spanDays / 2, spanDays: v.spanDays }, totalDays, plotWidth);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The native scrollbar's numbers for this viewport. */
|
|
83
|
+
export function scrollMetrics(v: Viewport, totalDays: number, plotWidth: number) {
|
|
84
|
+
const p = pxPerDay(v, plotWidth);
|
|
85
|
+
return {
|
|
86
|
+
contentWidth: Math.max(1, totalDays) * p,
|
|
87
|
+
scrollLeft: v.startDay * p,
|
|
88
|
+
scrollable: v.spanDays < Math.max(1, totalDays) - 1e-6,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function viewportFromScrollLeft(v: Viewport, scrollLeft: number, totalDays: number, plotWidth: number): Viewport {
|
|
93
|
+
return clampViewport({ startDay: scrollLeft / pxPerDay(v, plotWidth), spanDays: v.spanDays }, totalDays, plotWidth);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Wheel down (positive deltaY) zooms out; ~500px of wheel doubles the span. */
|
|
97
|
+
export function wheelZoomFactor(deltaY: number): number {
|
|
98
|
+
return Math.exp(Math.max(-300, Math.min(300, deltaY)) * (Math.LN2 / 500));
|
|
99
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// highlights.ts — pure helpers for the highlight overlays.
|
|
2
|
+
//
|
|
3
|
+
// Three overlays — blocked (has an open blocker), ready-now (spec:ready,
|
|
4
|
+
// unblocked, unassigned or unscheduled), over-cap (humans exceeding their
|
|
5
|
+
// WIP cap).
|
|
6
|
+
//
|
|
7
|
+
// All pure, no I/O, no reactivity — consumers wrap them in Pinia getters
|
|
8
|
+
// and reactively resolve dependencies through the store's state.
|
|
9
|
+
|
|
10
|
+
import type { Bead, Roster } from "./store";
|
|
11
|
+
|
|
12
|
+
// ── readiness core (shared) ───────────────────────────────────────────────
|
|
13
|
+
//
|
|
14
|
+
// One definition of "workable", read by the ready-now highlight and by the
|
|
15
|
+
// MINE shortcuts (shortcuts.ts). Assignment is deliberately not part of it:
|
|
16
|
+
// each caller adds its own clause, and the rule itself is written once.
|
|
17
|
+
|
|
18
|
+
/** Finished or parked: no longer live work. */
|
|
19
|
+
export const FINISHED_STATUSES: readonly string[] = [
|
|
20
|
+
"closed",
|
|
21
|
+
"deferred",
|
|
22
|
+
"tombstone",
|
|
23
|
+
"superseded",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/** Already moving: what counts as work in flight for WIP against a cap. */
|
|
27
|
+
export const IN_FLIGHT_STATUSES: readonly string[] = ["in_progress", "in_review"];
|
|
28
|
+
|
|
29
|
+
/** Finished, parked, or already moving: not workable right now. */
|
|
30
|
+
export const NOT_WORKABLE_STATUSES: readonly string[] = [
|
|
31
|
+
...FINISHED_STATUSES,
|
|
32
|
+
...IN_FLIGHT_STATUSES,
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/** The label that means the work has been approved for implementation. */
|
|
36
|
+
export const READY_LABEL = "spec:ready";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Does at least one `blocks` dependency point at a bead that is not closed?
|
|
40
|
+
* Closed deps do not count; deferred ones do (they still gate work). A dep
|
|
41
|
+
* whose bead is not in the map is ignored, as the blocked overlay always has.
|
|
42
|
+
*/
|
|
43
|
+
export function hasOpenBlocker(bead: Bead, byId: Map<string, Bead>): boolean {
|
|
44
|
+
for (const d of bead.dependencies ?? []) {
|
|
45
|
+
if (d.type !== "blocks") continue;
|
|
46
|
+
const dep = byId.get(d.depends_on_id);
|
|
47
|
+
if (dep && dep.status !== "closed") return true;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Approved, in a workable status, and unblocked. Nothing about who holds it.
|
|
54
|
+
*/
|
|
55
|
+
export function isReadyCore(bead: Bead, byId: Map<string, Bead>): boolean {
|
|
56
|
+
if (NOT_WORKABLE_STATUSES.includes(bead.status)) return false;
|
|
57
|
+
if (!(bead.labels ?? []).includes(READY_LABEL)) return false;
|
|
58
|
+
return !hasOpenBlocker(bead, byId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── blocked overlay ───────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A bead is "blocked" when at least one of its `blocks` dependencies points
|
|
65
|
+
* at another bead that isn't closed. Closed deps don't count. Deferred deps
|
|
66
|
+
* do (they still gate work).
|
|
67
|
+
*/
|
|
68
|
+
export function computeBlockedIds(beads: Bead[]): Set<string> {
|
|
69
|
+
const byId = new Map(beads.map((b) => [b.id, b] as const));
|
|
70
|
+
const out = new Set<string>();
|
|
71
|
+
for (const b of beads) if (hasOpenBlocker(b, byId)) out.add(b.id);
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── ready-now overlay ─────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A bead is "ready now" when:
|
|
79
|
+
* - it isn't closed / deferred / superseded
|
|
80
|
+
* - it carries the `spec:ready` label
|
|
81
|
+
* - every `blocks` dep resolves to a closed bead
|
|
82
|
+
* - it isn't currently in-flight (in_progress / in_review)
|
|
83
|
+
*
|
|
84
|
+
* That's the queue of things that could dispatch _right now_ but haven't.
|
|
85
|
+
*/
|
|
86
|
+
export function computeReadyNowIds(beads: Bead[]): Set<string> {
|
|
87
|
+
const byId = new Map(beads.map((b) => [b.id, b] as const));
|
|
88
|
+
const out = new Set<string>();
|
|
89
|
+
for (const b of beads) if (isReadyCore(b, byId)) out.add(b.id);
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── over-cap overlay ──────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
const DEFAULT_WIP_CAP = 5;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Assignees currently over their WIP cap. In-flight = in_progress + in_review.
|
|
99
|
+
* Cap comes from the roster; missing entries fall through to the roster-wide
|
|
100
|
+
* default (or DEFAULT_WIP_CAP when the roster doesn't set one either).
|
|
101
|
+
*/
|
|
102
|
+
export function computeOverCapAssignees(
|
|
103
|
+
beads: Bead[],
|
|
104
|
+
roster: Roster,
|
|
105
|
+
): Set<string> {
|
|
106
|
+
const defaultCap = roster.cap ?? DEFAULT_WIP_CAP;
|
|
107
|
+
const capByHandle = new Map<string, number>();
|
|
108
|
+
for (const h of roster.humans ?? []) {
|
|
109
|
+
capByHandle.set(h.handle, h.cap ?? defaultCap);
|
|
110
|
+
}
|
|
111
|
+
const inFlight = new Map<string, number>();
|
|
112
|
+
for (const b of beads) {
|
|
113
|
+
if (!IN_FLIGHT_STATUSES.includes(b.status)) continue;
|
|
114
|
+
if (!b.assignee) continue;
|
|
115
|
+
inFlight.set(b.assignee, (inFlight.get(b.assignee) ?? 0) + 1);
|
|
116
|
+
}
|
|
117
|
+
const out = new Set<string>();
|
|
118
|
+
for (const [handle, count] of inFlight) {
|
|
119
|
+
const cap = capByHandle.get(handle) ?? defaultCap;
|
|
120
|
+
if (count > cap) out.add(handle);
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>beadcyte</title>
|
|
7
|
+
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
|
|
8
|
+
<!--
|
|
9
|
+
Applies the stored theme and style before the browser paints, so an
|
|
10
|
+
explicit light/dark choice doesn't flash the OS's preference first.
|
|
11
|
+
Plain (non-module) script: runs synchronously during parsing, whereas
|
|
12
|
+
main.ts is deferred by type="module". Mirrors useTheme.ts's keys and
|
|
13
|
+
valid values — keep both in sync. useTheme().init() still runs after
|
|
14
|
+
mount to sync Vue's reactive state; this only covers the pre-JS paint.
|
|
15
|
+
-->
|
|
16
|
+
<script>
|
|
17
|
+
(function () {
|
|
18
|
+
try {
|
|
19
|
+
var q = new URLSearchParams(location.search);
|
|
20
|
+
var t = q.get("theme") || localStorage.getItem("beadcyte:theme");
|
|
21
|
+
if (t === "light" || t === "dark")
|
|
22
|
+
document.documentElement.setAttribute("data-theme", t);
|
|
23
|
+
var fx = q.get("fx");
|
|
24
|
+
var s = fx || localStorage.getItem("beadcyte:style");
|
|
25
|
+
// Every named style except default. theme.test.mjs checks this list
|
|
26
|
+
// against STYLE_NAMES, so a style added there but not here fails a
|
|
27
|
+
// test instead of flashing the default palette before hydration.
|
|
28
|
+
var STYLES = ["high-contrast", "catppuccin", "dracula", "github", "gruvbox", "nord", "one", "solarized", "synthwave", "terminal", "tokyonight"];
|
|
29
|
+
if (STYLES.indexOf(s) >= 0)
|
|
30
|
+
document.documentElement.setAttribute("data-style", s);
|
|
31
|
+
} catch (e) {
|
|
32
|
+
/* storage blocked — falls back to prefers-color-scheme */
|
|
33
|
+
}
|
|
34
|
+
})();
|
|
35
|
+
</script>
|
|
36
|
+
<!-- Stylesheets are imported from main.ts, not linked here. A plain
|
|
37
|
+
<link> sits outside Vite's module graph, so editing tokens.css or
|
|
38
|
+
styles-alt.css never triggered HMR: component changes hot-reloaded
|
|
39
|
+
while the colour layer silently stayed at whatever first loaded,
|
|
40
|
+
which looks exactly like a change not taking effect. -->
|
|
41
|
+
</head>
|
|
42
|
+
<body>
|
|
43
|
+
<div id="app"></div>
|
|
44
|
+
<script type="module" src="./main.ts"></script>
|
|
45
|
+
</body>
|
|
46
|
+
</html>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// insights.ts — pure derivations for the project-wide summary charts.
|
|
2
|
+
//
|
|
3
|
+
// Small on purpose. The two charts in app-main (bp-67g.30) read data the
|
|
4
|
+
// pipeline already produced: the weekly ship series comes straight off
|
|
5
|
+
// computeVelocity, and group progress is a reshape of the view model's own
|
|
6
|
+
// groups. Neither walks the bead list again.
|
|
7
|
+
//
|
|
8
|
+
// No DOM, no reactivity, no I/O.
|
|
9
|
+
|
|
10
|
+
import type { Group } from "./view-model";
|
|
11
|
+
|
|
12
|
+
/** What groupProgress reads: the view model's groups, or projectGroups(). */
|
|
13
|
+
export type ProgressSource = Pick<Group, "id" | "label" | "progress">;
|
|
14
|
+
|
|
15
|
+
export interface GroupProgressRow {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
closed: number;
|
|
19
|
+
total: number;
|
|
20
|
+
/** 0..1. Zero-population groups read as 0 rather than NaN. */
|
|
21
|
+
fraction: number;
|
|
22
|
+
/**
|
|
23
|
+
* True for the view model's placeholder buckets (`__no_epic`,
|
|
24
|
+
* `__unassigned`, `__all`).
|
|
25
|
+
*
|
|
26
|
+
* These are the REMAINDER, not a group whose size means anything beside a
|
|
27
|
+
* real one, and they are usually the largest thing in the list — 305 of
|
|
28
|
+
* 556 beads on the repo this was built against. Leaving one in the bars
|
|
29
|
+
* put every real group on 13% of the width, so callers are expected to
|
|
30
|
+
* split on this and report the remainder separately.
|
|
31
|
+
*/
|
|
32
|
+
synthetic: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Groups by population, largest first.
|
|
37
|
+
*
|
|
38
|
+
* Encodes `progress.total`, never `rowCount`. rowCount is what the Gantt
|
|
39
|
+
* DRAWS — clamped at maxPerGroup — so on a repo with several large epics it
|
|
40
|
+
* reports the same number for all of them and a chart of it is flat-topped
|
|
41
|
+
* by construction. `progress` is counted over the whole group before any
|
|
42
|
+
* filter or window clipping, which is also why closed-vs-open comes along
|
|
43
|
+
* for free and stays honest when the user hides closed beads.
|
|
44
|
+
*
|
|
45
|
+
* Fed by projectGroups() since bp-67g.36, so the SET of groups is project-wide
|
|
46
|
+
* as well as the counts: a window that clips every bead of a group no longer
|
|
47
|
+
* drops the group from the chart.
|
|
48
|
+
*/
|
|
49
|
+
export function groupProgress(groups: readonly ProgressSource[]): GroupProgressRow[] {
|
|
50
|
+
return groups
|
|
51
|
+
.map((g) => ({
|
|
52
|
+
id: g.id,
|
|
53
|
+
label: g.label,
|
|
54
|
+
closed: g.progress.closed,
|
|
55
|
+
total: g.progress.total,
|
|
56
|
+
fraction: g.progress.total > 0 ? g.progress.closed / g.progress.total : 0,
|
|
57
|
+
synthetic: g.id.startsWith("__"),
|
|
58
|
+
}))
|
|
59
|
+
.sort((a, b) => b.total - a.total || a.id.localeCompare(b.id));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ShipWeekPoint {
|
|
63
|
+
weekStart: Date;
|
|
64
|
+
count: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ShipTrend {
|
|
68
|
+
weeks: ShipWeekPoint[];
|
|
69
|
+
total: number;
|
|
70
|
+
/** Mean ships per week over the series — what the calibration band shows. */
|
|
71
|
+
perWeek: number;
|
|
72
|
+
/** The most recent complete week. */
|
|
73
|
+
last: number;
|
|
74
|
+
/**
|
|
75
|
+
* Change from the mean, as a signed fraction. Compared against the mean
|
|
76
|
+
* rather than the previous week, because week-to-week on counts this small
|
|
77
|
+
* is mostly noise and the question is "are we above or below our own
|
|
78
|
+
* normal". Null when there is no history to be normal.
|
|
79
|
+
*
|
|
80
|
+
* "No history" means fewer than two weeks that shipped anything, not
|
|
81
|
+
* merely a zero total. With a single non-zero week the mean IS that week
|
|
82
|
+
* divided by the series length, so the figure is decided by the window
|
|
83
|
+
* size rather than by the data: 15 ships in one week of an 8-week window
|
|
84
|
+
* reports +700%, and the identical beads under a 4-week window report
|
|
85
|
+
* +300%. That is an artefact with a percent sign on it.
|
|
86
|
+
*/
|
|
87
|
+
vsMean: number | null;
|
|
88
|
+
/** Weeks that shipped anything. Below 2, `vsMean` is null. */
|
|
89
|
+
activeWeeks: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Reshape computeVelocity's weekly buckets for the trend chart. */
|
|
93
|
+
export function shipTrend(weekly: readonly ShipWeekPoint[]): ShipTrend {
|
|
94
|
+
const weeks = weekly.map((w) => ({ ...w }));
|
|
95
|
+
const total = weeks.reduce((a, w) => a + w.count, 0);
|
|
96
|
+
const perWeek = weeks.length ? total / weeks.length : 0;
|
|
97
|
+
const last = weeks.length ? weeks[weeks.length - 1].count : 0;
|
|
98
|
+
const activeWeeks = weeks.filter((w) => w.count > 0).length;
|
|
99
|
+
return {
|
|
100
|
+
weeks,
|
|
101
|
+
total,
|
|
102
|
+
perWeek,
|
|
103
|
+
last,
|
|
104
|
+
activeWeeks,
|
|
105
|
+
vsMean: activeWeeks >= 2 && perWeek > 0 ? (last - perWeek) / perWeek : null,
|
|
106
|
+
};
|
|
107
|
+
}
|