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/web/store.ts
ADDED
|
@@ -0,0 +1,1378 @@
|
|
|
1
|
+
// The one Pinia store. Holds the raw API payload plus the reactive controls
|
|
2
|
+
// state (filters, window, group mode) that the view-model derives against.
|
|
3
|
+
// Later beads mount their own controls-panel state (bp-fdl), URL round-trip
|
|
4
|
+
// (bp-cel), and live refresh polling (bp-1kh) into the same fields here.
|
|
5
|
+
|
|
6
|
+
import { defineStore } from "pinia";
|
|
7
|
+
import {
|
|
8
|
+
assembleViewModel,
|
|
9
|
+
type Filters,
|
|
10
|
+
type GroupBy,
|
|
11
|
+
type Schedule,
|
|
12
|
+
type ScheduleEntry,
|
|
13
|
+
type ViewModel,
|
|
14
|
+
type WindowSpec,
|
|
15
|
+
projectGroups,
|
|
16
|
+
} from "./view-model";
|
|
17
|
+
import type { ViewMode } from "./composables/url-codec";
|
|
18
|
+
import {
|
|
19
|
+
ACTIVE_PROJECT_STORAGE_KEY,
|
|
20
|
+
PROJECTS_STORAGE_KEY,
|
|
21
|
+
addProject,
|
|
22
|
+
moveProject,
|
|
23
|
+
parseProjects,
|
|
24
|
+
removeProject,
|
|
25
|
+
renameProject,
|
|
26
|
+
resolveActive,
|
|
27
|
+
serializeProjects,
|
|
28
|
+
unadmittedPaths,
|
|
29
|
+
type Project,
|
|
30
|
+
} from "./projects";
|
|
31
|
+
import {
|
|
32
|
+
DEFAULT_REFRESH_MS,
|
|
33
|
+
REFRESH_STORAGE_KEY,
|
|
34
|
+
parseRefreshMs,
|
|
35
|
+
} from "./refresh";
|
|
36
|
+
import {
|
|
37
|
+
WATCHLIST_STORAGE_KEY,
|
|
38
|
+
parseWatched,
|
|
39
|
+
serializeWatched,
|
|
40
|
+
toggleWatched,
|
|
41
|
+
} from "./watchlist";
|
|
42
|
+
import {
|
|
43
|
+
buildBeadDetail,
|
|
44
|
+
type BeadDetail,
|
|
45
|
+
type EstimateResult,
|
|
46
|
+
} from "./bead-detail";
|
|
47
|
+
import {
|
|
48
|
+
computeBlockedIds,
|
|
49
|
+
computeReadyNowIds,
|
|
50
|
+
computeOverCapAssignees,
|
|
51
|
+
} from "./highlights";
|
|
52
|
+
import {
|
|
53
|
+
CURRENT_USER_STORAGE_KEY,
|
|
54
|
+
SHORTCUT_IDS,
|
|
55
|
+
isShortcutActive,
|
|
56
|
+
shortcutFilters,
|
|
57
|
+
type Shortcut,
|
|
58
|
+
} from "./shortcuts";
|
|
59
|
+
import {
|
|
60
|
+
computeTriage,
|
|
61
|
+
scorePopulation,
|
|
62
|
+
signalPopulation,
|
|
63
|
+
type ScorePopulation,
|
|
64
|
+
type SignalPopulation,
|
|
65
|
+
type TriageResult,
|
|
66
|
+
} from "./triage";
|
|
67
|
+
import {
|
|
68
|
+
groupProgress,
|
|
69
|
+
shipTrend,
|
|
70
|
+
type GroupProgressRow,
|
|
71
|
+
type ShipTrend,
|
|
72
|
+
} from "./insights";
|
|
73
|
+
// The estimator + scheduler are shared with the CLI SVG mode — pure ES
|
|
74
|
+
// modules, no `bd` shell-out, safe to import into the browser bundle.
|
|
75
|
+
import { buildEstimator } from "../estimator.mjs";
|
|
76
|
+
import { scheduleGreedy } from "../scheduler.mjs";
|
|
77
|
+
import { stageWaits } from "../stage-waits.mjs";
|
|
78
|
+
import { computeVelocity, calibrateRoster } from "../velocity.mjs";
|
|
79
|
+
|
|
80
|
+
export interface Bead {
|
|
81
|
+
id: string;
|
|
82
|
+
title: string;
|
|
83
|
+
description?: string | null;
|
|
84
|
+
status: string;
|
|
85
|
+
priority: number | null;
|
|
86
|
+
issue_type: string;
|
|
87
|
+
assignee: string | null;
|
|
88
|
+
parent: string | null;
|
|
89
|
+
labels: string[] | null;
|
|
90
|
+
started_at: string | null;
|
|
91
|
+
closed_at?: string | null;
|
|
92
|
+
created_at: string;
|
|
93
|
+
updated_at: string;
|
|
94
|
+
dependencies: Array<{
|
|
95
|
+
type: string;
|
|
96
|
+
depends_on_id: string;
|
|
97
|
+
}> | null;
|
|
98
|
+
/** Present in `bd list --json`; feeds triage's activity-churn signal. */
|
|
99
|
+
comment_count?: number | null;
|
|
100
|
+
metadata?: Record<string, unknown> | null;
|
|
101
|
+
/**
|
|
102
|
+
* Added by the server from its background walk of `bd history`
|
|
103
|
+
* (bp-67g.57): when the bead first entered in_review, or null when it
|
|
104
|
+
* never did. Absent until the walk has reached the bead.
|
|
105
|
+
*/
|
|
106
|
+
history?: { in_review_at: string | null; assigned_at?: string | null; walked_at?: string | null } | null;
|
|
107
|
+
/** A what-if idea (bp-q56): exists only in the what-if copy of the beads. */
|
|
108
|
+
hypothetical?: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface Roster {
|
|
112
|
+
cap?: number;
|
|
113
|
+
humans: Array<{
|
|
114
|
+
handle: string;
|
|
115
|
+
cap?: number;
|
|
116
|
+
/** Do not calibrate this cap (roster.json, or a what-if). */
|
|
117
|
+
cap_fixed?: boolean;
|
|
118
|
+
labels?: string[];
|
|
119
|
+
areas?: string[];
|
|
120
|
+
}>;
|
|
121
|
+
_derived?: boolean;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface Payload {
|
|
125
|
+
generated_at: string;
|
|
126
|
+
beads: Bead[];
|
|
127
|
+
roster: Roster;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface ApiResponse {
|
|
131
|
+
stale: boolean;
|
|
132
|
+
generated_at?: string;
|
|
133
|
+
beads?: Bead[];
|
|
134
|
+
roster?: Roster;
|
|
135
|
+
/** Which project the server answered for. */
|
|
136
|
+
project?: string;
|
|
137
|
+
/** How long the server's `bd` call took, in ms. */
|
|
138
|
+
took_ms?: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The rule lives in status-filter.ts so it is reachable from a test without
|
|
142
|
+
// dragging Pinia and a fetch in — the same reason window.ts exists.
|
|
143
|
+
// Re-exported because store.ts is where consumers already import from.
|
|
144
|
+
export { STATUSES_HIDDEN_BY_DEFAULT, defaultStatusSelection } from "./status-filter";
|
|
145
|
+
import { defaultStatusSelection } from "./status-filter";
|
|
146
|
+
import { nextCursor } from "./keybindings";
|
|
147
|
+
import { applyOptimistic, describeOutcome, type Mutation } from "./mutations";
|
|
148
|
+
import { applyChanges, diffPlans, upsertChange, ideaEstimateDays, mutationsFor, describeChange, type WhatIfChange, type PlanDiff } from "./whatif";
|
|
149
|
+
|
|
150
|
+
const DEFAULT_FILTERS: Filters = {
|
|
151
|
+
types: null,
|
|
152
|
+
priorities: null,
|
|
153
|
+
// Null until the first payload arrives, because the default is "every
|
|
154
|
+
// status this repo actually has, minus the ones above" and that cannot be
|
|
155
|
+
// known before the data. `initStatusSelection` fills it in.
|
|
156
|
+
statuses: null,
|
|
157
|
+
labels: null,
|
|
158
|
+
assignees: null,
|
|
159
|
+
unblocked: null,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// The window presets live in window.ts so they are reachable from a test —
|
|
163
|
+
// see the note there about the duplicated copy that drifted. Re-exported
|
|
164
|
+
// because store.ts is where every consumer already imports them from.
|
|
165
|
+
export {
|
|
166
|
+
WINDOW_PRESETS,
|
|
167
|
+
windowFromPreset,
|
|
168
|
+
type WindowPreset,
|
|
169
|
+
} from "./window";
|
|
170
|
+
import { windowFromPreset, WINDOW_PRESETS, type WindowPreset } from "./window";
|
|
171
|
+
|
|
172
|
+
function defaultWindow(): WindowSpec {
|
|
173
|
+
return windowFromPreset("90d");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface VelocityEntry {
|
|
177
|
+
handle: string;
|
|
178
|
+
closedInWindow: number;
|
|
179
|
+
closesPerWeek: number;
|
|
180
|
+
lastClosedAt: string | null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface VelocitySummary {
|
|
184
|
+
asOf: Date;
|
|
185
|
+
lookbackDays: number;
|
|
186
|
+
perHuman: Map<string, VelocityEntry>;
|
|
187
|
+
/** Whole-week ship buckets ending at asOf. See velocity.mjs for why the
|
|
188
|
+
* trend's window is weeklyWeeks * 7 days rather than lookbackDays. */
|
|
189
|
+
weekly: Array<{ weekStart: Date; count: number }>;
|
|
190
|
+
weeklyTotal: number;
|
|
191
|
+
weeklyWeeks: number;
|
|
192
|
+
totalClosedInWindow: number;
|
|
193
|
+
activeCount: number;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
interface CachedSchedule {
|
|
197
|
+
key: string;
|
|
198
|
+
schedule: Schedule;
|
|
199
|
+
estimates: Map<string, EstimateResult>;
|
|
200
|
+
velocity: VelocitySummary;
|
|
201
|
+
calibratedRoster: Roster;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
let scheduleCache: CachedSchedule | null = null;
|
|
205
|
+
|
|
206
|
+
// Recompute the velocity + estimator + scheduler only when the underlying
|
|
207
|
+
// beads change. Filter/group/window changes reuse the cached schedule + per-
|
|
208
|
+
// bead estimates — the point of the view-model split is that reactivity is
|
|
209
|
+
// cheap.
|
|
210
|
+
/** The plan for these beads and this roster: velocity, estimator, calibration, waits, schedule. Uncached. */
|
|
211
|
+
function computePlan(
|
|
212
|
+
beads: Bead[],
|
|
213
|
+
rawRoster: Roster,
|
|
214
|
+
horizon: Date,
|
|
215
|
+
): {
|
|
216
|
+
schedule: Schedule;
|
|
217
|
+
estimates: Map<string, EstimateResult>;
|
|
218
|
+
velocity: VelocitySummary;
|
|
219
|
+
calibratedRoster: Roster;
|
|
220
|
+
} {
|
|
221
|
+
|
|
222
|
+
// Reality calibration: measure per-human throughput over the last 60
|
|
223
|
+
// days and clamp each human's WIP cap via Little's Law using the
|
|
224
|
+
// estimator's median cycle time. Humans with no shipped history keep
|
|
225
|
+
// their raw cap so brand-new teams still get a projection.
|
|
226
|
+
const velocity = computeVelocity(beads) as VelocitySummary;
|
|
227
|
+
const { estimate, stats } = buildEstimator(beads) as unknown as {
|
|
228
|
+
estimate: (bead: Bead) => EstimateResult;
|
|
229
|
+
stats: {
|
|
230
|
+
global_median_days: number;
|
|
231
|
+
median_lead_days: number;
|
|
232
|
+
};
|
|
233
|
+
};
|
|
234
|
+
const calibratedRoster = calibrateRoster(rawRoster, velocity, {
|
|
235
|
+
medianCycleDays: stats.median_lead_days || stats.global_median_days,
|
|
236
|
+
}) as Roster;
|
|
237
|
+
const estimates = new Map<string, EstimateResult>();
|
|
238
|
+
const capturingEstimate = (bead: Bead): EstimateResult => {
|
|
239
|
+
// A what-if idea carries its own size (bp-q56); everything else is the estimator's.
|
|
240
|
+
const own = ideaEstimateDays(bead);
|
|
241
|
+
const r = own !== null ? { days: own, p90: null, source: `what-if, ${(own * 24).toFixed(1)}h` } : (estimate(bead) as EstimateResult);
|
|
242
|
+
estimates.set(bead.id, r);
|
|
243
|
+
return r;
|
|
244
|
+
};
|
|
245
|
+
// The wait clock (bp-m0g): this project's own stage medians, and the
|
|
246
|
+
// estimator's lead-time median as the floor where review data is missing.
|
|
247
|
+
const waits = stageWaits(beads);
|
|
248
|
+
const result = scheduleGreedy(beads, calibratedRoster, capturingEstimate, horizon, {
|
|
249
|
+
waits,
|
|
250
|
+
minLeadDays: stats.median_lead_days || 0,
|
|
251
|
+
}) as unknown as {
|
|
252
|
+
schedule: Schedule;
|
|
253
|
+
unassignedProjection: Schedule;
|
|
254
|
+
};
|
|
255
|
+
const schedule = result.schedule;
|
|
256
|
+
for (const [id, s] of result.unassignedProjection) {
|
|
257
|
+
if (!schedule.has(id)) schedule.set(id, s);
|
|
258
|
+
}
|
|
259
|
+
// Fill in estimates for closed/deferred beads — the scheduler doesn't
|
|
260
|
+
// call the estimator on those, but the tooltip should still show the
|
|
261
|
+
// historical model's opinion.
|
|
262
|
+
for (const b of beads) {
|
|
263
|
+
if (!estimates.has(b.id)) estimates.set(b.id, estimate(b) as EstimateResult);
|
|
264
|
+
}
|
|
265
|
+
return { schedule, estimates, velocity, calibratedRoster };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function scheduleFor(
|
|
269
|
+
beads: Bead[],
|
|
270
|
+
rawRoster: Roster,
|
|
271
|
+
horizon: Date,
|
|
272
|
+
): {
|
|
273
|
+
schedule: Schedule;
|
|
274
|
+
estimates: Map<string, EstimateResult>;
|
|
275
|
+
velocity: VelocitySummary;
|
|
276
|
+
calibratedRoster: Roster;
|
|
277
|
+
} {
|
|
278
|
+
const key =
|
|
279
|
+
beads.length + ":" + beads.map((b) => b.id + "@" + b.status).join(",") +
|
|
280
|
+
"|" + JSON.stringify(rawRoster);
|
|
281
|
+
if (scheduleCache && scheduleCache.key === key) return scheduleCache;
|
|
282
|
+
scheduleCache = { key, ...computePlan(beads, rawRoster, horizon) };
|
|
283
|
+
return scheduleCache;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
export type HighlightKind = "blocked" | "over-cap" | "ready-now";
|
|
288
|
+
|
|
289
|
+
export const HIGHLIGHT_KINDS: HighlightKind[] = [
|
|
290
|
+
"blocked",
|
|
291
|
+
"over-cap",
|
|
292
|
+
"ready-now",
|
|
293
|
+
];
|
|
294
|
+
|
|
295
|
+
// Shared frozen empty set for highlight getters — lets components use
|
|
296
|
+
// `.has()` without a null-check while still hitting Vue's identity-based
|
|
297
|
+
// change detection (returning the same instance every time avoids spurious
|
|
298
|
+
// re-renders).
|
|
299
|
+
const EMPTY_SET: ReadonlySet<string> = new Set();
|
|
300
|
+
|
|
301
|
+
export const useBeadsStore = defineStore("beads", {
|
|
302
|
+
state: () => ({
|
|
303
|
+
payload: null as Payload | null,
|
|
304
|
+
loading: false,
|
|
305
|
+
error: null as string | null,
|
|
306
|
+
lastFetched: null as Date | null,
|
|
307
|
+
filters: { ...DEFAULT_FILTERS } as Filters,
|
|
308
|
+
/**
|
|
309
|
+
* Normally-hidden statuses a legacy `?showClosed=`/`?includeDeferred=`
|
|
310
|
+
* link asked for, awaiting `initStatusSelection`. Not persisted to the
|
|
311
|
+
* URL — it is a one-shot translation of an old link, not state.
|
|
312
|
+
*/
|
|
313
|
+
pendingLegacyStatuses: null as string[] | null,
|
|
314
|
+
window: defaultWindow(),
|
|
315
|
+
groupBy: "epic" as GroupBy,
|
|
316
|
+
/** Gantt and grid are alternative views of the same data, not
|
|
317
|
+
* companions — only the selected one is mounted. */
|
|
318
|
+
view: "mine" as ViewMode,
|
|
319
|
+
// ── keyboard (bp-67g.45) ─────────────────────────────────────────────
|
|
320
|
+
/** The ? overlay. */
|
|
321
|
+
helpOpen: false,
|
|
322
|
+
/** The in-app changelog (bp-67g.46). */
|
|
323
|
+
changelogOpen: false,
|
|
324
|
+
/** The first-run walkthrough (bp-67g.48); App opens it once, options re-opens it. */
|
|
325
|
+
walkthroughOpen: false,
|
|
326
|
+
// ── writes (bp-ocs) ─────────────────────────────────────────────────
|
|
327
|
+
/** The quick-actions menu: which bead, and where to draw it. */
|
|
328
|
+
contextMenu: null as { id: string; x: number; y: number } | null,
|
|
329
|
+
/** The last write's outcome, for the toast. */
|
|
330
|
+
notice: null as { kind: "ok" | "error"; text: string } | null,
|
|
331
|
+
/** Ids with a write in flight, so a surface can show it. */
|
|
332
|
+
pending: [] as string[],
|
|
333
|
+
/**
|
|
334
|
+
* The order beads appear in on the active view, published by the views
|
|
335
|
+
* whose order the store cannot derive (table: its sort; mine: its
|
|
336
|
+
* sections). The gantt and grid are viewModel.rows. j/k walk this.
|
|
337
|
+
*/
|
|
338
|
+
viewOrder: [] as string[],
|
|
339
|
+
/** Bumped to ask the search palette / options menu to open; they watch it. */
|
|
340
|
+
searchRequests: 0,
|
|
341
|
+
optionsRequests: 0,
|
|
342
|
+
/** Bumped by the t key; the Gantt centres its viewport on today (bp-c6q). */
|
|
343
|
+
todayRequests: 0,
|
|
344
|
+
/** The drawer's subgraph scope, here so a key can flip it (bp-67g.60 built it local). */
|
|
345
|
+
drawerSubgraphScope: "bead" as "bead" | "epic",
|
|
346
|
+
/** Personal watch list, ids in the order they were added. Deliberately
|
|
347
|
+
* NOT part of URL state: sharing a link should share the view, not the
|
|
348
|
+
* recipient's pins. */
|
|
349
|
+
/** Who the MINE shortcuts mean (bp-egy). Stored in this browser. */
|
|
350
|
+
currentUser: (typeof localStorage === "undefined"
|
|
351
|
+
? null
|
|
352
|
+
: localStorage.getItem(CURRENT_USER_STORAGE_KEY)) as string | null,
|
|
353
|
+
/** A `?me=` carried by a shared link: wins for this load only. */
|
|
354
|
+
urlUser: null as string | null,
|
|
355
|
+
/** The person the incytes view is scoped to (bp-67g.43); ?who= in the URL. */
|
|
356
|
+
who: null as string | null,
|
|
357
|
+
// ── what-if (bp-q56) ─────────────────────────────────────────────────
|
|
358
|
+
/** The change set. Non-empty means every plan-reading view shows the what-if. */
|
|
359
|
+
whatIf: [] as WhatIfChange[],
|
|
360
|
+
whatIfPanelOpen: false,
|
|
361
|
+
/** The last confirm's outcome, per change (bp-2fq). */
|
|
362
|
+
whatIfReport: null as Array<{ text: string; ok: boolean; detail?: string }> | null,
|
|
363
|
+
whatIfApplying: false,
|
|
364
|
+
watched: (typeof localStorage === "undefined"
|
|
365
|
+
? []
|
|
366
|
+
: parseWatched(localStorage.getItem(WATCHLIST_STORAGE_KEY))) as string[],
|
|
367
|
+
/** Narrow both views to watched beads (and the children of watched
|
|
368
|
+
* epics). Session-only, like the highlight toggles. */
|
|
369
|
+
watchedOnly: false,
|
|
370
|
+
|
|
371
|
+
/** Known project locations, in the order the user arranged them. */
|
|
372
|
+
projects: (typeof localStorage === "undefined"
|
|
373
|
+
? []
|
|
374
|
+
: parseProjects(localStorage.getItem(PROJECTS_STORAGE_KEY))) as Project[],
|
|
375
|
+
activeProjectId: (typeof localStorage === "undefined"
|
|
376
|
+
? null
|
|
377
|
+
: localStorage.getItem(ACTIVE_PROJECT_STORAGE_KEY)) as string | null,
|
|
378
|
+
/** The directory the server was started in; seeded into an empty
|
|
379
|
+
* registry so a first run is never blank. */
|
|
380
|
+
launchPath: null as string | null,
|
|
381
|
+
/**
|
|
382
|
+
* Registry paths the CURRENT server would not admit, keyed by path with
|
|
383
|
+
* the refusal code as the value. Populated by loadProjects()'s reconcile
|
|
384
|
+
* step, which re-registers everything the server has forgotten; whatever
|
|
385
|
+
* still fails validation lands here so the menu can say so, instead of
|
|
386
|
+
* leaving a row that 403s whenever it is clicked.
|
|
387
|
+
*
|
|
388
|
+
* Not persisted: it is a statement about this server, exactly like the
|
|
389
|
+
* allowlist it mirrors.
|
|
390
|
+
*/
|
|
391
|
+
unavailableProjects: {} as Record<string, string>,
|
|
392
|
+
/** Last observed fetch duration for the active project, from the
|
|
393
|
+
* server. Feeds the load meter, which has no honest progress of its
|
|
394
|
+
* own to report. */
|
|
395
|
+
lastFetchMs: null as number | null,
|
|
396
|
+
/**
|
|
397
|
+
* Prior durations keyed by project path. The meter needs an expectation
|
|
398
|
+
* BEFORE the fetch returns, so remembering the last run per project is
|
|
399
|
+
* what makes a switch back determinate instead of starting blind.
|
|
400
|
+
*/
|
|
401
|
+
fetchMsByProject: {} as Record<string, number>,
|
|
402
|
+
/** When the in-flight fetch started, for the meter's elapsed clock. */
|
|
403
|
+
fetchStartedAt: null as number | null,
|
|
404
|
+
/**
|
|
405
|
+
* True while a refresh is happening over data we already have. Derived
|
|
406
|
+
* rather than flagged by the caller: what decides whether to blank the
|
|
407
|
+
* view is simply whether there IS a view — a manual refresh and a
|
|
408
|
+
* polled one both leave the chart up.
|
|
409
|
+
*/
|
|
410
|
+
refreshingInPlace: false,
|
|
411
|
+
/** Poll interval in ms; 0 means manual refresh only. bp-1kh acts on it. */
|
|
412
|
+
pollIntervalMs: (typeof localStorage === "undefined"
|
|
413
|
+
? DEFAULT_REFRESH_MS
|
|
414
|
+
: parseRefreshMs(localStorage.getItem(REFRESH_STORAGE_KEY))) as number,
|
|
415
|
+
// Row cap per group before truncation. Higher than the CLI default (20)
|
|
416
|
+
// because the interactive Gantt scrolls naturally — you rarely want
|
|
417
|
+
// work hidden by default, but a ceiling still helps very large groups
|
|
418
|
+
// stay usable. Per-group expand is offered via the group header.
|
|
419
|
+
maxPerGroup: 50,
|
|
420
|
+
/**
|
|
421
|
+
* Groups where per-group row truncation is lifted (all rows visible).
|
|
422
|
+
* Kept as a plain object rather than a Set so Pinia's reactivity picks
|
|
423
|
+
* up mutations without the caller needing to reassign.
|
|
424
|
+
*/
|
|
425
|
+
expandedGroups: {} as Record<string, boolean>,
|
|
426
|
+
highlights: {
|
|
427
|
+
blocked: false,
|
|
428
|
+
"over-cap": false,
|
|
429
|
+
"ready-now": false,
|
|
430
|
+
} as Record<HighlightKind, boolean>,
|
|
431
|
+
/** Draw dependency arrows between visible bars. When on, arrows render
|
|
432
|
+
* around the hovered bead only — that's
|
|
433
|
+
* the most useful signal and stays legible in a big project. Full-graph
|
|
434
|
+
* arrows require the separate `showAllDependencies` opt-in. */
|
|
435
|
+
showDependencies: false,
|
|
436
|
+
/** Draw arrows for every visible blocks edge, regardless of hover. Off
|
|
437
|
+
* by default because the arrow density gets busy fast. */
|
|
438
|
+
showAllDependencies: false,
|
|
439
|
+
/** Which bead has its detail drawer open, if any. Set via
|
|
440
|
+
* openBead() / closeBead() and kept in sync with the URL hash by
|
|
441
|
+
* App.vue so drawers are deep-linkable. */
|
|
442
|
+
openBeadId: null as string | null,
|
|
443
|
+
}),
|
|
444
|
+
|
|
445
|
+
getters: {
|
|
446
|
+
beads: (s): Bead[] => s.payload?.beads ?? [],
|
|
447
|
+
roster: (s): Roster | null => s.payload?.roster ?? null,
|
|
448
|
+
generatedAt: (s): string | null => s.payload?.generated_at ?? null,
|
|
449
|
+
|
|
450
|
+
/** The user the MINE shortcuts filter on: the link's if it carries one, else the stored choice. */
|
|
451
|
+
effectiveUser: (s): string | null => s.urlUser ?? s.currentUser,
|
|
452
|
+
|
|
453
|
+
/** Assignee handles present on real beads, most-used first: the options for "current user". */
|
|
454
|
+
presentAssignees(): string[] {
|
|
455
|
+
const seen = new Map<string, number>();
|
|
456
|
+
for (const b of this.beads) {
|
|
457
|
+
if (b.issue_type === "epic" || !b.assignee) continue;
|
|
458
|
+
seen.set(b.assignee, (seen.get(b.assignee) ?? 0) + 1);
|
|
459
|
+
}
|
|
460
|
+
return [...seen.entries()].sort((a, b) => b[1] - a[1]).map(([h]) => h);
|
|
461
|
+
},
|
|
462
|
+
|
|
463
|
+
/** A stored user that no longer appears in the data: renamed or departed. */
|
|
464
|
+
userIsStale(): boolean {
|
|
465
|
+
const u = this.effectiveUser;
|
|
466
|
+
return !!u && this.beads.length > 0 && !this.presentAssignees.includes(u);
|
|
467
|
+
},
|
|
468
|
+
|
|
469
|
+
/** Statuses on real beads, the same enumeration the status filter seeds from. */
|
|
470
|
+
presentStatuses(): string[] {
|
|
471
|
+
const out = new Set<string>();
|
|
472
|
+
for (const b of this.beads) if (b.issue_type !== "epic") out.add(b.status);
|
|
473
|
+
return [...out];
|
|
474
|
+
},
|
|
475
|
+
|
|
476
|
+
/** Which MINE shortcut the current filters equal, if any. Derived, never stored. */
|
|
477
|
+
activeShortcut(state): Shortcut | null {
|
|
478
|
+
const me = this.effectiveUser;
|
|
479
|
+
if (!me) return null;
|
|
480
|
+
for (const id of SHORTCUT_IDS) {
|
|
481
|
+
if (isShortcutActive(state.filters, id, me, this.presentStatuses)) return id;
|
|
482
|
+
}
|
|
483
|
+
return null;
|
|
484
|
+
},
|
|
485
|
+
|
|
486
|
+
watchedSet: (s): ReadonlySet<string> => new Set(s.watched),
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* How long the active project took last time, or null if we've never
|
|
490
|
+
* loaded it. Null is the honest answer and the meter shows an
|
|
491
|
+
* indeterminate bar for it rather than guessing.
|
|
492
|
+
*/
|
|
493
|
+
expectedFetchMs(state): number | null {
|
|
494
|
+
const path = this.activeProject?.path;
|
|
495
|
+
if (!path) return null;
|
|
496
|
+
return state.fetchMsByProject[path] ?? null;
|
|
497
|
+
},
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The selected project. Resolution order lives in projects.ts:
|
|
501
|
+
* ?project=<label>, then the stored id, then the launch directory,
|
|
502
|
+
* then the first entry — so a non-empty list always has a selection.
|
|
503
|
+
*/
|
|
504
|
+
activeProject(state): Project | null {
|
|
505
|
+
const urlLabel =
|
|
506
|
+
typeof location === "undefined"
|
|
507
|
+
? null
|
|
508
|
+
: new URLSearchParams(location.search).get("project");
|
|
509
|
+
return resolveActive(state.projects, {
|
|
510
|
+
urlLabel,
|
|
511
|
+
storedId: state.activeProjectId,
|
|
512
|
+
launchPath: state.launchPath,
|
|
513
|
+
});
|
|
514
|
+
},
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Watched ids that still exist in the payload, paired with their bead.
|
|
518
|
+
* An id can outlive its bead (deleted, renamed, or simply out of this
|
|
519
|
+
* repo), so the header list and count are built from this rather than
|
|
520
|
+
* from the raw id list.
|
|
521
|
+
*/
|
|
522
|
+
watchedBeads(state): Bead[] {
|
|
523
|
+
const byId = new Map(
|
|
524
|
+
(state.payload?.beads ?? []).map((b) => [b.id, b] as const),
|
|
525
|
+
);
|
|
526
|
+
return state.watched
|
|
527
|
+
.map((id) => byId.get(id))
|
|
528
|
+
.filter((b): b is Bead => !!b);
|
|
529
|
+
},
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Full schedule + per-bead estimates + observed velocity + calibrated
|
|
533
|
+
* roster. Kept in a single cached getter so all consumers share one
|
|
534
|
+
* computation; callers treat every returned map as a read-only
|
|
535
|
+
* snapshot.
|
|
536
|
+
*/
|
|
537
|
+
/** The real plan: the payload as bd reports it, cached. */
|
|
538
|
+
baselinePlan(state): {
|
|
539
|
+
schedule: Schedule;
|
|
540
|
+
estimates: Map<string, EstimateResult>;
|
|
541
|
+
velocity: VelocitySummary;
|
|
542
|
+
calibratedRoster: Roster;
|
|
543
|
+
} | null {
|
|
544
|
+
if (!state.payload) return null;
|
|
545
|
+
const roster = state.payload.roster ?? { humans: [] };
|
|
546
|
+
const horizon = state.window.to ?? new Date(Date.now() + 90 * 86_400_000);
|
|
547
|
+
return scheduleFor(state.payload.beads, roster, horizon);
|
|
548
|
+
},
|
|
549
|
+
|
|
550
|
+
whatIfActive(state): boolean {
|
|
551
|
+
return state.whatIf.length > 0;
|
|
552
|
+
},
|
|
553
|
+
|
|
554
|
+
/** The beads the plan is over: the what-if copy when one is active (bp-q56). */
|
|
555
|
+
planBeads(state): Bead[] {
|
|
556
|
+
if (!state.payload) return [];
|
|
557
|
+
if (state.whatIf.length === 0) return state.payload.beads;
|
|
558
|
+
return applyChanges(state.payload.beads, state.payload.roster ?? { humans: [] }, state.whatIf).beads;
|
|
559
|
+
},
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* The plan every plan-reading view uses: the baseline, or the what-if's
|
|
563
|
+
* copy run through the same pipeline. Uncached on purpose — a what-if is
|
|
564
|
+
* edited interactively and the pipeline is ~50ms.
|
|
565
|
+
*/
|
|
566
|
+
scheduleWithEstimates(state): {
|
|
567
|
+
schedule: Schedule;
|
|
568
|
+
estimates: Map<string, EstimateResult>;
|
|
569
|
+
velocity: VelocitySummary;
|
|
570
|
+
calibratedRoster: Roster;
|
|
571
|
+
} | null {
|
|
572
|
+
if (!state.payload) return null;
|
|
573
|
+
if (state.whatIf.length === 0) return this.baselinePlan;
|
|
574
|
+
const applied = applyChanges(state.payload.beads, state.payload.roster ?? { humans: [] }, state.whatIf);
|
|
575
|
+
const horizon = state.window.to ?? new Date(Date.now() + 90 * 86_400_000);
|
|
576
|
+
return computePlan(applied.beads, applied.roster, horizon);
|
|
577
|
+
},
|
|
578
|
+
|
|
579
|
+
/** What the what-if moves against the baseline; null when none is active. */
|
|
580
|
+
whatIfDiff(state): PlanDiff | null {
|
|
581
|
+
if (state.whatIf.length === 0) return null;
|
|
582
|
+
const base = this.baselinePlan;
|
|
583
|
+
const plan = this.scheduleWithEstimates;
|
|
584
|
+
if (!base || !plan) return null;
|
|
585
|
+
return diffPlans(base.schedule, plan.schedule, this.planBeads, { frontierDays: 14 });
|
|
586
|
+
},
|
|
587
|
+
|
|
588
|
+
/** Observed per-human close-rate over the last 60 days. */
|
|
589
|
+
velocity(): VelocitySummary | null {
|
|
590
|
+
return this.scheduleWithEstimates?.velocity ?? null;
|
|
591
|
+
},
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Roster with per-human WIP caps clamped to observed weekly close-rate.
|
|
595
|
+
* Consumers reach for this instead of the raw roster when they want to
|
|
596
|
+
* show "actual" projected concurrency.
|
|
597
|
+
*/
|
|
598
|
+
calibratedRoster(): Roster | null {
|
|
599
|
+
return this.scheduleWithEstimates?.calibratedRoster ?? null;
|
|
600
|
+
},
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* The reactive view model. Pinia getters are cached against their
|
|
604
|
+
* reactive reads, so this only recomputes when beads, filters, window,
|
|
605
|
+
* or group mode change.
|
|
606
|
+
*/
|
|
607
|
+
viewModel(state): ViewModel | null {
|
|
608
|
+
const sched = this.scheduleWithEstimates;
|
|
609
|
+
if (!state.payload || !sched) return null;
|
|
610
|
+
const expanded = new Set(
|
|
611
|
+
Object.entries(state.expandedGroups)
|
|
612
|
+
.filter(([, v]) => v)
|
|
613
|
+
.map(([k]) => k),
|
|
614
|
+
);
|
|
615
|
+
return assembleViewModel(this.planBeads, sched.schedule, {
|
|
616
|
+
groupBy: state.groupBy,
|
|
617
|
+
filters: state.watchedOnly
|
|
618
|
+
? { ...state.filters, watchedIds: state.watched }
|
|
619
|
+
: state.filters,
|
|
620
|
+
window: state.window,
|
|
621
|
+
maxPerGroup: state.maxPerGroup,
|
|
622
|
+
expandedGroups: expanded,
|
|
623
|
+
});
|
|
624
|
+
},
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* The table view's rows (bp-67g.44): the same filtered, windowed set as
|
|
628
|
+
* viewModel — same filters, same watched-only merge, same window — with
|
|
629
|
+
* the per-group cap lifted. Truncation is a Gantt drawing affordance; a
|
|
630
|
+
* table that silently drops rows is worse than a long one. Lazy, so the
|
|
631
|
+
* second assembly costs nothing until the table is on screen.
|
|
632
|
+
*/
|
|
633
|
+
tableViewModel(state): ViewModel | null {
|
|
634
|
+
const sched = this.scheduleWithEstimates;
|
|
635
|
+
if (!state.payload || !sched) return null;
|
|
636
|
+
return assembleViewModel(this.planBeads, sched.schedule, {
|
|
637
|
+
groupBy: state.groupBy,
|
|
638
|
+
filters: state.watchedOnly
|
|
639
|
+
? { ...state.filters, watchedIds: state.watched }
|
|
640
|
+
: state.filters,
|
|
641
|
+
window: state.window,
|
|
642
|
+
maxPerGroup: Infinity,
|
|
643
|
+
});
|
|
644
|
+
},
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Build the tooltip payload for a bead by id. Returns null if the bead
|
|
648
|
+
* or the payload isn't loaded yet. Consumers (tooltip, drawer)
|
|
649
|
+
* pass this straight to the template.
|
|
650
|
+
*/
|
|
651
|
+
beadDetailFor(): (id: string) => BeadDetail | null {
|
|
652
|
+
const sched = this.scheduleWithEstimates;
|
|
653
|
+
const beads = this.planBeads;
|
|
654
|
+
const byId = new Map(beads.map((b) => [b.id, b] as const));
|
|
655
|
+
return (id: string): BeadDetail | null => {
|
|
656
|
+
const b = byId.get(id);
|
|
657
|
+
if (!b) return null;
|
|
658
|
+
return buildBeadDetail(
|
|
659
|
+
b,
|
|
660
|
+
sched?.schedule.get(id) as ScheduleEntry | undefined,
|
|
661
|
+
sched?.estimates.get(id),
|
|
662
|
+
{ horizonDay: this.scheduleHorizonDay },
|
|
663
|
+
);
|
|
664
|
+
};
|
|
665
|
+
},
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* How many days past today the scheduler was asked to project.
|
|
669
|
+
*
|
|
670
|
+
* The same number scheduleWithEstimates hands scheduleGreedy, so a
|
|
671
|
+
* finish clamped to the horizon can be recognised as a bound rather
|
|
672
|
+
* than reported as a date. It moves with the window preset, which is
|
|
673
|
+
* exactly why the caveat is needed.
|
|
674
|
+
*/
|
|
675
|
+
scheduleHorizonDay(state): number {
|
|
676
|
+
const horizon =
|
|
677
|
+
state.window.to ?? new Date(Date.now() + 90 * 86_400_000);
|
|
678
|
+
const today = new Date();
|
|
679
|
+
const day0 = new Date(
|
|
680
|
+
today.getFullYear(),
|
|
681
|
+
today.getMonth(),
|
|
682
|
+
today.getDate(),
|
|
683
|
+
);
|
|
684
|
+
const end = new Date(
|
|
685
|
+
horizon.getFullYear(),
|
|
686
|
+
horizon.getMonth(),
|
|
687
|
+
horizon.getDate(),
|
|
688
|
+
);
|
|
689
|
+
return Math.round((+end - +day0) / 86_400_000);
|
|
690
|
+
},
|
|
691
|
+
|
|
692
|
+
blockedBeadIds(state): ReadonlySet<string> {
|
|
693
|
+
if (!state.highlights.blocked) return EMPTY_SET;
|
|
694
|
+
return computeBlockedIds(state.payload?.beads ?? []);
|
|
695
|
+
},
|
|
696
|
+
|
|
697
|
+
readyNowBeadIds(state): ReadonlySet<string> {
|
|
698
|
+
if (!state.highlights["ready-now"]) return EMPTY_SET;
|
|
699
|
+
return computeReadyNowIds(state.payload?.beads ?? []);
|
|
700
|
+
},
|
|
701
|
+
|
|
702
|
+
overCapAssignees(state): ReadonlySet<string> {
|
|
703
|
+
if (!state.highlights["over-cap"]) return EMPTY_SET;
|
|
704
|
+
return computeOverCapAssignees(
|
|
705
|
+
state.payload?.beads ?? [],
|
|
706
|
+
state.payload?.roster ?? { humans: [] },
|
|
707
|
+
);
|
|
708
|
+
},
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Triage score per bead — same signal set as bv --robot-triage,
|
|
712
|
+
* computed locally. Cached against the full bead array.
|
|
713
|
+
*/
|
|
714
|
+
triage(state): Map<string, TriageResult> {
|
|
715
|
+
if (!state.payload) return new Map();
|
|
716
|
+
return computeTriage(state.payload.beads);
|
|
717
|
+
},
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Per-signal spread of the triage signals across the OPEN population.
|
|
721
|
+
*
|
|
722
|
+
* Open only: that is the set triage ranks, and including closed beads
|
|
723
|
+
* would make every live bead look extreme against a mostly-dead field.
|
|
724
|
+
* Cached against the payload like `triage` itself, and read only from
|
|
725
|
+
* the drawer, so it stays off the Gantt's path.
|
|
726
|
+
*/
|
|
727
|
+
triageSignalPopulation(): SignalPopulation[] {
|
|
728
|
+
const t = this.triage;
|
|
729
|
+
if (!t.size) return [];
|
|
730
|
+
const openIds = (this.payload?.beads ?? [])
|
|
731
|
+
.filter((b) => b.status !== "closed")
|
|
732
|
+
.map((b) => b.id);
|
|
733
|
+
return signalPopulation(openIds, t);
|
|
734
|
+
},
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* The composite score's own population, for the drawer's strip (bp-67g.28).
|
|
738
|
+
* Open beads only, for the reason signalPopulation gives; read only from
|
|
739
|
+
* the drawer, so triage stays lazy.
|
|
740
|
+
*/
|
|
741
|
+
triageScorePopulation(): ScorePopulation {
|
|
742
|
+
const t = this.triage;
|
|
743
|
+
const openIds = t.size
|
|
744
|
+
? (this.payload?.beads ?? []).filter((b) => b.status !== "closed").map((b) => b.id)
|
|
745
|
+
: [];
|
|
746
|
+
return scorePopulation(openIds, t);
|
|
747
|
+
},
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Ships per week, for the summary trend. Reshapes the series
|
|
751
|
+
* computeVelocity already accumulated — nothing walks the beads again,
|
|
752
|
+
* and the trend cannot disagree with the calibration band about what
|
|
753
|
+
* counts as a ship.
|
|
754
|
+
*/
|
|
755
|
+
shipTrend(): ShipTrend | null {
|
|
756
|
+
const v = this.velocity;
|
|
757
|
+
return v ? shipTrend(v.weekly) : null;
|
|
758
|
+
},
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Groups by population for the summary chart, largest first.
|
|
762
|
+
*
|
|
763
|
+
* Derived from the view model's groups, so it respects the current
|
|
764
|
+
* group-by mode — but the quantity is `progress`, which is counted
|
|
765
|
+
* before filtering and window clipping, so the chart doesn't shrink
|
|
766
|
+
* when someone hides closed beads.
|
|
767
|
+
*/
|
|
768
|
+
// Project-wide (bp-67g.36): every group over every bead, so neither the
|
|
769
|
+
// filters nor the window reach the incytes Progress block. It used to
|
|
770
|
+
// reshape viewModel.groups, which only carries groups with a visible row.
|
|
771
|
+
groupProgress(state): GroupProgressRow[] {
|
|
772
|
+
const sched = this.scheduleWithEstimates;
|
|
773
|
+
if (!state.payload || !sched) return [];
|
|
774
|
+
return groupProgress(projectGroups(state.payload.beads, sched.schedule, state.groupBy));
|
|
775
|
+
},
|
|
776
|
+
|
|
777
|
+
/** The beads in the order the active view shows them; what j/k walk. */
|
|
778
|
+
cursorOrder(state): string[] {
|
|
779
|
+
if (state.view === "gantt" || state.view === "grid") return this.viewModel?.rows.map((r) => r.beadId) ?? [];
|
|
780
|
+
if (state.view === "table" || state.view === "mine") return state.viewOrder;
|
|
781
|
+
return [];
|
|
782
|
+
},
|
|
783
|
+
|
|
784
|
+
/** The preset the current window matches, or null when it is custom. */
|
|
785
|
+
activeWindowPreset(state): WindowPreset | null {
|
|
786
|
+
const same = (a: Date | null, b: Date | null) =>
|
|
787
|
+
(a === null && b === null) || (a !== null && b !== null && Math.abs(a.getTime() - b.getTime()) < 60_000);
|
|
788
|
+
for (const p of WINDOW_PRESETS) {
|
|
789
|
+
const w = windowFromPreset(p.id);
|
|
790
|
+
if (same(state.window.from, w.from) && same(state.window.to, w.to)) return p.id;
|
|
791
|
+
}
|
|
792
|
+
return null;
|
|
793
|
+
},
|
|
794
|
+
|
|
795
|
+
/** One bead by id, or null. */
|
|
796
|
+
beadById(state): (id: string) => Bead | null {
|
|
797
|
+
const byId = new Map((state.payload?.beads ?? []).map((b) => [b.id, b] as const));
|
|
798
|
+
return (id: string) => byId.get(id) ?? null;
|
|
799
|
+
},
|
|
800
|
+
|
|
801
|
+
/** The currently-open bead (drawer target), or null. */
|
|
802
|
+
drawerBead(state): Bead | null {
|
|
803
|
+
if (!state.openBeadId || !state.payload) return null;
|
|
804
|
+
return state.payload.beads.find((b) => b.id === state.openBeadId) ?? null;
|
|
805
|
+
},
|
|
806
|
+
},
|
|
807
|
+
|
|
808
|
+
actions: {
|
|
809
|
+
async fetch() {
|
|
810
|
+
// Refreshing over existing data must not blank the chart someone is
|
|
811
|
+
// reading, whether the trigger was a timer or a button.
|
|
812
|
+
this.refreshingInPlace = !!this.payload;
|
|
813
|
+
this.loading = true;
|
|
814
|
+
this.fetchStartedAt = Date.now();
|
|
815
|
+
this.error = null;
|
|
816
|
+
// Captured before awaiting: a project switch mid-flight must not let
|
|
817
|
+
// the old project's response land on the new selection.
|
|
818
|
+
const requestedProject = this.activeProject?.path ?? null;
|
|
819
|
+
try {
|
|
820
|
+
const params = new URLSearchParams();
|
|
821
|
+
if (this.generatedAt) params.set("since", this.generatedAt);
|
|
822
|
+
if (requestedProject) params.set("project", requestedProject);
|
|
823
|
+
const qs = params.toString();
|
|
824
|
+
const res = await fetch(qs ? `/api/beads?${qs}` : "/api/beads");
|
|
825
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
826
|
+
const body = (await res.json()) as ApiResponse;
|
|
827
|
+
// Discard a response for a project we've since navigated away from.
|
|
828
|
+
if ((this.activeProject?.path ?? null) !== requestedProject) return;
|
|
829
|
+
if (typeof body.took_ms === "number") {
|
|
830
|
+
this.lastFetchMs = body.took_ms;
|
|
831
|
+
if (requestedProject) {
|
|
832
|
+
this.fetchMsByProject[requestedProject] = body.took_ms;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
if (body.stale === false) {
|
|
836
|
+
this.lastFetched = new Date();
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
if (!body.beads || !body.generated_at) {
|
|
840
|
+
throw new Error("malformed payload from /api/beads");
|
|
841
|
+
}
|
|
842
|
+
this.payload = {
|
|
843
|
+
generated_at: body.generated_at,
|
|
844
|
+
beads: body.beads,
|
|
845
|
+
roster: body.roster ?? { humans: [] },
|
|
846
|
+
};
|
|
847
|
+
// The status filter's default depends on which statuses this repo
|
|
848
|
+
// actually has, so it can only be seeded once data exists. No-ops
|
|
849
|
+
// after the first payload and whenever a selection already stands.
|
|
850
|
+
this.initStatusSelection();
|
|
851
|
+
this.lastFetched = new Date();
|
|
852
|
+
} catch (e) {
|
|
853
|
+
this.error = String((e as Error).message ?? e);
|
|
854
|
+
} finally {
|
|
855
|
+
this.loading = false;
|
|
856
|
+
this.fetchStartedAt = null;
|
|
857
|
+
this.refreshingInPlace = false;
|
|
858
|
+
}
|
|
859
|
+
},
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Ask the server what it was launched in and what it already trusts,
|
|
863
|
+
* then make sure the launch directory is in the registry. Without this
|
|
864
|
+
* a fresh browser shows an empty project list against a server that is
|
|
865
|
+
* perfectly happy to serve one.
|
|
866
|
+
*/
|
|
867
|
+
async loadProjects() {
|
|
868
|
+
try {
|
|
869
|
+
const res = await fetch("/api/projects");
|
|
870
|
+
if (!res.ok) return;
|
|
871
|
+
const body = (await res.json()) as {
|
|
872
|
+
launch?: string;
|
|
873
|
+
projects?: string[];
|
|
874
|
+
};
|
|
875
|
+
if (!body.launch) return;
|
|
876
|
+
this.launchPath = body.launch;
|
|
877
|
+
if (!this.projects.some((p) => p.path === body.launch)) {
|
|
878
|
+
this.projects = addProject(this.projects, { path: body.launch });
|
|
879
|
+
this.persistProjects();
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Reconcile. The server's allowlist is per-process by design, so
|
|
883
|
+
// every restart leaves this registry holding paths it has forgotten
|
|
884
|
+
// — and reading one of those answers 403 with no data. Re-register
|
|
885
|
+
// them now, at boot, rather than discovering it at the click that
|
|
886
|
+
// switches to one.
|
|
887
|
+
//
|
|
888
|
+
// Re-registering rather than persisting the allowlist keeps the
|
|
889
|
+
// property that makes it per-process: inspectProject() runs again in
|
|
890
|
+
// THIS process, so a path that has moved or lost its .beads is
|
|
891
|
+
// refused now instead of being trusted from a stale file.
|
|
892
|
+
await Promise.all(
|
|
893
|
+
unadmittedPaths(this.projects, body.projects).map(async (path) => {
|
|
894
|
+
const result = await this.addProjectByPath(path);
|
|
895
|
+
if (result.ok) {
|
|
896
|
+
delete this.unavailableProjects[path];
|
|
897
|
+
} else {
|
|
898
|
+
this.unavailableProjects[path] = result.reason;
|
|
899
|
+
}
|
|
900
|
+
}),
|
|
901
|
+
);
|
|
902
|
+
} catch {
|
|
903
|
+
// Older server, or none. The app still works against the default
|
|
904
|
+
// project; the menu simply has nothing to switch between.
|
|
905
|
+
}
|
|
906
|
+
},
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Register a path with the server and add it on success. Returns the
|
|
910
|
+
* refusal reason so the UI can say why rather than failing silently.
|
|
911
|
+
*/
|
|
912
|
+
async addProjectByPath(
|
|
913
|
+
path: string,
|
|
914
|
+
label?: string,
|
|
915
|
+
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
916
|
+
try {
|
|
917
|
+
const res = await fetch("/api/projects", {
|
|
918
|
+
method: "POST",
|
|
919
|
+
headers: { "Content-Type": "application/json" },
|
|
920
|
+
body: JSON.stringify({ path }),
|
|
921
|
+
});
|
|
922
|
+
const body = (await res.json()) as {
|
|
923
|
+
ok?: boolean;
|
|
924
|
+
path?: string;
|
|
925
|
+
label?: string;
|
|
926
|
+
reason?: string;
|
|
927
|
+
};
|
|
928
|
+
if (!res.ok || !body.ok || !body.path) {
|
|
929
|
+
return { ok: false, reason: body.reason ?? `http-${res.status}` };
|
|
930
|
+
}
|
|
931
|
+
// Store the path the SERVER canonicalised, not what was typed, so
|
|
932
|
+
// later reads normalise to the same string it admitted.
|
|
933
|
+
this.projects = addProject(this.projects, {
|
|
934
|
+
path: body.path,
|
|
935
|
+
label: label || body.label,
|
|
936
|
+
});
|
|
937
|
+
delete this.unavailableProjects[body.path];
|
|
938
|
+
this.persistProjects();
|
|
939
|
+
return { ok: true };
|
|
940
|
+
} catch (e) {
|
|
941
|
+
return { ok: false, reason: String((e as Error).message ?? e) };
|
|
942
|
+
}
|
|
943
|
+
},
|
|
944
|
+
|
|
945
|
+
setPollInterval(ms: number) {
|
|
946
|
+
this.pollIntervalMs = parseRefreshMs(String(ms));
|
|
947
|
+
try {
|
|
948
|
+
localStorage.setItem(REFRESH_STORAGE_KEY, String(this.pollIntervalMs));
|
|
949
|
+
} catch {
|
|
950
|
+
/* private mode — holds for this session only */
|
|
951
|
+
}
|
|
952
|
+
},
|
|
953
|
+
|
|
954
|
+
setActiveProject(id: string) {
|
|
955
|
+
if (this.activeProjectId === id) return;
|
|
956
|
+
this.activeProjectId = id;
|
|
957
|
+
try {
|
|
958
|
+
localStorage.setItem(ACTIVE_PROJECT_STORAGE_KEY, id);
|
|
959
|
+
} catch {
|
|
960
|
+
/* private mode — holds for this session only */
|
|
961
|
+
}
|
|
962
|
+
// A different project is a different dataset: drop the payload so the
|
|
963
|
+
// views show a load state rather than the previous project's beads,
|
|
964
|
+
// and close the drawer, whose id does not exist over there.
|
|
965
|
+
this.payload = null;
|
|
966
|
+
this.openBeadId = null;
|
|
967
|
+
this.lastFetchMs = null;
|
|
968
|
+
void this.fetch();
|
|
969
|
+
},
|
|
970
|
+
|
|
971
|
+
renameProject(id: string, label: string) {
|
|
972
|
+
this.projects = renameProject(this.projects, id, label);
|
|
973
|
+
this.persistProjects();
|
|
974
|
+
},
|
|
975
|
+
|
|
976
|
+
/** Forgets a location. Nothing on disk is touched. */
|
|
977
|
+
forgetProject(id: string) {
|
|
978
|
+
const wasActive = this.activeProject?.id === id;
|
|
979
|
+
const gone = this.projects.find((p) => p.id === id);
|
|
980
|
+
if (gone) delete this.unavailableProjects[gone.path];
|
|
981
|
+
this.projects = removeProject(this.projects, id);
|
|
982
|
+
this.persistProjects();
|
|
983
|
+
if (wasActive) {
|
|
984
|
+
const fallback = resolveActive(this.projects, {
|
|
985
|
+
launchPath: this.launchPath,
|
|
986
|
+
});
|
|
987
|
+
if (fallback) this.setActiveProject(fallback.id);
|
|
988
|
+
}
|
|
989
|
+
},
|
|
990
|
+
|
|
991
|
+
moveProject(id: string, delta: number) {
|
|
992
|
+
this.projects = moveProject(this.projects, id, delta);
|
|
993
|
+
this.persistProjects();
|
|
994
|
+
},
|
|
995
|
+
|
|
996
|
+
persistProjects() {
|
|
997
|
+
try {
|
|
998
|
+
localStorage.setItem(
|
|
999
|
+
PROJECTS_STORAGE_KEY,
|
|
1000
|
+
serializeProjects(this.projects),
|
|
1001
|
+
);
|
|
1002
|
+
} catch {
|
|
1003
|
+
/* private mode — holds for this session only */
|
|
1004
|
+
}
|
|
1005
|
+
},
|
|
1006
|
+
|
|
1007
|
+
setGroupBy(g: GroupBy) {
|
|
1008
|
+
this.groupBy = g;
|
|
1009
|
+
},
|
|
1010
|
+
|
|
1011
|
+
setView(v: ViewMode) {
|
|
1012
|
+
this.view = v;
|
|
1013
|
+
},
|
|
1014
|
+
|
|
1015
|
+
toggleWatch(id: string) {
|
|
1016
|
+
this.watched = toggleWatched(this.watched, id);
|
|
1017
|
+
try {
|
|
1018
|
+
localStorage.setItem(
|
|
1019
|
+
WATCHLIST_STORAGE_KEY,
|
|
1020
|
+
serializeWatched(this.watched),
|
|
1021
|
+
);
|
|
1022
|
+
} catch {
|
|
1023
|
+
// Private mode or a full quota: the list still works for this
|
|
1024
|
+
// session, it just won't survive a reload. Not worth failing over.
|
|
1025
|
+
}
|
|
1026
|
+
// Nothing left to narrow to.
|
|
1027
|
+
if (this.watched.length === 0) this.watchedOnly = false;
|
|
1028
|
+
},
|
|
1029
|
+
|
|
1030
|
+
toggleWatchedOnly() {
|
|
1031
|
+
this.watchedOnly = !this.watchedOnly;
|
|
1032
|
+
},
|
|
1033
|
+
|
|
1034
|
+
toggleHighlight(kind: HighlightKind) {
|
|
1035
|
+
this.highlights = {
|
|
1036
|
+
...this.highlights,
|
|
1037
|
+
[kind]: !this.highlights[kind],
|
|
1038
|
+
};
|
|
1039
|
+
},
|
|
1040
|
+
|
|
1041
|
+
toggleShowDependencies() {
|
|
1042
|
+
this.showDependencies = !this.showDependencies;
|
|
1043
|
+
},
|
|
1044
|
+
|
|
1045
|
+
toggleShowAllDependencies() {
|
|
1046
|
+
this.showAllDependencies = !this.showAllDependencies;
|
|
1047
|
+
},
|
|
1048
|
+
|
|
1049
|
+
openBead(id: string) {
|
|
1050
|
+
this.openBeadId = id;
|
|
1051
|
+
this.drawerSubgraphScope = "bead";
|
|
1052
|
+
},
|
|
1053
|
+
|
|
1054
|
+
closeBead() {
|
|
1055
|
+
this.openBeadId = null;
|
|
1056
|
+
},
|
|
1057
|
+
|
|
1058
|
+
// ── keyboard (bp-67g.45) ─────────────────────────────────────────────
|
|
1059
|
+
/** The keyboard cursor is the open drawer; see keybindings.ts. */
|
|
1060
|
+
moveCursor(delta: number) {
|
|
1061
|
+
const id = nextCursor(this.cursorOrder, this.openBeadId, delta);
|
|
1062
|
+
if (id) this.openBead(id);
|
|
1063
|
+
},
|
|
1064
|
+
|
|
1065
|
+
publishViewOrder(ids: string[]) {
|
|
1066
|
+
this.viewOrder = ids;
|
|
1067
|
+
},
|
|
1068
|
+
|
|
1069
|
+
openHelp() {
|
|
1070
|
+
this.helpOpen = true;
|
|
1071
|
+
},
|
|
1072
|
+
|
|
1073
|
+
closeHelp() {
|
|
1074
|
+
this.helpOpen = false;
|
|
1075
|
+
},
|
|
1076
|
+
|
|
1077
|
+
openChangelog() {
|
|
1078
|
+
this.changelogOpen = true;
|
|
1079
|
+
},
|
|
1080
|
+
|
|
1081
|
+
openWalkthrough() {
|
|
1082
|
+
this.walkthroughOpen = true;
|
|
1083
|
+
},
|
|
1084
|
+
|
|
1085
|
+
// ── writes (bp-ocs) ─────────────────────────────────────────────────
|
|
1086
|
+
openContextMenu(id: string, x: number, y: number) {
|
|
1087
|
+
this.contextMenu = { id, x, y };
|
|
1088
|
+
},
|
|
1089
|
+
|
|
1090
|
+
closeContextMenu() {
|
|
1091
|
+
this.contextMenu = null;
|
|
1092
|
+
},
|
|
1093
|
+
|
|
1094
|
+
clearNotice() {
|
|
1095
|
+
this.notice = null;
|
|
1096
|
+
},
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Apply a whitelisted mutation: optimistic patch, POST to /api/mutate,
|
|
1100
|
+
* then the server's fresh bead — or the original back on failure — and
|
|
1101
|
+
* a notice either way that carries the exact bd command. Finishes with
|
|
1102
|
+
* a fetch, since the server dropped its list cache for the project.
|
|
1103
|
+
*/
|
|
1104
|
+
async mutate(m: Mutation) {
|
|
1105
|
+
const beads = this.payload?.beads;
|
|
1106
|
+
if (!beads) return;
|
|
1107
|
+
const i = beads.findIndex((b) => b.id === m.id);
|
|
1108
|
+
if (i < 0) return;
|
|
1109
|
+
const original = beads[i]!;
|
|
1110
|
+
const guess = applyOptimistic(original, m, this.effectiveUser);
|
|
1111
|
+
beads.splice(i, 1, guess);
|
|
1112
|
+
this.pending = [...this.pending, m.id];
|
|
1113
|
+
const project = this.activeProject?.path ?? null;
|
|
1114
|
+
try {
|
|
1115
|
+
const res = await fetch("/api/mutate", {
|
|
1116
|
+
method: "POST",
|
|
1117
|
+
headers: { "Content-Type": "application/json" },
|
|
1118
|
+
body: JSON.stringify({ ...m, project }),
|
|
1119
|
+
});
|
|
1120
|
+
const body = (await res.json().catch(() => ({}))) as { ok?: boolean; command?: string; bead?: Bead | null; error?: string };
|
|
1121
|
+
// A 404 here is a server started before this route existed: Vite
|
|
1122
|
+
// hot-reloads the client but middleware loads at start.
|
|
1123
|
+
if (res.status === 404) {
|
|
1124
|
+
throw new Error("this server predates quick actions — restart it (beadcyte stop, then beadcyte start)");
|
|
1125
|
+
}
|
|
1126
|
+
if (!res.ok || !body.ok) throw new Error(body.error || `HTTP ${res.status}`, { cause: body.command });
|
|
1127
|
+
const j = this.payload?.beads.findIndex((b) => b.id === m.id) ?? -1;
|
|
1128
|
+
if (j >= 0 && body.bead) this.payload!.beads.splice(j, 1, { ...this.payload!.beads[j]!, ...body.bead });
|
|
1129
|
+
if (body.command) console.info(`beadcyte: ${body.command}`);
|
|
1130
|
+
this.notice = { kind: "ok", text: `${m.id} ${describeOutcome(m)}` };
|
|
1131
|
+
} catch (e) {
|
|
1132
|
+
const j = this.payload?.beads.findIndex((b) => b.id === m.id) ?? -1;
|
|
1133
|
+
if (j >= 0) this.payload!.beads.splice(j, 1, original);
|
|
1134
|
+
const err = e as Error & { cause?: unknown };
|
|
1135
|
+
if (typeof err.cause === "string") console.info(`beadcyte: failed: ${err.cause}`);
|
|
1136
|
+
this.notice = { kind: "error", text: `${m.id}: ${err.message}` };
|
|
1137
|
+
} finally {
|
|
1138
|
+
this.pending = this.pending.filter((id) => id !== m.id);
|
|
1139
|
+
setTimeout(() => {
|
|
1140
|
+
if (this.notice) this.notice = null;
|
|
1141
|
+
}, 6000);
|
|
1142
|
+
void this.fetch();
|
|
1143
|
+
}
|
|
1144
|
+
},
|
|
1145
|
+
|
|
1146
|
+
closeWalkthrough() {
|
|
1147
|
+
this.walkthroughOpen = false;
|
|
1148
|
+
},
|
|
1149
|
+
|
|
1150
|
+
closeChangelog() {
|
|
1151
|
+
this.changelogOpen = false;
|
|
1152
|
+
},
|
|
1153
|
+
|
|
1154
|
+
requestSearch() {
|
|
1155
|
+
this.searchRequests++;
|
|
1156
|
+
},
|
|
1157
|
+
|
|
1158
|
+
requestOptions() {
|
|
1159
|
+
this.optionsRequests++;
|
|
1160
|
+
},
|
|
1161
|
+
|
|
1162
|
+
requestToday() {
|
|
1163
|
+
this.todayRequests++;
|
|
1164
|
+
},
|
|
1165
|
+
|
|
1166
|
+
setDrawerSubgraphScope(scope: "bead" | "epic") {
|
|
1167
|
+
this.drawerSubgraphScope = scope;
|
|
1168
|
+
},
|
|
1169
|
+
|
|
1170
|
+
toggleDrawerSubgraphScope() {
|
|
1171
|
+
this.drawerSubgraphScope = this.drawerSubgraphScope === "bead" ? "epic" : "bead";
|
|
1172
|
+
},
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* Add or remove one status from the status selection. An empty or null
|
|
1176
|
+
* selection means every status, so it is materialised from the statuses
|
|
1177
|
+
* present before the toggle, otherwise removing "closed" from "all"
|
|
1178
|
+
* would have nothing to remove it from.
|
|
1179
|
+
*/
|
|
1180
|
+
toggleStatusInFilter(status: string) {
|
|
1181
|
+
const cur =
|
|
1182
|
+
this.filters.statuses && this.filters.statuses.length ? this.filters.statuses : this.presentStatuses;
|
|
1183
|
+
const next = cur.includes(status) ? cur.filter((x) => x !== status) : [...cur, status];
|
|
1184
|
+
this.setFilters({ statuses: next });
|
|
1185
|
+
},
|
|
1186
|
+
|
|
1187
|
+
toggleExpandGroup(gid: string) {
|
|
1188
|
+
this.expandedGroups = {
|
|
1189
|
+
...this.expandedGroups,
|
|
1190
|
+
[gid]: !this.expandedGroups[gid],
|
|
1191
|
+
};
|
|
1192
|
+
},
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* Seed the status filter from the data, once.
|
|
1196
|
+
*
|
|
1197
|
+
* Called when a payload lands. Does nothing if a selection already
|
|
1198
|
+
* exists — an explicit `?status=` from the URL, or the user's own choice
|
|
1199
|
+
* surviving a refresh — so this only ever supplies the first-paint
|
|
1200
|
+
* default.
|
|
1201
|
+
*
|
|
1202
|
+
* `pendingLegacyStatuses` carries the intent of a pre-bp-67g.40 link:
|
|
1203
|
+
* `?showClosed=true` has to end up selecting `closed` alongside the
|
|
1204
|
+
* default set, and it cannot be honoured at URL-read time because the
|
|
1205
|
+
* rest of that set is not known until the beads arrive.
|
|
1206
|
+
*/
|
|
1207
|
+
initStatusSelection() {
|
|
1208
|
+
if (this.filters.statuses !== null) return;
|
|
1209
|
+
const present: string[] = [];
|
|
1210
|
+
for (const b of this.beads) {
|
|
1211
|
+
if (b.issue_type !== "epic") present.push(b.status);
|
|
1212
|
+
}
|
|
1213
|
+
const seeded = defaultStatusSelection(present, this.pendingLegacyStatuses ?? []);
|
|
1214
|
+
if (seeded === null) return; // nothing to seed from yet
|
|
1215
|
+
this.pendingLegacyStatuses = null;
|
|
1216
|
+
this.filters = { ...this.filters, statuses: seeded };
|
|
1217
|
+
},
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* Record which normally-hidden statuses a legacy URL asked for, to be
|
|
1221
|
+
* applied by `initStatusSelection` once the data is known.
|
|
1222
|
+
*/
|
|
1223
|
+
setPendingLegacyStatuses(statuses: string[]) {
|
|
1224
|
+
this.pendingLegacyStatuses = statuses.length ? statuses : null;
|
|
1225
|
+
},
|
|
1226
|
+
|
|
1227
|
+
setCurrentUser(handle: string | null) {
|
|
1228
|
+
this.currentUser = handle;
|
|
1229
|
+
// An explicit choice supersedes whatever the link carried in.
|
|
1230
|
+
this.urlUser = null;
|
|
1231
|
+
try {
|
|
1232
|
+
if (handle) localStorage.setItem(CURRENT_USER_STORAGE_KEY, handle);
|
|
1233
|
+
else localStorage.removeItem(CURRENT_USER_STORAGE_KEY);
|
|
1234
|
+
} catch {
|
|
1235
|
+
/* private mode — holds for this session only */
|
|
1236
|
+
}
|
|
1237
|
+
},
|
|
1238
|
+
|
|
1239
|
+
setWho(handle: string | null) {
|
|
1240
|
+
this.who = handle;
|
|
1241
|
+
},
|
|
1242
|
+
|
|
1243
|
+
// ── what-if (bp-q56) ─────────────────────────────────────────────────
|
|
1244
|
+
addWhatIf(c: WhatIfChange) {
|
|
1245
|
+
this.whatIfReport = null; // a new change supersedes the last confirm's report
|
|
1246
|
+
this.whatIf = upsertChange(this.whatIf, c);
|
|
1247
|
+
this.whatIfPanelOpen = true;
|
|
1248
|
+
},
|
|
1249
|
+
|
|
1250
|
+
removeWhatIf(index: number) {
|
|
1251
|
+
this.whatIf = this.whatIf.filter((_, i) => i !== index);
|
|
1252
|
+
},
|
|
1253
|
+
|
|
1254
|
+
discardWhatIf() {
|
|
1255
|
+
this.whatIf = [];
|
|
1256
|
+
this.whatIfPanelOpen = false;
|
|
1257
|
+
},
|
|
1258
|
+
|
|
1259
|
+
openWhatIfPanel() {
|
|
1260
|
+
this.whatIfPanelOpen = true;
|
|
1261
|
+
},
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* Close the panel. The last confirm's report goes with it: the panel
|
|
1265
|
+
* stays mounted while a report exists, so a close that left the report
|
|
1266
|
+
* behind left the panel behind too (owner, 2026-09-05). Staged changes
|
|
1267
|
+
* stay — closing is not discarding.
|
|
1268
|
+
*/
|
|
1269
|
+
closeWhatIfPanel() {
|
|
1270
|
+
this.whatIfPanelOpen = false;
|
|
1271
|
+
this.whatIfReport = null;
|
|
1272
|
+
},
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* Confirm (bp-2fq): the change set becomes bd mutations, run one after
|
|
1276
|
+
* another through /api/mutate — each is the same whitelisted write the
|
|
1277
|
+
* quick actions use, so nothing new can be written from here. Every
|
|
1278
|
+
* change gets a line in the report; a failure stops the batch there and
|
|
1279
|
+
* leaves the rest staged for another try. Caps and unassigns are
|
|
1280
|
+
* reported as not applied, with what to do by hand.
|
|
1281
|
+
*/
|
|
1282
|
+
async confirmWhatIf() {
|
|
1283
|
+
if (this.whatIfApplying || this.whatIf.length === 0) return;
|
|
1284
|
+
this.whatIfApplying = true;
|
|
1285
|
+
const titleOf = (id: string) => this.beadById(id)?.title;
|
|
1286
|
+
const report: Array<{ text: string; ok: boolean; detail?: string }> = [];
|
|
1287
|
+
const remaining: WhatIfChange[] = [];
|
|
1288
|
+
const project = this.activeProject?.path ?? null;
|
|
1289
|
+
const { skipped } = mutationsFor(this.whatIf);
|
|
1290
|
+
for (const c of skipped) {
|
|
1291
|
+
report.push({
|
|
1292
|
+
text: describeChange(c, titleOf),
|
|
1293
|
+
ok: false,
|
|
1294
|
+
detail: c.kind === "cap" ? "not a bd fact: set it in .beadcyte/roster.json" : "bd has no unassign here: bd update <id> --assignee \"\"",
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
let stopped = false;
|
|
1298
|
+
for (const c of this.whatIf) {
|
|
1299
|
+
if (skipped.includes(c)) continue;
|
|
1300
|
+
if (stopped) {
|
|
1301
|
+
remaining.push(c);
|
|
1302
|
+
continue;
|
|
1303
|
+
}
|
|
1304
|
+
const m = mutationsFor([c]).mutations[0]!;
|
|
1305
|
+
try {
|
|
1306
|
+
const res = await fetch("/api/mutate", {
|
|
1307
|
+
method: "POST",
|
|
1308
|
+
headers: { "Content-Type": "application/json" },
|
|
1309
|
+
body: JSON.stringify({ ...m, project }),
|
|
1310
|
+
});
|
|
1311
|
+
const body = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string; bead?: Bead | null };
|
|
1312
|
+
if (res.status === 404) throw new Error("this server predates quick actions — restart it");
|
|
1313
|
+
if (!res.ok || !body.ok) throw new Error(body.error || `HTTP ${res.status}`);
|
|
1314
|
+
report.push({ text: describeChange(c, titleOf), ok: true, detail: body.bead?.id && c.kind === "idea" ? `created ${body.bead.id}` : undefined });
|
|
1315
|
+
} catch (e) {
|
|
1316
|
+
report.push({ text: describeChange(c, titleOf), ok: false, detail: String((e as Error).message ?? e) });
|
|
1317
|
+
remaining.push(c);
|
|
1318
|
+
stopped = true;
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
this.whatIfReport = report;
|
|
1322
|
+
this.whatIf = remaining;
|
|
1323
|
+
this.whatIfApplying = false;
|
|
1324
|
+
const okCount = report.filter((r) => r.ok).length;
|
|
1325
|
+
this.notice = { kind: stopped ? "error" : "ok", text: `what-if: ${okCount} change${okCount === 1 ? "" : "s"} written${stopped ? ", stopped at a failure" : ""}${skipped.length ? `, ${skipped.length} not applicable` : ""}` };
|
|
1326
|
+
setTimeout(() => {
|
|
1327
|
+
if (this.notice) this.notice = null;
|
|
1328
|
+
}, 6000);
|
|
1329
|
+
void this.fetch();
|
|
1330
|
+
},
|
|
1331
|
+
|
|
1332
|
+
/** Any assignee handle in the app leads here: incytes, scoped to that person. */
|
|
1333
|
+
openPerson(handle: string) {
|
|
1334
|
+
this.who = handle;
|
|
1335
|
+
this.view = "incytes";
|
|
1336
|
+
},
|
|
1337
|
+
|
|
1338
|
+
setUrlUser(handle: string | null) {
|
|
1339
|
+
this.urlUser = handle;
|
|
1340
|
+
},
|
|
1341
|
+
|
|
1342
|
+
/** Every filter back to its default; the status seed is recomputed from the data. */
|
|
1343
|
+
resetFilters() {
|
|
1344
|
+
const seeded = defaultStatusSelection(this.presentStatuses, []);
|
|
1345
|
+
this.filters = { ...DEFAULT_FILTERS, statuses: seeded };
|
|
1346
|
+
},
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* MINE shortcut (bp-egy). Replaces the whole filter state with the
|
|
1350
|
+
* shortcut's; clicking the active one clears back to the defaults.
|
|
1351
|
+
*/
|
|
1352
|
+
applyShortcut(kind: Shortcut) {
|
|
1353
|
+
const me = this.effectiveUser;
|
|
1354
|
+
if (!me) return;
|
|
1355
|
+
if (this.activeShortcut === kind) {
|
|
1356
|
+
this.resetFilters();
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
this.filters = shortcutFilters(kind, me, this.presentStatuses);
|
|
1360
|
+
},
|
|
1361
|
+
|
|
1362
|
+
setFilters(patch: Partial<Filters>) {
|
|
1363
|
+
this.filters = { ...this.filters, ...patch };
|
|
1364
|
+
},
|
|
1365
|
+
|
|
1366
|
+
setWindowFromPreset(preset: WindowPreset) {
|
|
1367
|
+
this.window = windowFromPreset(preset);
|
|
1368
|
+
},
|
|
1369
|
+
|
|
1370
|
+
setPriorityFilter(priorities: number[] | null) {
|
|
1371
|
+
this.filters = { ...this.filters, priorities };
|
|
1372
|
+
},
|
|
1373
|
+
|
|
1374
|
+
setWindow(w: WindowSpec) {
|
|
1375
|
+
this.window = w;
|
|
1376
|
+
},
|
|
1377
|
+
},
|
|
1378
|
+
});
|