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,476 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// ControlsPanel.vue — group-by radio + filter popovers + window presets.
|
|
3
|
+
// Every change mutates the Pinia store; the reactive view model recomputes
|
|
4
|
+
// through Vue's computed-caching (bp-092), so re-render is instant with no
|
|
5
|
+
// refetch. Filter option lists are enumerated live from the current beads
|
|
6
|
+
// so there are no dead options.
|
|
7
|
+
|
|
8
|
+
import { computed } from "vue";
|
|
9
|
+
import {
|
|
10
|
+
useBeadsStore,
|
|
11
|
+
WINDOW_PRESETS,
|
|
12
|
+
HIGHLIGHT_KINDS,
|
|
13
|
+
type WindowPreset,
|
|
14
|
+
type HighlightKind,
|
|
15
|
+
windowFromPreset,
|
|
16
|
+
} from "../store";
|
|
17
|
+
import type { GroupBy } from "../view-model";
|
|
18
|
+
import { assigneeKey, UNASSIGNED } from "../view-model";
|
|
19
|
+
import type { ViewMode } from "../composables/url-codec";
|
|
20
|
+
import FilterPopover from "./FilterPopover.vue";
|
|
21
|
+
import type { FilterOption } from "./filter-option";
|
|
22
|
+
import { SHORTCUTS } from "../shortcuts";
|
|
23
|
+
import { controlApplies, scopeNote, type ControlId } from "../controls-scope";
|
|
24
|
+
|
|
25
|
+
const HIGHLIGHT_LABELS: Record<HighlightKind, string> = {
|
|
26
|
+
blocked: "blocked",
|
|
27
|
+
"over-cap": "over-cap",
|
|
28
|
+
"ready-now": "ready-now",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const store = useBeadsStore();
|
|
32
|
+
|
|
33
|
+
// ── what the active view listens to (bp-67g.36) ──────────────────────────
|
|
34
|
+
// A group the view cannot act on is dimmed and its buttons disabled, and one
|
|
35
|
+
// note at the end of the panel says where they do work. The matrix lives in
|
|
36
|
+
// controls-scope.ts; nothing here decides applicability.
|
|
37
|
+
const inert = (c: ControlId) => !controlApplies(store.view, c);
|
|
38
|
+
const inertTitle = (c: ControlId) =>
|
|
39
|
+
inert(c) ? `Not used by the ${store.view} view` : undefined;
|
|
40
|
+
const note = computed(() => scopeNote(store.view));
|
|
41
|
+
|
|
42
|
+
const GROUP_MODES: Array<{ id: GroupBy; label: string }> = [
|
|
43
|
+
{ id: "epic", label: "epic" },
|
|
44
|
+
{ id: "assignee", label: "assignee" },
|
|
45
|
+
{ id: "seat", label: "seat" },
|
|
46
|
+
{ id: "track", label: "track" },
|
|
47
|
+
{ id: "status", label: "status" },
|
|
48
|
+
{ id: "priority", label: "priority" },
|
|
49
|
+
{ id: "none", label: "none" },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const VIEW_MODES: Array<{ id: ViewMode; label: string }> = [
|
|
53
|
+
{ id: "mine", label: "mine" },
|
|
54
|
+
{ id: "gantt", label: "gantt" },
|
|
55
|
+
{ id: "grid", label: "grid" },
|
|
56
|
+
{ id: "table", label: "table" },
|
|
57
|
+
{ id: "incytes", label: "incytes" },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
// ── option lists (enumerated from live data) ──────────────────────────────
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Is this bead's status in view?
|
|
64
|
+
*
|
|
65
|
+
* Every count below is a promise about what selecting the option will show,
|
|
66
|
+
* so they all have to filter by the same rule. Before bp-67g.40 only the
|
|
67
|
+
* assignee counter consulted status at all, and it consulted the two toggles
|
|
68
|
+
* rather than the status filter — so type, priority and label hints
|
|
69
|
+
* cheerfully counted hidden closed beads while assignee did not.
|
|
70
|
+
*
|
|
71
|
+
* The status counter itself deliberately does NOT use this: its whole job is
|
|
72
|
+
* to tell you how many beads a status you have not selected would bring in.
|
|
73
|
+
*/
|
|
74
|
+
function inStatusSelection(b: { status: string }): boolean {
|
|
75
|
+
const sel = store.filters.statuses;
|
|
76
|
+
return !sel || sel.length === 0 || sel.includes(b.status);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const typeOptions = computed<FilterOption<string>[]>(() => {
|
|
80
|
+
const seen = new Map<string, number>();
|
|
81
|
+
for (const b of store.beads) {
|
|
82
|
+
if (b.issue_type === "epic") continue;
|
|
83
|
+
if (!inStatusSelection(b)) continue;
|
|
84
|
+
seen.set(b.issue_type, (seen.get(b.issue_type) ?? 0) + 1);
|
|
85
|
+
}
|
|
86
|
+
return [...seen.entries()]
|
|
87
|
+
.sort((a, b) => b[1] - a[1])
|
|
88
|
+
.map(([value, count]) => ({ value, label: value, hint: `${count}` }));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const priorityOptions = computed<FilterOption<number>[]>(() => {
|
|
92
|
+
const seen = new Map<number, number>();
|
|
93
|
+
for (const b of store.beads) {
|
|
94
|
+
if (b.issue_type === "epic") continue;
|
|
95
|
+
if (!inStatusSelection(b)) continue;
|
|
96
|
+
const p = b.priority ?? 4;
|
|
97
|
+
seen.set(p, (seen.get(p) ?? 0) + 1);
|
|
98
|
+
}
|
|
99
|
+
return [0, 1, 2, 3, 4].map((p) => ({
|
|
100
|
+
value: p,
|
|
101
|
+
label: `P${p}`,
|
|
102
|
+
hint: `${seen.get(p) ?? 0}`,
|
|
103
|
+
}));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const statusOptions = computed<FilterOption<string>[]>(() => {
|
|
107
|
+
const seen = new Map<string, number>();
|
|
108
|
+
for (const b of store.beads) {
|
|
109
|
+
if (b.issue_type === "epic") continue;
|
|
110
|
+
seen.set(b.status, (seen.get(b.status) ?? 0) + 1);
|
|
111
|
+
}
|
|
112
|
+
return [...seen.entries()]
|
|
113
|
+
.sort((a, b) => b[1] - a[1])
|
|
114
|
+
.map(([value, count]) => ({
|
|
115
|
+
value,
|
|
116
|
+
label: value.replace(/_/g, " "),
|
|
117
|
+
hint: `${count}`,
|
|
118
|
+
}));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const labelOptions = computed<FilterOption<string>[]>(() => {
|
|
122
|
+
const seen = new Map<string, number>();
|
|
123
|
+
for (const b of store.beads) {
|
|
124
|
+
if (b.issue_type === "epic") continue;
|
|
125
|
+
if (!inStatusSelection(b)) continue;
|
|
126
|
+
for (const l of b.labels ?? []) seen.set(l, (seen.get(l) ?? 0) + 1);
|
|
127
|
+
}
|
|
128
|
+
return [...seen.entries()]
|
|
129
|
+
.sort((a, b) => b[1] - a[1])
|
|
130
|
+
.slice(0, 40) // cap the popover; wide-tail labels rarely help filtering
|
|
131
|
+
.map(([value, count]) => ({ value, label: value, hint: `${count}` }));
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const assigneeOptions = computed<FilterOption<string>[]>(() => {
|
|
135
|
+
// Resolved through the scheduler, exactly as grouping and the filter do —
|
|
136
|
+
// reading b.assignee here would list handles that then filter to nothing
|
|
137
|
+
// and pile everyone's real work under "(unassigned)".
|
|
138
|
+
const schedule = store.scheduleWithEstimates?.schedule;
|
|
139
|
+
const seen = new Map<string, number>();
|
|
140
|
+
for (const b of store.beads) {
|
|
141
|
+
if (b.issue_type === "epic") continue;
|
|
142
|
+
if (!inStatusSelection(b)) continue;
|
|
143
|
+
const a = assigneeKey(b, schedule?.get(b.id));
|
|
144
|
+
seen.set(a, (seen.get(a) ?? 0) + 1);
|
|
145
|
+
}
|
|
146
|
+
return [...seen.entries()]
|
|
147
|
+
.sort((a, b) => b[1] - a[1])
|
|
148
|
+
.map(([value, count]) => ({
|
|
149
|
+
value,
|
|
150
|
+
label: value === UNASSIGNED ? "(unassigned)" : value,
|
|
151
|
+
hint: `${count}`,
|
|
152
|
+
}));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ── two-way bindings that route through the store ────────────────────────
|
|
156
|
+
|
|
157
|
+
const types = computed({
|
|
158
|
+
get: () => store.filters.types ?? null,
|
|
159
|
+
set: (v) => store.setFilters({ types: v }),
|
|
160
|
+
});
|
|
161
|
+
const priorities = computed({
|
|
162
|
+
get: () => store.filters.priorities ?? null,
|
|
163
|
+
set: (v) => store.setFilters({ priorities: v }),
|
|
164
|
+
});
|
|
165
|
+
const statuses = computed({
|
|
166
|
+
get: () => store.filters.statuses ?? null,
|
|
167
|
+
set: (v) => store.setFilters({ statuses: v }),
|
|
168
|
+
});
|
|
169
|
+
const labels = computed({
|
|
170
|
+
get: () => store.filters.labels ?? null,
|
|
171
|
+
set: (v) => store.setFilters({ labels: v }),
|
|
172
|
+
});
|
|
173
|
+
const assignees = computed({
|
|
174
|
+
get: () => store.filters.assignees ?? null,
|
|
175
|
+
set: (v) => store.setFilters({ assignees: v }),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const activePreset = computed<WindowPreset | null>(() => {
|
|
179
|
+
// Best-effort match: if `window` matches a preset's from/to, mark it active.
|
|
180
|
+
//
|
|
181
|
+
// Uses the store's windowFromPreset rather than a local mirror of it. There
|
|
182
|
+
// WAS a mirror here, defended on the grounds that template code should not
|
|
183
|
+
// see every branch of the store's factory — and it silently stopped
|
|
184
|
+
// matching the moment a seventh preset was added to one switch and not the
|
|
185
|
+
// other, leaving the new preset permanently un-highlighted.
|
|
186
|
+
const w = store.window;
|
|
187
|
+
for (const p of WINDOW_PRESETS) {
|
|
188
|
+
const cand = p.id;
|
|
189
|
+
const other = windowFromPreset(cand);
|
|
190
|
+
if (sameDate(w.from, other.from) && sameDate(w.to, other.to)) return cand;
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
function sameDate(a: Date | null, b: Date | null): boolean {
|
|
196
|
+
if (a === null && b === null) return true;
|
|
197
|
+
if (a === null || b === null) return false;
|
|
198
|
+
return Math.abs(a.getTime() - b.getTime()) < 60_000;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
</script>
|
|
202
|
+
|
|
203
|
+
<template>
|
|
204
|
+
<section class="controls-panel">
|
|
205
|
+
<div class="control-group">
|
|
206
|
+
<label class="group-label">View</label>
|
|
207
|
+
<div class="segmented">
|
|
208
|
+
<button
|
|
209
|
+
v-for="v in VIEW_MODES"
|
|
210
|
+
:key="v.id"
|
|
211
|
+
type="button"
|
|
212
|
+
:class="{ active: store.view === v.id }"
|
|
213
|
+
@click="store.setView(v.id)"
|
|
214
|
+
>{{ v.label }}</button>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<div class="control-group" :class="{ 'is-inert': inert('groupBy') }" :title="inertTitle('groupBy')">
|
|
219
|
+
<label class="group-label">Group by</label>
|
|
220
|
+
<div class="segmented">
|
|
221
|
+
<button
|
|
222
|
+
v-for="g in GROUP_MODES"
|
|
223
|
+
:key="g.id"
|
|
224
|
+
type="button"
|
|
225
|
+
:class="{ active: store.groupBy === g.id }"
|
|
226
|
+
:disabled="inert('groupBy')"
|
|
227
|
+
@click="store.setGroupBy(g.id)"
|
|
228
|
+
>{{ g.label }}</button>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
<div v-if="store.watched.length" class="control-group" :class="{ 'is-inert': inert('watch') }" :title="inertTitle('watch')">
|
|
233
|
+
<label class="group-label">Watch</label>
|
|
234
|
+
<div class="segmented">
|
|
235
|
+
<button
|
|
236
|
+
type="button"
|
|
237
|
+
:class="{ active: store.watchedOnly }"
|
|
238
|
+
:aria-pressed="store.watchedOnly"
|
|
239
|
+
:disabled="inert('watch')"
|
|
240
|
+
title="Show only watched beads, and the children of watched epics"
|
|
241
|
+
@click="store.toggleWatchedOnly()"
|
|
242
|
+
>watched only</button>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<!-- MINE: one click to the two views that matter during a working day
|
|
247
|
+
(bp-egy). Each button REPLACES the filter state with its own, and
|
|
248
|
+
the active mark is derived from the filters, so a hand edit drops
|
|
249
|
+
it. Disabled until a current user is chosen in the options menu. -->
|
|
250
|
+
<div class="control-group" :class="{ 'is-inert': inert('mine') }" :title="inertTitle('mine')">
|
|
251
|
+
<label class="group-label">Mine</label>
|
|
252
|
+
<div class="segmented">
|
|
253
|
+
<button
|
|
254
|
+
v-for="s in SHORTCUTS"
|
|
255
|
+
:key="s.id"
|
|
256
|
+
type="button"
|
|
257
|
+
:class="{ active: store.activeShortcut === s.id }"
|
|
258
|
+
:aria-pressed="store.activeShortcut === s.id"
|
|
259
|
+
:disabled="!store.effectiveUser || inert('mine')"
|
|
260
|
+
:title="store.effectiveUser ? s.title : 'Set your current user in the options menu (gear, top right) first'"
|
|
261
|
+
@click="store.applyShortcut(s.id)"
|
|
262
|
+
>{{ s.label }}</button>
|
|
263
|
+
</div>
|
|
264
|
+
<span v-if="!store.effectiveUser && !inert('mine')" class="mine-hint">set your user in options (gear, top right)</span>
|
|
265
|
+
<span v-else-if="store.effectiveUser && store.userIsStale" class="mine-hint">{{ store.effectiveUser }} is not an assignee here — pick again in options</span>
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<div class="control-group" :class="{ 'is-inert': inert('filters') }" :title="inertTitle('filters')">
|
|
269
|
+
<label class="group-label">Filters</label>
|
|
270
|
+
<FilterPopover
|
|
271
|
+
label="type"
|
|
272
|
+
:options="typeOptions"
|
|
273
|
+
:disabled="inert('filters')"
|
|
274
|
+
v-model="types"
|
|
275
|
+
/>
|
|
276
|
+
<FilterPopover
|
|
277
|
+
label="priority"
|
|
278
|
+
:options="priorityOptions"
|
|
279
|
+
:disabled="inert('filters')"
|
|
280
|
+
v-model="priorities"
|
|
281
|
+
/>
|
|
282
|
+
<FilterPopover
|
|
283
|
+
label="status"
|
|
284
|
+
:options="statusOptions"
|
|
285
|
+
:disabled="inert('filters')"
|
|
286
|
+
v-model="statuses"
|
|
287
|
+
/>
|
|
288
|
+
<FilterPopover
|
|
289
|
+
label="label"
|
|
290
|
+
:options="labelOptions"
|
|
291
|
+
:disabled="inert('filters')"
|
|
292
|
+
v-model="labels"
|
|
293
|
+
/>
|
|
294
|
+
<FilterPopover
|
|
295
|
+
label="assignee"
|
|
296
|
+
:options="assigneeOptions"
|
|
297
|
+
:disabled="inert('filters')"
|
|
298
|
+
v-model="assignees"
|
|
299
|
+
/>
|
|
300
|
+
<!-- The one filter term the popovers cannot express: it looks at other
|
|
301
|
+
beads. Shown here so a shortcut that sets it stays legible. -->
|
|
302
|
+
<div class="segmented">
|
|
303
|
+
<button
|
|
304
|
+
type="button"
|
|
305
|
+
:class="{ active: !!store.filters.unblocked }"
|
|
306
|
+
:aria-pressed="!!store.filters.unblocked"
|
|
307
|
+
:disabled="inert('filters')"
|
|
308
|
+
title="Only beads whose every blocks-dependency is closed"
|
|
309
|
+
@click="store.setFilters({ unblocked: store.filters.unblocked ? null : true })"
|
|
310
|
+
>unblocked</button>
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
|
|
314
|
+
<div class="control-group" :class="{ 'is-inert': inert('window') }" :title="inertTitle('window')">
|
|
315
|
+
<label class="group-label">Window</label>
|
|
316
|
+
<div class="segmented">
|
|
317
|
+
<button
|
|
318
|
+
v-for="p in WINDOW_PRESETS"
|
|
319
|
+
:key="p.id"
|
|
320
|
+
type="button"
|
|
321
|
+
:class="{ active: activePreset === p.id }"
|
|
322
|
+
:disabled="inert('window')"
|
|
323
|
+
@click="store.setWindowFromPreset(p.id)"
|
|
324
|
+
>{{ p.label }}</button>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
|
|
328
|
+
<div class="control-group" :class="{ 'is-inert': inert('highlight') }" :title="inertTitle('highlight')">
|
|
329
|
+
<label class="group-label">Highlight</label>
|
|
330
|
+
<div class="segmented">
|
|
331
|
+
<button
|
|
332
|
+
v-for="k in HIGHLIGHT_KINDS"
|
|
333
|
+
:key="k"
|
|
334
|
+
type="button"
|
|
335
|
+
:class="{ active: store.highlights[k] }"
|
|
336
|
+
:disabled="inert('highlight')"
|
|
337
|
+
@click="store.toggleHighlight(k)"
|
|
338
|
+
>{{ HIGHLIGHT_LABELS[k] }}</button>
|
|
339
|
+
<!-- Arrows are the Gantt's alone, so these two can be inert while the
|
|
340
|
+
highlights beside them are live (the grid). -->
|
|
341
|
+
<button
|
|
342
|
+
type="button"
|
|
343
|
+
:class="{ active: store.showDependencies }"
|
|
344
|
+
:disabled="inert('deps')"
|
|
345
|
+
:title="inert('deps') ? 'Dependency arrows are drawn on the gantt only' : 'Draw dependency arrows around the hovered bead (green = it blocks these, red = these block it)'"
|
|
346
|
+
@click="store.toggleShowDependencies()"
|
|
347
|
+
>deps</button>
|
|
348
|
+
<button
|
|
349
|
+
type="button"
|
|
350
|
+
:disabled="inert('deps') || !store.showDependencies"
|
|
351
|
+
:class="{ active: store.showAllDependencies }"
|
|
352
|
+
title="Also draw arrows for every visible edge, not just the hovered bead"
|
|
353
|
+
@click="store.toggleShowAllDependencies()"
|
|
354
|
+
>all deps</button>
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
|
|
358
|
+
<!-- What-if (bp-q56): the change set's size, and the way to its panel. -->
|
|
359
|
+
<div class="control-group" :class="{ 'is-inert': inert('whatif') }" :title="inertTitle('whatif')">
|
|
360
|
+
<label class="group-label">What if</label>
|
|
361
|
+
<div class="segmented">
|
|
362
|
+
<button
|
|
363
|
+
type="button"
|
|
364
|
+
:class="{ active: store.whatIfActive }"
|
|
365
|
+
:aria-pressed="store.whatIfActive"
|
|
366
|
+
:disabled="inert('whatif')"
|
|
367
|
+
title="Stage changes and see the plan as it would be, without writing to bd"
|
|
368
|
+
@click="store.openWhatIfPanel()"
|
|
369
|
+
>{{ store.whatIfActive ? `${store.whatIf.length} staged` : "plan…" }}</button>
|
|
370
|
+
<button v-if="store.whatIfActive" type="button" :disabled="inert('whatif')" title="Drop every staged change" @click="store.discardWhatIf()">discard</button>
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
|
|
374
|
+
<span v-if="note" class="scope-note">{{ note }}</span>
|
|
375
|
+
|
|
376
|
+
<!-- The "Show closed" / "Include deferred" toggles used to live here.
|
|
377
|
+
They duplicated the STATUS filter and were applied ahead of it, so
|
|
378
|
+
asking for a status a toggle hid returned an empty view with no
|
|
379
|
+
explanation (bp-67g.40). The status filter is now the only control
|
|
380
|
+
over status, and its first-paint value says so out loud. -->
|
|
381
|
+
</section>
|
|
382
|
+
</template>
|
|
383
|
+
|
|
384
|
+
<style>
|
|
385
|
+
.controls-panel {
|
|
386
|
+
display: flex;
|
|
387
|
+
gap: 20px;
|
|
388
|
+
flex-wrap: wrap;
|
|
389
|
+
align-items: center;
|
|
390
|
+
padding: 10px 20px;
|
|
391
|
+
border-bottom: 1px solid var(--c-border);
|
|
392
|
+
background: var(--c-panel);
|
|
393
|
+
--border: var(--c-border);
|
|
394
|
+
--panel: var(--c-panel);
|
|
395
|
+
--text: var(--c-text);
|
|
396
|
+
--dim: var(--c-dim);
|
|
397
|
+
--accent: var(--c-accent);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
.controls-panel .control-group {
|
|
401
|
+
display: flex;
|
|
402
|
+
align-items: center;
|
|
403
|
+
gap: 6px;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
.controls-panel .group-label {
|
|
407
|
+
font-size: 10px;
|
|
408
|
+
/* Explicit: this used to arrive by accident, leaked from BeadsGrid's
|
|
409
|
+
unscoped .group-label rule. */
|
|
410
|
+
font-weight: 600;
|
|
411
|
+
text-transform: uppercase;
|
|
412
|
+
letter-spacing: 0.05em;
|
|
413
|
+
color: var(--c-dim);
|
|
414
|
+
margin-right: 2px;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
.controls-panel .segmented {
|
|
418
|
+
display: inline-flex;
|
|
419
|
+
border: 1px solid var(--c-border);
|
|
420
|
+
border-radius: 3px;
|
|
421
|
+
overflow: hidden;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
.controls-panel .segmented button {
|
|
425
|
+
background: transparent;
|
|
426
|
+
color: var(--c-dim);
|
|
427
|
+
border: none;
|
|
428
|
+
padding: 4px 10px;
|
|
429
|
+
font-size: 11px;
|
|
430
|
+
cursor: pointer;
|
|
431
|
+
border-right: 1px solid var(--c-border);
|
|
432
|
+
font-family: inherit;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
.controls-panel .segmented button:last-child {
|
|
436
|
+
border-right: none;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
.controls-panel .segmented button:hover:not(.active) {
|
|
440
|
+
color: var(--c-text);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
.controls-panel .segmented button.active {
|
|
444
|
+
background: var(--c-accent);
|
|
445
|
+
color: var(--c-bg);
|
|
446
|
+
font-weight: 600;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
.controls-panel .segmented button:disabled {
|
|
450
|
+
opacity: 0.4;
|
|
451
|
+
cursor: not-allowed;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
.controls-panel .mine-hint {
|
|
455
|
+
font-size: 11px;
|
|
456
|
+
color: var(--c-dim);
|
|
457
|
+
margin-left: 8px;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/* A group the active view cannot act on (bp-67g.36): still there, so the
|
|
461
|
+
panel keeps its shape between views, but plainly not live. The buttons
|
|
462
|
+
carry disabled as well, so the pointer says so too. */
|
|
463
|
+
.controls-panel .control-group.is-inert {
|
|
464
|
+
opacity: 0.45;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
.controls-panel .control-group.is-inert .segmented button:disabled {
|
|
468
|
+
opacity: 1; /* the group is already dimmed; do not double it */
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
.controls-panel .scope-note {
|
|
472
|
+
font-size: 11px;
|
|
473
|
+
color: var(--c-dim);
|
|
474
|
+
font-style: italic;
|
|
475
|
+
}
|
|
476
|
+
</style>
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// CostTrend.vue — seat hours per week, measured behind today and projected
|
|
3
|
+
// ahead, with tokens in/out in the figures and the table (bp-0z5).
|
|
4
|
+
//
|
|
5
|
+
// Sits beside Ships per week and shares its weekly buckets. Measured weeks
|
|
6
|
+
// are solid stacks by seat; projected weeks continue the axis past a rule at
|
|
7
|
+
// today, drawn dulled and dashed like every projection in the app, and the
|
|
8
|
+
// head says what the projection is made of — medians of comparable shipped
|
|
9
|
+
// work, with the sample size — so nothing projected can be read as measured.
|
|
10
|
+
// A hand-written SVG: stacked bars over a dozen or so buckets need no
|
|
11
|
+
// scaler library, and the table beneath carries every value anyway.
|
|
12
|
+
|
|
13
|
+
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
|
14
|
+
import { weeklyAverage, fmtTokens, fmtHours, type CostSeries } from "../cost";
|
|
15
|
+
|
|
16
|
+
const props = defineProps<{ series: CostSeries; who?: string | null }>();
|
|
17
|
+
|
|
18
|
+
const weeks = computed(() => [...props.series.past, ...props.series.projected]);
|
|
19
|
+
const measuredAvg = computed(() => weeklyAverage(props.series.past));
|
|
20
|
+
const projectedAvg = computed(() => weeklyAverage(props.series.projected));
|
|
21
|
+
const hasMeasured = computed(() => props.series.past.some((w) => w.beads > 0));
|
|
22
|
+
const hasProjected = computed(() => props.series.projected.some((w) => w.beads > 0));
|
|
23
|
+
const peak = computed(() => Math.max(1, ...weeks.value.map((w) => w.hours)));
|
|
24
|
+
|
|
25
|
+
// Geometry in pixels: the svg is as wide as its host (measured, like
|
|
26
|
+
// ShipTrend) and a fixed height, so text stays text-sized at any width.
|
|
27
|
+
const hostEl = ref<HTMLElement | null>(null);
|
|
28
|
+
const W = ref(600);
|
|
29
|
+
let ro: ResizeObserver | null = null;
|
|
30
|
+
onMounted(() => {
|
|
31
|
+
if (!hostEl.value) return;
|
|
32
|
+
const measure = () => {
|
|
33
|
+
if (hostEl.value) W.value = Math.max(200, hostEl.value.getBoundingClientRect().width);
|
|
34
|
+
};
|
|
35
|
+
measure();
|
|
36
|
+
ro = new ResizeObserver(measure);
|
|
37
|
+
ro.observe(hostEl.value);
|
|
38
|
+
});
|
|
39
|
+
onBeforeUnmount(() => ro?.disconnect());
|
|
40
|
+
const H = 150;
|
|
41
|
+
const PAD = { top: 14, right: 8, bottom: 22, left: 34 };
|
|
42
|
+
const plotW = computed(() => W.value - PAD.left - PAD.right);
|
|
43
|
+
const plotH = H - PAD.top - PAD.bottom;
|
|
44
|
+
const slot = computed(() => plotW.value / Math.max(1, weeks.value.length));
|
|
45
|
+
const barW = computed(() => Math.max(3, slot.value * 0.62));
|
|
46
|
+
const x = (i: number) => PAD.left + i * slot.value + (slot.value - barW.value) / 2;
|
|
47
|
+
const y = (h: number) => PAD.top + plotH - (h / peak.value) * plotH;
|
|
48
|
+
const todayX = computed(() => PAD.left + props.series.past.length * slot.value);
|
|
49
|
+
const fmtWeek = (d: Date) => d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
50
|
+
/** Every 2nd/3rd label when the axis is crowded. */
|
|
51
|
+
const labelEvery = computed(() => Math.max(1, Math.ceil(56 / slot.value)));
|
|
52
|
+
const gridTicks = computed(() => {
|
|
53
|
+
const p = peak.value;
|
|
54
|
+
const step = p <= 5 ? 1 : p <= 20 ? 5 : p <= 50 ? 10 : p <= 200 ? 50 : 100;
|
|
55
|
+
const out: number[] = [];
|
|
56
|
+
for (let v = step; v <= p; v += step) out.push(v);
|
|
57
|
+
return out;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** Segments for one week's stack, in the shared seat order, bottom-up. */
|
|
61
|
+
function segments(w: { hoursBySeat: Record<string, number> }) {
|
|
62
|
+
let acc = 0;
|
|
63
|
+
return props.series.seats
|
|
64
|
+
.map((seat, si) => {
|
|
65
|
+
const h = w.hoursBySeat[seat] ?? 0;
|
|
66
|
+
const seg = { seat, si, h, y0: acc, y1: acc + h };
|
|
67
|
+
acc += h;
|
|
68
|
+
return seg;
|
|
69
|
+
})
|
|
70
|
+
.filter((s) => s.h > 0);
|
|
71
|
+
}
|
|
72
|
+
const seatHours = (avg: { hoursBySeat: Record<string, number> }) =>
|
|
73
|
+
props.series.seats.map((s) => `${s} ${fmtHours(avg.hoursBySeat[s] ?? 0)}`).join(" · ");
|
|
74
|
+
</script>
|
|
75
|
+
|
|
76
|
+
<template>
|
|
77
|
+
<section class="stat-block cost-block">
|
|
78
|
+
<h2>
|
|
79
|
+
Cost per week
|
|
80
|
+
<span class="hint">{{ series.past.length }} weeks recorded · {{ series.horizonWeeks }} projected</span>
|
|
81
|
+
</h2>
|
|
82
|
+
|
|
83
|
+
<div v-if="hasMeasured || hasProjected" class="cost-head">
|
|
84
|
+
<div v-if="hasMeasured" class="cost-figure">
|
|
85
|
+
<span class="cost-value">{{ fmtHours(measuredAvg.hours) }}<span class="unit">/wk</span></span>
|
|
86
|
+
<span class="cost-sub">
|
|
87
|
+
recorded · {{ seatHours(measuredAvg) }}
|
|
88
|
+
<template v-if="measuredAvg.tokensIn !== null"> · tokens {{ fmtTokens(measuredAvg.tokensIn) }} in / {{ fmtTokens(measuredAvg.tokensOut) }} out</template>
|
|
89
|
+
· over {{ measuredAvg.beads }} bead{{ measuredAvg.beads === 1 ? "" : "s" }}<template v-if="series.reconstructed"> · {{ series.reconstructed }} of the {{ series.sample }} records say they were reconstructed, not measured</template>
|
|
90
|
+
</span>
|
|
91
|
+
</div>
|
|
92
|
+
<div v-if="hasProjected" class="cost-figure is-projection">
|
|
93
|
+
<span class="cost-value">{{ fmtHours(projectedAvg.hours) }}<span class="unit">/wk</span></span>
|
|
94
|
+
<span class="cost-sub">
|
|
95
|
+
projected · {{ seatHours(projectedAvg) }}
|
|
96
|
+
<template v-if="projectedAvg.tokensIn !== null"> · tokens {{ fmtTokens(projectedAvg.tokensIn) }} in / {{ fmtTokens(projectedAvg.tokensOut) }} out</template>
|
|
97
|
+
· {{ projectedAvg.beads }} bead{{ projectedAvg.beads === 1 ? "" : "s" }} scheduled to land, each given the median cost of comparable shipped work ({{ series.sample }} recorded)
|
|
98
|
+
</span>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<div ref="hostEl" class="cost-host">
|
|
103
|
+
<svg v-if="hasMeasured || hasProjected" class="cost-svg" :width="W" :height="H" role="img" :aria-label="`Hours per week by seat, ${series.past.length} recorded weeks then ${series.horizonWeeks} projected`">
|
|
104
|
+
<g class="grid">
|
|
105
|
+
<line v-for="v in gridTicks" :key="v" :x1="PAD.left" :x2="W - PAD.right" :y1="y(v)" :y2="y(v)" />
|
|
106
|
+
<text v-for="v in gridTicks" :key="'l' + v" :x="PAD.left - 4" :y="y(v) + 3" text-anchor="end">{{ v }}h</text>
|
|
107
|
+
</g>
|
|
108
|
+
<line class="baseline" :x1="PAD.left" :x2="W - PAD.right" :y1="PAD.top + plotH" :y2="PAD.top + plotH" />
|
|
109
|
+
<g v-for="(w, i) in weeks" :key="w.weekStart.toISOString()" class="week" :class="{ projected: !w.measured }">
|
|
110
|
+
<rect
|
|
111
|
+
v-for="s in segments(w)"
|
|
112
|
+
:key="s.seat"
|
|
113
|
+
:class="`seat seat-${s.si}`"
|
|
114
|
+
:x="x(i)"
|
|
115
|
+
:y="y(s.y1)"
|
|
116
|
+
:width="barW"
|
|
117
|
+
:height="Math.max(0, y(s.y0) - y(s.y1))"
|
|
118
|
+
>
|
|
119
|
+
<title>{{ fmtWeek(w.weekStart) }} · {{ s.seat }} {{ s.h.toFixed(1) }}h{{ w.measured ? "" : " (projected)" }}</title>
|
|
120
|
+
</rect>
|
|
121
|
+
<text v-if="i % labelEvery === 0" class="xlabel" :x="x(i) + barW / 2" :y="H - 8" text-anchor="middle">{{ fmtWeek(w.weekStart) }}</text>
|
|
122
|
+
</g>
|
|
123
|
+
<g class="today" :transform="`translate(${todayX} 0)`">
|
|
124
|
+
<line :y1="PAD.top - 4" :y2="PAD.top + plotH" />
|
|
125
|
+
<text :y="PAD.top - 3" x="3">today</text>
|
|
126
|
+
</g>
|
|
127
|
+
</svg>
|
|
128
|
+
<p v-else class="hint">
|
|
129
|
+
No bead here carries recorded economics{{ who ? ` for ${who}` : "" }} — hours by seat under
|
|
130
|
+
<code>metadata.economics.by_seat</code>, tokens under <code>totals</code> or per session — so there is no recorded cost to show and nothing
|
|
131
|
+
to project from. See docs/economics.md for what to record.
|
|
132
|
+
</p>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<div v-if="hasMeasured || hasProjected" class="cost-legend">
|
|
136
|
+
<span v-for="(s, i) in series.seats" :key="s" class="key"><i :class="`swatch seat-${i}`" />{{ s }}</span>
|
|
137
|
+
<span class="key"><i class="swatch projected-swatch" />projected (dulled)</span>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<details v-if="hasMeasured || hasProjected" class="table-view">
|
|
141
|
+
<summary>Table</summary>
|
|
142
|
+
<table>
|
|
143
|
+
<thead>
|
|
144
|
+
<tr>
|
|
145
|
+
<th>week of</th>
|
|
146
|
+
<th v-for="s in series.seats" :key="s" class="num">{{ s }} h</th>
|
|
147
|
+
<th class="num">tokens in</th>
|
|
148
|
+
<th class="num">tokens out</th>
|
|
149
|
+
<th class="num">beads</th>
|
|
150
|
+
<th>basis</th>
|
|
151
|
+
</tr>
|
|
152
|
+
</thead>
|
|
153
|
+
<tbody>
|
|
154
|
+
<tr v-for="w in weeks" :key="w.weekStart.toISOString()" :class="{ projected: !w.measured }">
|
|
155
|
+
<td>{{ fmtWeek(w.weekStart) }}</td>
|
|
156
|
+
<td v-for="s in series.seats" :key="s" class="num">{{ fmtHours(w.hoursBySeat[s] ?? 0) }}</td>
|
|
157
|
+
<td class="num">{{ fmtTokens(w.tokensIn) }}</td>
|
|
158
|
+
<td class="num">{{ fmtTokens(w.tokensOut) }}</td>
|
|
159
|
+
<td class="num">{{ w.beads }}</td>
|
|
160
|
+
<td>{{ w.measured ? "recorded" : "projected (medians)" }}</td>
|
|
161
|
+
</tr>
|
|
162
|
+
</tbody>
|
|
163
|
+
</table>
|
|
164
|
+
</details>
|
|
165
|
+
</section>
|
|
166
|
+
</template>
|
|
167
|
+
|
|
168
|
+
<style scoped>
|
|
169
|
+
.cost-block h2 .hint {
|
|
170
|
+
text-transform: none;
|
|
171
|
+
letter-spacing: 0;
|
|
172
|
+
font-weight: 400;
|
|
173
|
+
color: var(--c-dim);
|
|
174
|
+
margin-left: 6px;
|
|
175
|
+
}
|
|
176
|
+
.cost-head { display: flex; flex-wrap: wrap; gap: 6px 24px; margin-bottom: 6px; }
|
|
177
|
+
.cost-figure { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
|
178
|
+
.cost-value { font-size: 22px; font-weight: 600; line-height: 1.1; color: var(--c-text); }
|
|
179
|
+
.cost-value .unit { font-size: 12px; font-weight: 400; color: var(--c-dim); margin-left: 2px; }
|
|
180
|
+
.cost-figure.is-projection .cost-value { color: var(--c-dim); font-style: italic; }
|
|
181
|
+
.cost-sub { font-size: 11px; color: var(--c-dim); max-width: 60ch; }
|
|
182
|
+
.cost-host { width: 100%; margin-top: 4px; }
|
|
183
|
+
.cost-svg { display: block; }
|
|
184
|
+
.cost-svg .grid line { stroke: var(--c-border); stroke-width: 0.5; }
|
|
185
|
+
.cost-svg .grid text, .cost-svg .xlabel { fill: var(--c-dim); font-size: 9px; font-family: var(--font-body); }
|
|
186
|
+
.cost-svg .baseline { stroke: var(--c-border-strong); stroke-width: 1; }
|
|
187
|
+
/* Seats: the first (most hours, usually the agent) in the accent, the second in the in-progress hue, more in dim. */
|
|
188
|
+
.cost-svg .seat-0, .cost-legend .seat-0 { fill: var(--c-accent); background: var(--c-accent); }
|
|
189
|
+
.cost-svg .seat-1, .cost-legend .seat-1 { fill: var(--c-in-progress); background: var(--c-in-progress); }
|
|
190
|
+
.cost-svg .seat-2, .cost-legend .seat-2, .cost-svg .seat-3, .cost-legend .seat-3 { fill: var(--c-dim); background: var(--c-dim); }
|
|
191
|
+
.cost-svg .week.projected rect { fill-opacity: 0.35; stroke: var(--c-dim); stroke-width: 0.75; stroke-dasharray: 2 2; }
|
|
192
|
+
.cost-svg .today line { stroke: var(--c-danger); stroke-width: 1; stroke-dasharray: 3 2; }
|
|
193
|
+
.cost-svg .today text { fill: var(--c-danger); font-size: 9px; font-family: var(--font-body); }
|
|
194
|
+
.cost-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 4px; font-size: 10px; color: var(--c-dim); }
|
|
195
|
+
.cost-legend .key { display: inline-flex; align-items: center; gap: 5px; }
|
|
196
|
+
.cost-legend .swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; }
|
|
197
|
+
.cost-legend .projected-swatch { background: var(--c-dim); opacity: 0.35; outline: 1px dashed var(--c-dim); }
|
|
198
|
+
.hint code { font-family: var(--font-mono); font-size: 0.92em; }
|
|
199
|
+
.table-view { margin-top: 10px; font-size: 11px; }
|
|
200
|
+
.table-view summary { cursor: pointer; color: var(--c-dim); padding: 2px 0; }
|
|
201
|
+
.table-view table { border-collapse: collapse; margin-top: 6px; width: 100%; }
|
|
202
|
+
.table-view th, .table-view td { text-align: left; padding: 2px 8px 2px 0; border-bottom: 1px solid var(--c-border); }
|
|
203
|
+
.table-view thead th { color: var(--c-dim); font-size: 10px; font-weight: 600; }
|
|
204
|
+
.table-view .num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
205
|
+
.table-view tr.projected td { color: var(--c-dim); font-style: italic; }
|
|
206
|
+
</style>
|