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,245 @@
|
|
|
1
|
+
<script setup lang="ts" generic="T extends string | number">
|
|
2
|
+
// FilterPopover.vue — small multi-select popover for the controls panel.
|
|
3
|
+
//
|
|
4
|
+
// Renders a chip-style trigger button that opens a floating checkbox list.
|
|
5
|
+
// Options come from live data (parent enumerates unique values from the
|
|
6
|
+
// current beads); selecting nothing means "no filter." Closes on
|
|
7
|
+
// outside-click and Escape. Keyboard-navigable (tab into the button, enter/
|
|
8
|
+
// space to open, tab through options).
|
|
9
|
+
|
|
10
|
+
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
11
|
+
import type { FilterOption } from "./filter-option";
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{
|
|
14
|
+
label: string;
|
|
15
|
+
options: FilterOption<T>[];
|
|
16
|
+
modelValue: T[] | null;
|
|
17
|
+
/** The active view does not act on this filter (bp-67g.36): inert trigger, menu shut. */
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
}>();
|
|
20
|
+
|
|
21
|
+
const emit = defineEmits<{
|
|
22
|
+
"update:modelValue": [T[] | null];
|
|
23
|
+
}>();
|
|
24
|
+
|
|
25
|
+
const open = ref(false);
|
|
26
|
+
const rootEl = ref<HTMLElement | null>(null);
|
|
27
|
+
|
|
28
|
+
const selected = computed(() => new Set(props.modelValue ?? []));
|
|
29
|
+
|
|
30
|
+
const summary = computed(() => {
|
|
31
|
+
const n = props.modelValue?.length ?? 0;
|
|
32
|
+
if (!n) return "any";
|
|
33
|
+
if (n === 1) {
|
|
34
|
+
const opt = props.options.find((o) => o.value === props.modelValue![0]);
|
|
35
|
+
return opt?.label ?? String(props.modelValue![0]);
|
|
36
|
+
}
|
|
37
|
+
return `${n} selected`;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function toggle(v: T) {
|
|
41
|
+
const cur = props.modelValue ?? [];
|
|
42
|
+
const next = cur.includes(v)
|
|
43
|
+
? cur.filter((x) => x !== v)
|
|
44
|
+
: [...cur, v];
|
|
45
|
+
emit("update:modelValue", next.length ? next : null);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function clear() {
|
|
49
|
+
emit("update:modelValue", null);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function onClickOutside(e: MouseEvent) {
|
|
53
|
+
if (!rootEl.value) return;
|
|
54
|
+
if (!rootEl.value.contains(e.target as Node)) open.value = false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function onKeydown(e: KeyboardEvent) {
|
|
58
|
+
if (e.key === "Escape") open.value = false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
onMounted(() => {
|
|
62
|
+
document.addEventListener("mousedown", onClickOutside);
|
|
63
|
+
document.addEventListener("keydown", onKeydown);
|
|
64
|
+
});
|
|
65
|
+
onBeforeUnmount(() => {
|
|
66
|
+
document.removeEventListener("mousedown", onClickOutside);
|
|
67
|
+
document.removeEventListener("keydown", onKeydown);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Auto-scroll list to selected item when opened.
|
|
71
|
+
watch(open, (v) => {
|
|
72
|
+
if (!v) return;
|
|
73
|
+
});
|
|
74
|
+
</script>
|
|
75
|
+
|
|
76
|
+
<template>
|
|
77
|
+
<div ref="rootEl" class="filter-popover" :class="{ open }">
|
|
78
|
+
<button
|
|
79
|
+
type="button"
|
|
80
|
+
class="filter-trigger"
|
|
81
|
+
:class="{ 'has-selection': (modelValue?.length ?? 0) > 0 }"
|
|
82
|
+
:disabled="disabled"
|
|
83
|
+
@click="open = !open"
|
|
84
|
+
>
|
|
85
|
+
<span class="label">{{ label }}</span>
|
|
86
|
+
<span class="summary">{{ summary }}</span>
|
|
87
|
+
<span class="caret">▾</span>
|
|
88
|
+
</button>
|
|
89
|
+
|
|
90
|
+
<div v-if="open && !disabled" class="filter-menu" role="listbox">
|
|
91
|
+
<div class="filter-menu-header">
|
|
92
|
+
<span>{{ label }}</span>
|
|
93
|
+
<button
|
|
94
|
+
v-if="(modelValue?.length ?? 0) > 0"
|
|
95
|
+
type="button"
|
|
96
|
+
class="clear"
|
|
97
|
+
@click="clear"
|
|
98
|
+
>clear</button>
|
|
99
|
+
</div>
|
|
100
|
+
<ul>
|
|
101
|
+
<li v-for="opt in options" :key="String(opt.value)">
|
|
102
|
+
<label>
|
|
103
|
+
<input
|
|
104
|
+
type="checkbox"
|
|
105
|
+
:checked="selected.has(opt.value)"
|
|
106
|
+
@change="toggle(opt.value)"
|
|
107
|
+
/>
|
|
108
|
+
<span class="option-label">{{ opt.label }}</span>
|
|
109
|
+
<span v-if="opt.hint" class="option-hint">{{ opt.hint }}</span>
|
|
110
|
+
</label>
|
|
111
|
+
</li>
|
|
112
|
+
<li v-if="!options.length" class="empty">(no options in data)</li>
|
|
113
|
+
</ul>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
</template>
|
|
117
|
+
|
|
118
|
+
<style>
|
|
119
|
+
.filter-popover {
|
|
120
|
+
position: relative;
|
|
121
|
+
display: inline-block;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.filter-trigger {
|
|
125
|
+
display: inline-flex;
|
|
126
|
+
align-items: center;
|
|
127
|
+
gap: 6px;
|
|
128
|
+
background: transparent;
|
|
129
|
+
border: 1px solid var(--c-border);
|
|
130
|
+
color: var(--c-text);
|
|
131
|
+
padding: 4px 8px 4px 10px;
|
|
132
|
+
border-radius: 3px;
|
|
133
|
+
font-size: 11px;
|
|
134
|
+
cursor: pointer;
|
|
135
|
+
font-family: inherit;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.filter-trigger:hover,
|
|
139
|
+
.filter-popover.open .filter-trigger {
|
|
140
|
+
border-color: var(--c-accent);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.filter-trigger .label {
|
|
144
|
+
color: var(--c-dim);
|
|
145
|
+
text-transform: uppercase;
|
|
146
|
+
letter-spacing: 0.05em;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.filter-trigger .summary {
|
|
150
|
+
color: var(--c-text);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.filter-trigger.has-selection .summary {
|
|
154
|
+
color: var(--c-accent);
|
|
155
|
+
font-weight: 600;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.filter-trigger .caret {
|
|
159
|
+
color: var(--c-dim);
|
|
160
|
+
font-size: 9px;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.filter-menu {
|
|
164
|
+
position: absolute;
|
|
165
|
+
top: calc(100% + 4px);
|
|
166
|
+
left: 0;
|
|
167
|
+
z-index: 100;
|
|
168
|
+
min-width: 200px;
|
|
169
|
+
max-height: 320px;
|
|
170
|
+
overflow: auto;
|
|
171
|
+
background: var(--c-panel);
|
|
172
|
+
border: 1px solid var(--c-border);
|
|
173
|
+
border-radius: 4px;
|
|
174
|
+
box-shadow: var(--shadow-panel);
|
|
175
|
+
color: var(--c-text);
|
|
176
|
+
font-size: 11px;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.filter-menu-header {
|
|
180
|
+
display: flex;
|
|
181
|
+
align-items: center;
|
|
182
|
+
justify-content: space-between;
|
|
183
|
+
padding: 8px 12px;
|
|
184
|
+
border-bottom: 1px solid var(--c-border);
|
|
185
|
+
color: var(--c-dim);
|
|
186
|
+
text-transform: uppercase;
|
|
187
|
+
letter-spacing: 0.05em;
|
|
188
|
+
font-size: 10px;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.filter-menu-header .clear {
|
|
192
|
+
background: none;
|
|
193
|
+
border: none;
|
|
194
|
+
color: var(--c-accent);
|
|
195
|
+
cursor: pointer;
|
|
196
|
+
font-size: 10px;
|
|
197
|
+
padding: 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.filter-menu ul {
|
|
201
|
+
list-style: none;
|
|
202
|
+
padding: 4px 0;
|
|
203
|
+
margin: 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.filter-menu li {
|
|
207
|
+
padding: 0;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.filter-menu label {
|
|
211
|
+
display: flex;
|
|
212
|
+
align-items: center;
|
|
213
|
+
gap: 6px;
|
|
214
|
+
padding: 4px 12px;
|
|
215
|
+
cursor: pointer;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.filter-menu label:hover {
|
|
219
|
+
background: var(--c-accent-wash);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.filter-menu input[type="checkbox"] {
|
|
223
|
+
accent-color: var(--c-accent);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.option-label {
|
|
227
|
+
flex: 1;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.option-hint {
|
|
231
|
+
color: var(--c-dim);
|
|
232
|
+
font-size: 10px;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.filter-menu li.empty {
|
|
236
|
+
padding: 8px 12px;
|
|
237
|
+
color: var(--c-dim);
|
|
238
|
+
font-style: italic;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/* Dimmed with its group when the view cannot act on it (bp-67g.36). */
|
|
242
|
+
.filter-popover .filter-trigger:disabled {
|
|
243
|
+
cursor: not-allowed;
|
|
244
|
+
}
|
|
245
|
+
</style>
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// GroupProgress.vue — group sizes and completion, replacing the Groups text
|
|
3
|
+
// list in app-main (bp-67g.30).
|
|
4
|
+
//
|
|
5
|
+
// The list this replaces was ordered by group ID, so the outlier group was
|
|
6
|
+
// not merely unobvious, it was unfindable. Sorted by population with the
|
|
7
|
+
// closed run drawn inside the bar, "which epic is huge" and "how far along
|
|
8
|
+
// is it" are both answered without reading a number.
|
|
9
|
+
//
|
|
10
|
+
// Horizontal rows rather than columns: group labels are long (an epic's ID
|
|
11
|
+
// plus its title) and a horizontal form never rotates a label.
|
|
12
|
+
|
|
13
|
+
import { computed } from "vue";
|
|
14
|
+
import type { GroupProgressRow } from "../insights";
|
|
15
|
+
|
|
16
|
+
const props = defineProps<{
|
|
17
|
+
rows: GroupProgressRow[];
|
|
18
|
+
/** What the rows are grouped by, for the heading. */
|
|
19
|
+
groupBy: string;
|
|
20
|
+
}>();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The placeholder bucket is pulled out of the bars. It is the remainder
|
|
24
|
+
* rather than a peer — on a 556-bead repo it held 305 of them — and left in
|
|
25
|
+
* the scale it put every real group on a tenth of the width.
|
|
26
|
+
*/
|
|
27
|
+
const groups = computed(() => props.rows.filter((r) => !r.synthetic));
|
|
28
|
+
const remainder = computed(() => props.rows.filter((r) => r.synthetic));
|
|
29
|
+
|
|
30
|
+
const maxTotal = computed(() =>
|
|
31
|
+
Math.max(1, ...groups.value.map((r) => r.total)),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const totals = computed(() => ({
|
|
35
|
+
closed: props.rows.reduce((a, r) => a + r.closed, 0),
|
|
36
|
+
total: props.rows.reduce((a, r) => a + r.total, 0),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
const overall = computed(() =>
|
|
40
|
+
totals.value.total > 0 ? totals.value.closed / totals.value.total : 0,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const pct = (v: number) => Math.round(v * 100);
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<template>
|
|
47
|
+
<section class="stat-block progress-block">
|
|
48
|
+
<h2>
|
|
49
|
+
Progress
|
|
50
|
+
<span class="hint">by {{ groupBy }}</span>
|
|
51
|
+
</h2>
|
|
52
|
+
|
|
53
|
+
<div class="progress-head">
|
|
54
|
+
<span class="progress-value">{{ pct(overall) }}%</span>
|
|
55
|
+
<span class="progress-sub">
|
|
56
|
+
{{ totals.closed }} of {{ totals.total }} closed ·
|
|
57
|
+
{{ groups.length }} {{ groups.length === 1 ? "group" : "groups" }}
|
|
58
|
+
<!-- Phrased rather than interpolating the label, which is already
|
|
59
|
+
parenthesised ("(no epic)") and read as a double bracket. -->
|
|
60
|
+
<template v-for="r in remainder" :key="r.id">
|
|
61
|
+
· {{ r.total }} ungrouped, {{ pct(r.fraction) }}% closed
|
|
62
|
+
</template>
|
|
63
|
+
</span>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div class="legend">
|
|
67
|
+
<span class="key"><i class="swatch is-closed" />closed</span>
|
|
68
|
+
<span class="key"><i class="swatch is-open" />open</span>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<ul v-if="groups.length" class="rows">
|
|
72
|
+
<li v-for="r in groups" :key="r.id">
|
|
73
|
+
<span class="r-label" :title="r.label">{{ r.label }}</span>
|
|
74
|
+
<span
|
|
75
|
+
class="r-track"
|
|
76
|
+
:style="{ width: `${(r.total / maxTotal) * 100}%` }"
|
|
77
|
+
:aria-label="`${r.closed} of ${r.total} closed`"
|
|
78
|
+
>
|
|
79
|
+
<i class="r-closed" :style="{ width: `${r.fraction * 100}%` }" />
|
|
80
|
+
</span>
|
|
81
|
+
<span class="r-value">
|
|
82
|
+
{{ r.closed }}/{{ r.total }}
|
|
83
|
+
<em>{{ pct(r.fraction) }}%</em>
|
|
84
|
+
</span>
|
|
85
|
+
</li>
|
|
86
|
+
</ul>
|
|
87
|
+
<p v-else class="progress-empty">Nothing grouped to compare.</p>
|
|
88
|
+
|
|
89
|
+
<!-- rowCount is deliberately absent from the bars — it is what the Gantt
|
|
90
|
+
DRAWS, clamped at maxPerGroup, not how big the group is. It appears
|
|
91
|
+
here only so the difference is inspectable. -->
|
|
92
|
+
<details class="table-view">
|
|
93
|
+
<summary>Table</summary>
|
|
94
|
+
<table>
|
|
95
|
+
<thead>
|
|
96
|
+
<tr>
|
|
97
|
+
<th>group</th>
|
|
98
|
+
<th class="num">closed</th>
|
|
99
|
+
<th class="num">total</th>
|
|
100
|
+
<th class="num">%</th>
|
|
101
|
+
</tr>
|
|
102
|
+
</thead>
|
|
103
|
+
<tbody>
|
|
104
|
+
<tr v-for="r in rows" :key="r.id">
|
|
105
|
+
<td><code>{{ r.id }}</code></td>
|
|
106
|
+
<td class="num">{{ r.closed }}</td>
|
|
107
|
+
<td class="num">{{ r.total }}</td>
|
|
108
|
+
<td class="num">{{ pct(r.fraction) }}%</td>
|
|
109
|
+
</tr>
|
|
110
|
+
</tbody>
|
|
111
|
+
</table>
|
|
112
|
+
</details>
|
|
113
|
+
</section>
|
|
114
|
+
</template>
|
|
115
|
+
|
|
116
|
+
<style scoped>
|
|
117
|
+
.progress-block h2 .hint {
|
|
118
|
+
text-transform: none;
|
|
119
|
+
letter-spacing: 0;
|
|
120
|
+
font-weight: 400;
|
|
121
|
+
color: var(--c-dim);
|
|
122
|
+
margin-left: 6px;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.progress-head {
|
|
126
|
+
display: flex;
|
|
127
|
+
align-items: baseline;
|
|
128
|
+
gap: 8px;
|
|
129
|
+
flex-wrap: wrap;
|
|
130
|
+
margin-bottom: 8px;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* Proportional figures: tabular-nums makes a 24px standalone value read
|
|
134
|
+
loose. The table below gets tabular, where digits align. */
|
|
135
|
+
.progress-value {
|
|
136
|
+
font-size: 24px;
|
|
137
|
+
font-weight: 600;
|
|
138
|
+
line-height: 1.1;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.progress-sub {
|
|
142
|
+
font-size: 11px;
|
|
143
|
+
color: var(--c-dim);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.legend {
|
|
147
|
+
display: flex;
|
|
148
|
+
gap: 12px;
|
|
149
|
+
font-size: 10px;
|
|
150
|
+
color: var(--c-dim);
|
|
151
|
+
margin-bottom: 8px;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.key {
|
|
155
|
+
display: inline-flex;
|
|
156
|
+
align-items: center;
|
|
157
|
+
gap: 5px;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.swatch {
|
|
161
|
+
width: 9px;
|
|
162
|
+
height: 9px;
|
|
163
|
+
border-radius: 2px;
|
|
164
|
+
display: inline-block;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.swatch.is-closed {
|
|
168
|
+
background: var(--c-shipped);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.swatch.is-open {
|
|
172
|
+
background: var(--c-future);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.rows {
|
|
176
|
+
list-style: none;
|
|
177
|
+
margin: 0;
|
|
178
|
+
padding: 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.rows li {
|
|
182
|
+
display: grid;
|
|
183
|
+
grid-template-columns: minmax(80px, 30%) 1fr 76px;
|
|
184
|
+
align-items: center;
|
|
185
|
+
gap: 8px;
|
|
186
|
+
padding: 2px 0;
|
|
187
|
+
/* The shared .stat-block li rule adds a divider and space-between; these
|
|
188
|
+
rows carry their own grid, so neither applies. */
|
|
189
|
+
border-bottom: none;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.r-label {
|
|
193
|
+
font-size: 11px;
|
|
194
|
+
color: var(--c-dim-bright);
|
|
195
|
+
overflow: hidden;
|
|
196
|
+
text-overflow: ellipsis;
|
|
197
|
+
white-space: nowrap;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/* Track length is the group's population; the fill is what's closed. */
|
|
201
|
+
.r-track {
|
|
202
|
+
display: block;
|
|
203
|
+
height: 11px;
|
|
204
|
+
background: var(--c-future);
|
|
205
|
+
border-radius: 2px;
|
|
206
|
+
overflow: hidden;
|
|
207
|
+
min-width: 3px;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/* A 2px gap in the surface colour separates closed from open, rather than a
|
|
211
|
+
stroke — a border would add data-weight ink that isn't data. */
|
|
212
|
+
.r-closed {
|
|
213
|
+
display: block;
|
|
214
|
+
height: 11px;
|
|
215
|
+
background: var(--c-shipped);
|
|
216
|
+
border-right: 2px solid var(--c-panel);
|
|
217
|
+
border-radius: 2px 0 0 2px;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.r-value {
|
|
221
|
+
font-size: 10px;
|
|
222
|
+
color: var(--c-dim);
|
|
223
|
+
font-variant-numeric: tabular-nums;
|
|
224
|
+
text-align: right;
|
|
225
|
+
white-space: nowrap;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.r-value em {
|
|
229
|
+
font-style: normal;
|
|
230
|
+
color: var(--c-text);
|
|
231
|
+
margin-left: 4px;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.progress-empty {
|
|
235
|
+
color: var(--c-dim);
|
|
236
|
+
font-size: 12px;
|
|
237
|
+
margin: 12px 0;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.table-view {
|
|
241
|
+
margin-top: 10px;
|
|
242
|
+
font-size: 11px;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.table-view summary {
|
|
246
|
+
cursor: pointer;
|
|
247
|
+
color: var(--c-dim);
|
|
248
|
+
padding: 2px 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.table-view table {
|
|
252
|
+
border-collapse: collapse;
|
|
253
|
+
margin-top: 6px;
|
|
254
|
+
width: 100%;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.table-view th,
|
|
258
|
+
.table-view td {
|
|
259
|
+
text-align: left;
|
|
260
|
+
padding: 2px 8px 2px 0;
|
|
261
|
+
border-bottom: 1px solid var(--c-border);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.table-view thead th {
|
|
265
|
+
color: var(--c-dim);
|
|
266
|
+
font-size: 10px;
|
|
267
|
+
font-weight: 600;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.table-view .num {
|
|
271
|
+
text-align: right;
|
|
272
|
+
font-variant-numeric: tabular-nums;
|
|
273
|
+
}
|
|
274
|
+
</style>
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// LoadMeter.vue — shown in place of the chart while there is nothing to
|
|
3
|
+
// show: a first load, or a project switch. A background poll never reaches
|
|
4
|
+
// here; it refreshes in place behind a thin activity hint.
|
|
5
|
+
//
|
|
6
|
+
// The bar fills against how long THIS project took last time, because the
|
|
7
|
+
// current run has no progress to report — the `bd` subprocess is ~90% of
|
|
8
|
+
// the wall time and says nothing while it works. That makes it an estimate,
|
|
9
|
+
// so it is labelled as one and gives up honestly when outlasted rather than
|
|
10
|
+
// creeping to 99%.
|
|
11
|
+
|
|
12
|
+
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
|
13
|
+
import { useBeadsStore } from "../store";
|
|
14
|
+
import { formatElapsed, meterState } from "../load-meter";
|
|
15
|
+
|
|
16
|
+
const store = useBeadsStore();
|
|
17
|
+
|
|
18
|
+
// Ticks the elapsed clock. 100ms is smooth enough for a bar and cheap.
|
|
19
|
+
const now = ref(Date.now());
|
|
20
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
21
|
+
onMounted(() => {
|
|
22
|
+
timer = setInterval(() => (now.value = Date.now()), 100);
|
|
23
|
+
});
|
|
24
|
+
onBeforeUnmount(() => {
|
|
25
|
+
if (timer !== null) clearInterval(timer);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const elapsedMs = computed(() =>
|
|
29
|
+
store.fetchStartedAt === null ? 0 : now.value - store.fetchStartedAt,
|
|
30
|
+
);
|
|
31
|
+
const expectedMs = computed(() => store.expectedFetchMs);
|
|
32
|
+
const state = computed(() => meterState(elapsedMs.value, expectedMs.value));
|
|
33
|
+
const projectLabel = computed(() => store.activeProject?.label ?? null);
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<section class="load-meter" role="status" aria-live="polite">
|
|
38
|
+
<div class="meter-head">
|
|
39
|
+
<!-- Built as one expression: a leading space inside a <template> tag
|
|
40
|
+
is collapsed by the compiler, which ran "Loading" into the
|
|
41
|
+
project name. -->
|
|
42
|
+
<span class="meter-what">{{
|
|
43
|
+
projectLabel ? `Loading ${projectLabel}…` : "Loading…"
|
|
44
|
+
}}</span>
|
|
45
|
+
<span class="meter-elapsed">{{ formatElapsed(elapsedMs) }}</span>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div
|
|
49
|
+
class="meter-track"
|
|
50
|
+
role="progressbar"
|
|
51
|
+
:aria-valuemin="0"
|
|
52
|
+
:aria-valuemax="100"
|
|
53
|
+
:aria-valuenow="state.pct === null ? undefined : Math.round(state.pct)"
|
|
54
|
+
>
|
|
55
|
+
<div
|
|
56
|
+
v-if="state.mode === 'determinate'"
|
|
57
|
+
class="meter-fill"
|
|
58
|
+
:style="{ width: state.pct + '%' }"
|
|
59
|
+
/>
|
|
60
|
+
<div v-else class="meter-fill is-indeterminate" />
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<!-- Says where the number came from. A bar filling against a past run
|
|
64
|
+
is an estimate, and presenting it as measurement would be a lie. -->
|
|
65
|
+
<p class="meter-note">
|
|
66
|
+
<template v-if="state.overrun">
|
|
67
|
+
Taking longer than last time ({{ formatElapsed(expectedMs ?? 0) }}) —
|
|
68
|
+
no estimate to give.
|
|
69
|
+
</template>
|
|
70
|
+
<template v-else-if="state.mode === 'determinate'">
|
|
71
|
+
Estimated from the last load of this project
|
|
72
|
+
({{ formatElapsed(expectedMs ?? 0) }}).
|
|
73
|
+
</template>
|
|
74
|
+
<template v-else>
|
|
75
|
+
First load of this project — no previous timing to estimate from.
|
|
76
|
+
</template>
|
|
77
|
+
</p>
|
|
78
|
+
</section>
|
|
79
|
+
</template>
|
|
80
|
+
|
|
81
|
+
<style>
|
|
82
|
+
.load-meter {
|
|
83
|
+
padding: 22px 20px;
|
|
84
|
+
border: 1px solid var(--c-border);
|
|
85
|
+
border-radius: var(--radius);
|
|
86
|
+
background: var(--c-bg);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.meter-head {
|
|
90
|
+
display: flex;
|
|
91
|
+
align-items: baseline;
|
|
92
|
+
gap: 10px;
|
|
93
|
+
margin-bottom: 9px;
|
|
94
|
+
font-size: 12px;
|
|
95
|
+
color: var(--c-text);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.meter-what {
|
|
99
|
+
font-weight: 600;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.meter-elapsed {
|
|
103
|
+
margin-left: auto;
|
|
104
|
+
font-variant-numeric: tabular-nums;
|
|
105
|
+
color: var(--c-dim);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.meter-track {
|
|
109
|
+
height: 6px;
|
|
110
|
+
border-radius: 3px;
|
|
111
|
+
background: var(--c-panel-2);
|
|
112
|
+
overflow: hidden;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.meter-fill {
|
|
116
|
+
height: 100%;
|
|
117
|
+
background: var(--c-accent);
|
|
118
|
+
border-radius: 3px;
|
|
119
|
+
transition: width 0.1s linear;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* A sweep rather than a fill, so it reads as "working" and not as an
|
|
123
|
+
amount completed. */
|
|
124
|
+
.meter-fill.is-indeterminate {
|
|
125
|
+
width: 34%;
|
|
126
|
+
animation: meter-sweep 1.1s ease-in-out infinite;
|
|
127
|
+
transition: none;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
@keyframes meter-sweep {
|
|
131
|
+
0% {
|
|
132
|
+
transform: translateX(-100%);
|
|
133
|
+
}
|
|
134
|
+
100% {
|
|
135
|
+
transform: translateX(295%);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.meter-note {
|
|
140
|
+
margin: 9px 0 0;
|
|
141
|
+
font-size: 10px;
|
|
142
|
+
color: var(--c-dim);
|
|
143
|
+
}
|
|
144
|
+
</style>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// MineRow.vue — one bead as a dense list row in the mine view. Click opens
|
|
3
|
+
// the drawer, as everywhere else. The optional note carries the relationship
|
|
4
|
+
// a section is about ("waiting on bp-x · bob").
|
|
5
|
+
|
|
6
|
+
import type { Bead } from "../store";
|
|
7
|
+
import { useBeadsStore } from "../store";
|
|
8
|
+
import PriorityChip from "./PriorityChip.vue";
|
|
9
|
+
import TypeChip from "./TypeChip.vue";
|
|
10
|
+
|
|
11
|
+
defineProps<{ bead: Bead; note?: string | null }>();
|
|
12
|
+
|
|
13
|
+
const store = useBeadsStore();
|
|
14
|
+
const statusClass = (s: string) => `status-${s.replace(/_/g, "-")}`;
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<li class="mine-row" @click="store.openBead(bead.id)" @contextmenu.prevent="store.openContextMenu(bead.id, $event.clientX, $event.clientY)">
|
|
19
|
+
<code class="mine-id">{{ bead.id }}</code>
|
|
20
|
+
<PriorityChip :priority="bead.priority ?? 4" />
|
|
21
|
+
<span class="mine-title" :title="bead.title">{{ bead.title }}</span>
|
|
22
|
+
<span v-if="note" class="mine-note" :title="note">{{ note }}</span>
|
|
23
|
+
<TypeChip :type="bead.issue_type" />
|
|
24
|
+
<span class="pill" :class="statusClass(bead.status)">{{ bead.status.replace(/_/g, " ") }}</span>
|
|
25
|
+
<!-- A trailing action, when a section has one (the frontier's "stage", bp-wfe). -->
|
|
26
|
+
<slot />
|
|
27
|
+
</li>
|
|
28
|
+
</template>
|