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,325 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// BeadsMine.vue — the worklog: what should I be doing, what is going on
|
|
3
|
+
// (bp-egy). First tab and the default.
|
|
4
|
+
//
|
|
5
|
+
// Everything here is about ONE person — the current user chosen in the
|
|
6
|
+
// options menu, or carried in by a ?me= link — and is a list, not a chart:
|
|
7
|
+
// several small sets at once, which no single filter on the Gantt or grid
|
|
8
|
+
// can show together. The sets come from mine.ts so they are testable without
|
|
9
|
+
// a DOM; this file lays them out and hosts the "who are you" prompt when no
|
|
10
|
+
// user is set, because the view itself is the natural place to ask.
|
|
11
|
+
|
|
12
|
+
import { computed, watchEffect } from "vue";
|
|
13
|
+
import { useBeadsStore } from "../store";
|
|
14
|
+
import type { ViewModel } from "../view-model";
|
|
15
|
+
import { buildMineModel, type HoldingItem, type WaitingItem } from "../mine";
|
|
16
|
+
import MineRow from "./MineRow.vue";
|
|
17
|
+
|
|
18
|
+
defineProps<{ vm: ViewModel | null }>();
|
|
19
|
+
|
|
20
|
+
const store = useBeadsStore();
|
|
21
|
+
const me = computed(() => store.effectiveUser);
|
|
22
|
+
|
|
23
|
+
const model = computed(() => {
|
|
24
|
+
if (!me.value) return null;
|
|
25
|
+
return buildMineModel(store.beads, me.value, {
|
|
26
|
+
schedule: store.scheduleWithEstimates?.schedule ?? null,
|
|
27
|
+
triage: store.triage,
|
|
28
|
+
watched: store.watched,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// j/k walk the sections in reading order (bp-67g.45).
|
|
33
|
+
watchEffect(() => {
|
|
34
|
+
const m = model.value;
|
|
35
|
+
store.publishViewOrder(
|
|
36
|
+
m
|
|
37
|
+
? [
|
|
38
|
+
...m.ready, ...m.suggested, ...m.inProgress, ...m.inReview,
|
|
39
|
+
...m.waitingOnOthers.map((w) => w.bead), ...m.waitingOnMe.map((h) => h.bead), ...m.watching,
|
|
40
|
+
].map((b) => b.id)
|
|
41
|
+
: [],
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function pick(e: Event) {
|
|
46
|
+
const v = (e.target as HTMLSelectElement).value;
|
|
47
|
+
store.setCurrentUser(v || null);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const waitingNote = (w: WaitingItem) =>
|
|
51
|
+
"waiting on " + w.blockers.map((b) => (b.holder ? `${b.id} · ${b.holder}` : `${b.id} · unassigned`)).join(", ");
|
|
52
|
+
const holdingNote = (h: HoldingItem) =>
|
|
53
|
+
"via " + h.via.map((b) => b.id).join(", ") + (h.bead.assignee ? ` · for ${h.bead.assignee}` : " · unassigned");
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<template>
|
|
57
|
+
<div class="mine">
|
|
58
|
+
<!-- No user yet: the view is the prompt. -->
|
|
59
|
+
<section v-if="!me" class="mine-section mine-setup">
|
|
60
|
+
<h2>Who are you?</h2>
|
|
61
|
+
<p class="mine-why">
|
|
62
|
+
This view lists your own work: what is ready for you, what you have in
|
|
63
|
+
flight, who you are waiting on and who is waiting on you. Pick the
|
|
64
|
+
assignee handle that is you. It is stored in this browser and can be
|
|
65
|
+
changed later in the options menu.
|
|
66
|
+
</p>
|
|
67
|
+
<select class="mine-select" value="" aria-label="Current user" @change="pick">
|
|
68
|
+
<option value="">choose your handle…</option>
|
|
69
|
+
<option v-for="a in store.presentAssignees" :key="a" :value="a">{{ a }}</option>
|
|
70
|
+
</select>
|
|
71
|
+
<p v-if="store.beads.length && !store.presentAssignees.length" class="mine-empty">
|
|
72
|
+
No bead in this project carries an assignee yet, so there is nobody to be.
|
|
73
|
+
</p>
|
|
74
|
+
</section>
|
|
75
|
+
|
|
76
|
+
<template v-else-if="model">
|
|
77
|
+
<header class="mine-header">
|
|
78
|
+
<h1><span class="mine-label">mine</span>{{ me }}</h1>
|
|
79
|
+
<p v-if="store.userIsStale" class="mine-stale">
|
|
80
|
+
{{ me }} is not an assignee in this project.
|
|
81
|
+
<select class="mine-select" value="" aria-label="Current user" @change="pick">
|
|
82
|
+
<option value="">pick again…</option>
|
|
83
|
+
<option v-for="a in store.presentAssignees" :key="a" :value="a">{{ a }}</option>
|
|
84
|
+
</select>
|
|
85
|
+
</p>
|
|
86
|
+
<p v-else class="mine-sub">
|
|
87
|
+
assigned to you<template v-if="store.urlUser"> · from this link</template> · change in options
|
|
88
|
+
</p>
|
|
89
|
+
</header>
|
|
90
|
+
|
|
91
|
+
<section class="mine-section">
|
|
92
|
+
<h2>Ready for you <span class="count">{{ model.ready.length }}</span></h2>
|
|
93
|
+
<p class="mine-why">Assigned to you, approved (spec:ready), not already moving, every blocker closed.</p>
|
|
94
|
+
<ul v-if="model.ready.length" class="mine-list">
|
|
95
|
+
<MineRow v-for="b in model.ready" :key="b.id" :bead="b" />
|
|
96
|
+
</ul>
|
|
97
|
+
<p v-else class="mine-empty">Nothing is ready for you right now.</p>
|
|
98
|
+
<template v-if="model.suggested.length">
|
|
99
|
+
<h3>Suggested <span class="count">{{ model.suggested.length }}</span></h3>
|
|
100
|
+
<p class="mine-why">Unassigned and ready; the scheduler would route these to you by affinity.</p>
|
|
101
|
+
<ul class="mine-list">
|
|
102
|
+
<MineRow v-for="b in model.suggested" :key="b.id" :bead="b" note="inferred" />
|
|
103
|
+
</ul>
|
|
104
|
+
</template>
|
|
105
|
+
</section>
|
|
106
|
+
|
|
107
|
+
<section class="mine-section">
|
|
108
|
+
<h2>In flight <span class="count">{{ model.inProgress.length + model.inReview.length }}</span></h2>
|
|
109
|
+
<h3>In progress <span class="count">{{ model.inProgress.length }}</span></h3>
|
|
110
|
+
<ul v-if="model.inProgress.length" class="mine-list">
|
|
111
|
+
<MineRow v-for="b in model.inProgress" :key="b.id" :bead="b" />
|
|
112
|
+
</ul>
|
|
113
|
+
<p v-else class="mine-empty">Nothing in progress.</p>
|
|
114
|
+
<h3>In review <span class="count">{{ model.inReview.length }}</span></h3>
|
|
115
|
+
<ul v-if="model.inReview.length" class="mine-list">
|
|
116
|
+
<MineRow v-for="b in model.inReview" :key="b.id" :bead="b" />
|
|
117
|
+
</ul>
|
|
118
|
+
<p v-else class="mine-empty">Nothing in review.</p>
|
|
119
|
+
</section>
|
|
120
|
+
|
|
121
|
+
<section class="mine-section">
|
|
122
|
+
<h2>Waiting on others <span class="count">{{ model.waitingOnOthers.length }}</span></h2>
|
|
123
|
+
<p class="mine-why">Your beads that cannot move yet, and who holds what blocks them.</p>
|
|
124
|
+
<ul v-if="model.waitingOnOthers.length" class="mine-list">
|
|
125
|
+
<MineRow v-for="w in model.waitingOnOthers" :key="w.bead.id" :bead="w.bead" :note="waitingNote(w)" />
|
|
126
|
+
</ul>
|
|
127
|
+
<p v-else class="mine-empty">Nothing of yours is blocked.</p>
|
|
128
|
+
</section>
|
|
129
|
+
|
|
130
|
+
<section class="mine-section">
|
|
131
|
+
<h2>Waiting on you <span class="count">{{ model.waitingOnMe.length }}</span></h2>
|
|
132
|
+
<p class="mine-why">Other people's beads held up by something you have open.</p>
|
|
133
|
+
<ul v-if="model.waitingOnMe.length" class="mine-list">
|
|
134
|
+
<MineRow v-for="h in model.waitingOnMe" :key="h.bead.id" :bead="h.bead" :note="holdingNote(h)" />
|
|
135
|
+
</ul>
|
|
136
|
+
<p v-else class="mine-empty">Nobody is waiting on you.</p>
|
|
137
|
+
</section>
|
|
138
|
+
|
|
139
|
+
<section class="mine-section">
|
|
140
|
+
<h2>Watching <span class="count">{{ model.watching.length }}</span></h2>
|
|
141
|
+
<ul v-if="model.watching.length" class="mine-list">
|
|
142
|
+
<MineRow v-for="b in model.watching" :key="b.id" :bead="b" />
|
|
143
|
+
</ul>
|
|
144
|
+
<p v-else class="mine-empty">Nothing watched. Pin a bead from its tooltip or drawer.</p>
|
|
145
|
+
</section>
|
|
146
|
+
</template>
|
|
147
|
+
</div>
|
|
148
|
+
</template>
|
|
149
|
+
|
|
150
|
+
<style>
|
|
151
|
+
.mine {
|
|
152
|
+
display: grid;
|
|
153
|
+
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
|
|
154
|
+
gap: 16px;
|
|
155
|
+
width: 100%;
|
|
156
|
+
box-sizing: border-box;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.mine-header {
|
|
160
|
+
grid-column: 1 / -1;
|
|
161
|
+
display: flex;
|
|
162
|
+
align-items: baseline;
|
|
163
|
+
gap: 14px;
|
|
164
|
+
padding: 0 2px;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.mine-header h1 {
|
|
168
|
+
margin: 0;
|
|
169
|
+
font-size: 16px;
|
|
170
|
+
font-weight: 600;
|
|
171
|
+
color: var(--c-text);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.mine-label {
|
|
175
|
+
color: var(--c-accent);
|
|
176
|
+
text-transform: uppercase;
|
|
177
|
+
font-size: 11px;
|
|
178
|
+
letter-spacing: 0.06em;
|
|
179
|
+
margin-right: 8px;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.mine-sub,
|
|
183
|
+
.mine-why,
|
|
184
|
+
.mine-empty {
|
|
185
|
+
margin: 0;
|
|
186
|
+
font-size: 11px;
|
|
187
|
+
color: var(--c-dim);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.mine-stale {
|
|
191
|
+
margin: 0;
|
|
192
|
+
font-size: 11px;
|
|
193
|
+
color: var(--c-danger);
|
|
194
|
+
display: inline-flex;
|
|
195
|
+
align-items: center;
|
|
196
|
+
gap: 8px;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.mine-empty {
|
|
200
|
+
padding: 8px 4px 2px;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.mine-section {
|
|
204
|
+
background: var(--c-panel);
|
|
205
|
+
border: 1px solid var(--c-border);
|
|
206
|
+
border-radius: 4px;
|
|
207
|
+
padding: 12px 14px;
|
|
208
|
+
min-width: 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.mine-setup {
|
|
212
|
+
grid-column: 1 / -1;
|
|
213
|
+
max-width: 560px;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.mine-setup .mine-why {
|
|
217
|
+
margin: 6px 0 12px;
|
|
218
|
+
line-height: 1.5;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.mine-section h2,
|
|
222
|
+
.mine-section h3 {
|
|
223
|
+
display: flex;
|
|
224
|
+
justify-content: space-between;
|
|
225
|
+
align-items: baseline;
|
|
226
|
+
margin: 0 0 4px;
|
|
227
|
+
font-size: 11px;
|
|
228
|
+
font-weight: 600;
|
|
229
|
+
text-transform: uppercase;
|
|
230
|
+
letter-spacing: 0.06em;
|
|
231
|
+
color: var(--c-dim);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.mine-section h3 {
|
|
235
|
+
margin-top: 14px;
|
|
236
|
+
font-weight: 500;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.mine-section .count {
|
|
240
|
+
font-family: ui-monospace, monospace;
|
|
241
|
+
font-weight: 500;
|
|
242
|
+
color: var(--c-text);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.mine-list {
|
|
246
|
+
list-style: none;
|
|
247
|
+
margin: 8px 0 0;
|
|
248
|
+
padding: 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.mine-row {
|
|
252
|
+
display: flex;
|
|
253
|
+
align-items: center;
|
|
254
|
+
gap: 8px;
|
|
255
|
+
padding: 6px 4px;
|
|
256
|
+
border-top: 1px solid var(--c-border);
|
|
257
|
+
font-size: 12px;
|
|
258
|
+
cursor: pointer;
|
|
259
|
+
transition: background 0.08s ease-out;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.mine-row:hover {
|
|
263
|
+
background: var(--c-accent-wash);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.mine-id {
|
|
267
|
+
flex: none;
|
|
268
|
+
font-family: ui-monospace, monospace;
|
|
269
|
+
font-size: 11px;
|
|
270
|
+
color: var(--c-dim);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.mine-title {
|
|
274
|
+
flex: 1;
|
|
275
|
+
min-width: 0;
|
|
276
|
+
overflow: hidden;
|
|
277
|
+
text-overflow: ellipsis;
|
|
278
|
+
white-space: nowrap;
|
|
279
|
+
color: var(--c-text);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.mine-note {
|
|
283
|
+
flex: none;
|
|
284
|
+
max-width: 38%;
|
|
285
|
+
overflow: hidden;
|
|
286
|
+
text-overflow: ellipsis;
|
|
287
|
+
white-space: nowrap;
|
|
288
|
+
font-size: 11px;
|
|
289
|
+
color: var(--c-dim);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.mine .pill {
|
|
293
|
+
flex: none;
|
|
294
|
+
font-size: 10px;
|
|
295
|
+
padding: 1px 6px;
|
|
296
|
+
border-radius: 10px;
|
|
297
|
+
border: 1px solid var(--c-border);
|
|
298
|
+
color: var(--c-dim);
|
|
299
|
+
white-space: nowrap;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.mine .pill.status-in-progress {
|
|
303
|
+
color: var(--c-accent);
|
|
304
|
+
border-color: var(--c-accent);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.mine .pill.status-in-review {
|
|
308
|
+
color: var(--c-warning);
|
|
309
|
+
border-color: var(--c-warning);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
.mine .pill.status-closed {
|
|
313
|
+
opacity: 0.6;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.mine-select {
|
|
317
|
+
font: inherit;
|
|
318
|
+
font-size: 12px;
|
|
319
|
+
padding: 4px 8px;
|
|
320
|
+
background: var(--c-bg);
|
|
321
|
+
color: var(--c-text);
|
|
322
|
+
border: 1px solid var(--c-border);
|
|
323
|
+
border-radius: 3px;
|
|
324
|
+
}
|
|
325
|
+
</style>
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// BeadsTable.vue — the table view (bp-67g.44): one dense row per bead,
|
|
3
|
+
// every column sortable. The surface for scanning and comparing; the Gantt
|
|
4
|
+
// is for time and the grid for browsing.
|
|
5
|
+
//
|
|
6
|
+
// Rows come from store.tableViewModel — the grid's filtered, windowed set
|
|
7
|
+
// with the per-group cap lifted — through table.ts, which also owns the sort.
|
|
8
|
+
// Clicking a row opens the drawer, as a card or a bar does. This is also the
|
|
9
|
+
// one view where every derived number is visible at once instead of behind
|
|
10
|
+
// a hover, which makes it the readable equivalent of the Gantt: nothing in
|
|
11
|
+
// the app is reachable only by reading a bar.
|
|
12
|
+
//
|
|
13
|
+
// The controls that paint the other views (highlight, dependency arrows)
|
|
14
|
+
// do not reach here; controls-scope.ts says so and the panel dims them.
|
|
15
|
+
|
|
16
|
+
import { computed, ref, watchEffect } from "vue";
|
|
17
|
+
import { useBeadsStore } from "../store";
|
|
18
|
+
import { formatEstimate } from "../bead-detail";
|
|
19
|
+
import { SYNTHETIC_ASSIGNEE_NOTE } from "../view-model";
|
|
20
|
+
import {
|
|
21
|
+
buildTableRows,
|
|
22
|
+
sortRows,
|
|
23
|
+
toggleSort,
|
|
24
|
+
describeSort,
|
|
25
|
+
COLUMNS,
|
|
26
|
+
DEFAULT_SORT,
|
|
27
|
+
type TableSort,
|
|
28
|
+
type TableSortKey,
|
|
29
|
+
} from "../table";
|
|
30
|
+
import PriorityChip from "./PriorityChip.vue";
|
|
31
|
+
import TypeChip from "./TypeChip.vue";
|
|
32
|
+
import Term from "./Term.vue";
|
|
33
|
+
|
|
34
|
+
const store = useBeadsStore();
|
|
35
|
+
|
|
36
|
+
const sort = ref<TableSort>({ ...DEFAULT_SORT });
|
|
37
|
+
|
|
38
|
+
const rows = computed(() => {
|
|
39
|
+
const vm = store.tableViewModel;
|
|
40
|
+
if (!vm) return [];
|
|
41
|
+
const detailFor = store.beadDetailFor;
|
|
42
|
+
const byId = new Map(store.beads.map((b) => [b.id, b] as const));
|
|
43
|
+
return buildTableRows(vm, {
|
|
44
|
+
detailFor,
|
|
45
|
+
triage: store.triage,
|
|
46
|
+
labelsFor: (id) => byId.get(id)?.labels ?? [],
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const sorted = computed(() => sortRows(rows.value, sort.value));
|
|
51
|
+
// j/k walk the rows in the order on screen, which only this component knows.
|
|
52
|
+
watchEffect(() => store.publishViewOrder(sorted.value.map((r) => r.id)));
|
|
53
|
+
const groupCount = computed(() => store.tableViewModel?.groups.length ?? 0);
|
|
54
|
+
|
|
55
|
+
function onSort(key: TableSortKey) {
|
|
56
|
+
sort.value = toggleSort(sort.value, key);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ariaSort = (key: TableSortKey) =>
|
|
60
|
+
sort.value.key === key ? (sort.value.dir === "asc" ? "ascending" : "descending") : "none";
|
|
61
|
+
|
|
62
|
+
const fmtPct = (v: number) => `${(v * 100).toFixed(1)}%`;
|
|
63
|
+
const fmtDay = (d: Date) => d.toLocaleDateString(undefined, { day: "numeric", month: "short" });
|
|
64
|
+
const statusClass = (s: string) => `status-${s.replace(/_/g, "-")}`;
|
|
65
|
+
|
|
66
|
+
function onRowKey(e: KeyboardEvent, id: string) {
|
|
67
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
store.openBead(id);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<template>
|
|
75
|
+
<div class="beads-table">
|
|
76
|
+
<div class="table-scroll">
|
|
77
|
+
<table>
|
|
78
|
+
<thead>
|
|
79
|
+
<tr>
|
|
80
|
+
<th
|
|
81
|
+
v-for="c in COLUMNS"
|
|
82
|
+
:key="c.key"
|
|
83
|
+
scope="col"
|
|
84
|
+
:class="[`col-${c.key}`, { 'is-sorted': sort.key === c.key, 'is-numeric': c.numeric }]"
|
|
85
|
+
:aria-sort="ariaSort(c.key)"
|
|
86
|
+
>
|
|
87
|
+
<button type="button" class="sort" @click="onSort(c.key)">
|
|
88
|
+
{{ c.label }}
|
|
89
|
+
<span class="dir" aria-hidden="true">{{
|
|
90
|
+
sort.key === c.key ? (sort.dir === "asc" ? "▲" : "▼") : ""
|
|
91
|
+
}}</span>
|
|
92
|
+
</button>
|
|
93
|
+
</th>
|
|
94
|
+
</tr>
|
|
95
|
+
</thead>
|
|
96
|
+
<tbody>
|
|
97
|
+
<tr
|
|
98
|
+
v-for="r in sorted"
|
|
99
|
+
:key="r.id"
|
|
100
|
+
tabindex="0"
|
|
101
|
+
:class="{ 'is-closed': r.status === 'closed', 'is-open': store.openBeadId === r.id }"
|
|
102
|
+
@click="store.openBead(r.id)"
|
|
103
|
+
@keydown="onRowKey($event, r.id)"
|
|
104
|
+
@contextmenu.prevent="store.openContextMenu(r.id, $event.clientX, $event.clientY)"
|
|
105
|
+
>
|
|
106
|
+
<td class="col-id"><code>{{ r.id }}</code></td>
|
|
107
|
+
<td class="col-title" :title="r.title">{{ r.title }}</td>
|
|
108
|
+
<td class="col-type"><TypeChip :type="r.type" /></td>
|
|
109
|
+
<td class="col-priority"><PriorityChip :priority="r.priority" /></td>
|
|
110
|
+
<td class="col-status">
|
|
111
|
+
<span class="pill" :class="statusClass(r.status)">{{ r.status.replace(/_/g, " ") }}</span>
|
|
112
|
+
</td>
|
|
113
|
+
<td class="col-assignee">
|
|
114
|
+
<template v-if="r.assignee">
|
|
115
|
+
<code>{{ r.assignee }}</code>
|
|
116
|
+
<Term v-if="r.synthetic" class="inferred" :note="SYNTHETIC_ASSIGNEE_NOTE">inferred</Term>
|
|
117
|
+
</template>
|
|
118
|
+
<span v-else class="none">—</span>
|
|
119
|
+
</td>
|
|
120
|
+
<td class="col-labels">
|
|
121
|
+
<span v-for="l in r.labels" :key="l" class="label">{{ l }}</span>
|
|
122
|
+
</td>
|
|
123
|
+
<td class="col-estimate is-numeric">
|
|
124
|
+
<template v-if="r.estimateDays !== null">{{ formatEstimate(r.estimateDays) }}</template>
|
|
125
|
+
<span v-else class="none">—</span>
|
|
126
|
+
</td>
|
|
127
|
+
<td class="col-finish is-numeric">
|
|
128
|
+
<template v-if="r.finishDate">
|
|
129
|
+
<span v-if="r.beyondHorizon" class="beyond" title="The scheduling window's end — a bound, not a projection">after {{ fmtDay(r.finishDate) }}</span>
|
|
130
|
+
<template v-else>{{ fmtDay(r.finishDate) }}</template>
|
|
131
|
+
</template>
|
|
132
|
+
<span v-else class="none">—</span>
|
|
133
|
+
</td>
|
|
134
|
+
<td class="col-triage is-numeric">
|
|
135
|
+
<template v-if="r.triage !== null">{{ fmtPct(r.triage) }}</template>
|
|
136
|
+
<span v-else class="none">—</span>
|
|
137
|
+
</td>
|
|
138
|
+
<td class="col-group" :title="r.group">{{ r.group }}</td>
|
|
139
|
+
</tr>
|
|
140
|
+
</tbody>
|
|
141
|
+
</table>
|
|
142
|
+
</div>
|
|
143
|
+
<p v-if="rows.length === 0" class="empty-state">
|
|
144
|
+
No beads match the current filters and window.
|
|
145
|
+
</p>
|
|
146
|
+
<!-- Its own footer: bars are a timeline concept, and "every row" is the
|
|
147
|
+
point here, so the line says the cap is off. -->
|
|
148
|
+
<p v-else class="table-footer">
|
|
149
|
+
<code>{{ rows.length }}</code> rows · <code>{{ groupCount }}</code> groups ·
|
|
150
|
+
{{ describeSort(sort) }} · every row the filters and window admit, no per-group cap
|
|
151
|
+
</p>
|
|
152
|
+
</div>
|
|
153
|
+
</template>
|
|
154
|
+
|
|
155
|
+
<style>
|
|
156
|
+
.beads-table {
|
|
157
|
+
width: 100%;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.beads-table .table-scroll {
|
|
161
|
+
overflow-x: auto;
|
|
162
|
+
border: 1px solid var(--c-border);
|
|
163
|
+
border-radius: 4px;
|
|
164
|
+
background: var(--c-panel);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.beads-table table {
|
|
168
|
+
width: 100%;
|
|
169
|
+
border-collapse: collapse;
|
|
170
|
+
font-size: 12px;
|
|
171
|
+
line-height: 1.3;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.beads-table th,
|
|
175
|
+
.beads-table td {
|
|
176
|
+
padding: 4px 8px;
|
|
177
|
+
text-align: left;
|
|
178
|
+
vertical-align: middle;
|
|
179
|
+
white-space: nowrap;
|
|
180
|
+
border-bottom: 1px solid var(--c-border);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.beads-table th {
|
|
184
|
+
position: sticky;
|
|
185
|
+
top: 0;
|
|
186
|
+
z-index: 1;
|
|
187
|
+
background: var(--c-panel);
|
|
188
|
+
border-bottom: 1px solid var(--c-border-strong);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.beads-table th .sort {
|
|
192
|
+
all: unset;
|
|
193
|
+
display: inline-flex;
|
|
194
|
+
align-items: center;
|
|
195
|
+
gap: 4px;
|
|
196
|
+
cursor: pointer;
|
|
197
|
+
font-size: 10px;
|
|
198
|
+
font-weight: 600;
|
|
199
|
+
text-transform: uppercase;
|
|
200
|
+
letter-spacing: 0.05em;
|
|
201
|
+
color: var(--c-dim);
|
|
202
|
+
padding: 4px 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.beads-table th .sort:hover,
|
|
206
|
+
.beads-table th.is-sorted .sort {
|
|
207
|
+
color: var(--c-text);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.beads-table th .dir {
|
|
211
|
+
display: inline-block;
|
|
212
|
+
width: 1em;
|
|
213
|
+
font-size: 9px;
|
|
214
|
+
color: var(--c-accent);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.beads-table th.is-numeric,
|
|
218
|
+
.beads-table td.is-numeric {
|
|
219
|
+
text-align: right;
|
|
220
|
+
font-variant-numeric: tabular-nums;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.beads-table th.is-numeric .sort {
|
|
224
|
+
flex-direction: row-reverse;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.beads-table tbody tr {
|
|
228
|
+
cursor: pointer;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.beads-table tbody tr:hover,
|
|
232
|
+
.beads-table tbody tr:focus-visible {
|
|
233
|
+
background: var(--c-lift);
|
|
234
|
+
outline: none;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.beads-table tbody tr:focus-visible td:first-child,
|
|
238
|
+
.beads-table tbody tr.is-open td:first-child {
|
|
239
|
+
box-shadow: inset 2px 0 0 var(--c-accent);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* The open bead is the keyboard cursor (bp-67g.45): marked in the view too. */
|
|
243
|
+
.beads-table tbody tr.is-open {
|
|
244
|
+
background: var(--c-accent-wash);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.beads-table tbody tr.is-closed td {
|
|
248
|
+
color: var(--c-dim);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.beads-table code {
|
|
252
|
+
font-family: ui-monospace, monospace;
|
|
253
|
+
font-size: 11px;
|
|
254
|
+
color: var(--c-accent);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.beads-table tr.is-closed code {
|
|
258
|
+
color: var(--c-dim-bright);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.beads-table .col-title {
|
|
262
|
+
max-width: 34ch;
|
|
263
|
+
overflow: hidden;
|
|
264
|
+
text-overflow: ellipsis;
|
|
265
|
+
color: var(--c-text);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.beads-table .col-group {
|
|
269
|
+
max-width: 24ch;
|
|
270
|
+
overflow: hidden;
|
|
271
|
+
text-overflow: ellipsis;
|
|
272
|
+
color: var(--c-dim);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.beads-table .none {
|
|
276
|
+
color: var(--c-dim);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.beads-table .inferred {
|
|
280
|
+
margin-left: 6px;
|
|
281
|
+
font-size: 10px;
|
|
282
|
+
color: var(--c-dim);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.beads-table .label {
|
|
286
|
+
display: inline-block;
|
|
287
|
+
margin-right: 4px;
|
|
288
|
+
padding: 0 5px;
|
|
289
|
+
border: 1px solid var(--c-border);
|
|
290
|
+
border-radius: 3px;
|
|
291
|
+
font-size: 10px;
|
|
292
|
+
color: var(--c-dim);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.beads-table .beyond {
|
|
296
|
+
color: var(--c-dim);
|
|
297
|
+
font-style: italic;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/* Same pill vocabulary as the grid cards. */
|
|
301
|
+
.beads-table .pill {
|
|
302
|
+
display: inline-block;
|
|
303
|
+
padding: 0 6px;
|
|
304
|
+
border-radius: 3px;
|
|
305
|
+
border: 1px solid var(--c-border);
|
|
306
|
+
font-size: 10px;
|
|
307
|
+
color: var(--c-dim);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.beads-table .pill.status-closed {
|
|
311
|
+
border-color: var(--c-shipped);
|
|
312
|
+
color: var(--c-shipped);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.beads-table .pill.status-in-progress {
|
|
316
|
+
border-color: var(--c-in-progress);
|
|
317
|
+
color: var(--c-in-progress);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
.beads-table .pill.status-deferred {
|
|
321
|
+
border-color: var(--c-closed-noship);
|
|
322
|
+
color: var(--c-closed-noship);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.beads-table .empty-state,
|
|
326
|
+
.beads-table .table-footer {
|
|
327
|
+
color: var(--c-dim);
|
|
328
|
+
font-size: 11px;
|
|
329
|
+
margin: 6px 4px 0;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
.beads-table .table-footer code {
|
|
333
|
+
color: var(--c-accent);
|
|
334
|
+
}
|
|
335
|
+
</style>
|