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.
Files changed (108) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/LICENSE +661 -0
  3. package/README.md +386 -0
  4. package/THIRD_PARTY_NOTICES.md +56 -0
  5. package/bin/beadcyte.mjs +60 -0
  6. package/package.json +77 -0
  7. package/src/changelog-cli.mjs +105 -0
  8. package/src/changelog.mjs +196 -0
  9. package/src/cli.mjs +577 -0
  10. package/src/estimator.mjs +314 -0
  11. package/src/format.mjs +22 -0
  12. package/src/history-walk.mjs +170 -0
  13. package/src/index.mjs +5 -0
  14. package/src/mutate.mjs +193 -0
  15. package/src/projects.mjs +120 -0
  16. package/src/provenance.mjs +75 -0
  17. package/src/review-hours.mjs +117 -0
  18. package/src/roster-path.mjs +24 -0
  19. package/src/scheduler.mjs +424 -0
  20. package/src/serve.mjs +411 -0
  21. package/src/server-state.mjs +105 -0
  22. package/src/ship.mjs +178 -0
  23. package/src/stage-waits.mjs +69 -0
  24. package/src/start.mjs +111 -0
  25. package/src/stop.mjs +66 -0
  26. package/src/velocity.mjs +209 -0
  27. package/src/web/App.vue +691 -0
  28. package/src/web/app.css +54 -0
  29. package/src/web/assets/favicon.svg +12 -0
  30. package/src/web/avatar.ts +53 -0
  31. package/src/web/bead-detail.ts +240 -0
  32. package/src/web/changelog-view.ts +41 -0
  33. package/src/web/components/BeadDrawer.vue +1883 -0
  34. package/src/web/components/BeadSubGraph.vue +326 -0
  35. package/src/web/components/BeadSubGraphOverlay.vue +192 -0
  36. package/src/web/components/BeadTooltip.vue +516 -0
  37. package/src/web/components/BeadcyteMark.vue +64 -0
  38. package/src/web/components/BeadsGantt.vue +2125 -0
  39. package/src/web/components/BeadsGrid.vue +468 -0
  40. package/src/web/components/BeadsIncytes.vue +567 -0
  41. package/src/web/components/BeadsMine.vue +325 -0
  42. package/src/web/components/BeadsTable.vue +335 -0
  43. package/src/web/components/ChangelogOverlay.vue +198 -0
  44. package/src/web/components/ContextMenu.vue +386 -0
  45. package/src/web/components/ControlsPanel.vue +476 -0
  46. package/src/web/components/CostTrend.vue +206 -0
  47. package/src/web/components/FilterPopover.vue +245 -0
  48. package/src/web/components/GroupProgress.vue +274 -0
  49. package/src/web/components/LoadMeter.vue +144 -0
  50. package/src/web/components/MineRow.vue +28 -0
  51. package/src/web/components/OptionsMenu.vue +825 -0
  52. package/src/web/components/PriorityChip.vue +105 -0
  53. package/src/web/components/ScoreStrip.vue +131 -0
  54. package/src/web/components/SearchPalette.vue +210 -0
  55. package/src/web/components/ShipTrend.vue +510 -0
  56. package/src/web/components/ShortcutsOverlay.vue +164 -0
  57. package/src/web/components/Term.vue +177 -0
  58. package/src/web/components/Toast.vue +50 -0
  59. package/src/web/components/TriageMeters.vue +426 -0
  60. package/src/web/components/TypeChip.vue +96 -0
  61. package/src/web/components/Walkthrough.vue +209 -0
  62. package/src/web/components/WhatIfPanel.vue +206 -0
  63. package/src/web/components/WipBullets.vue +191 -0
  64. package/src/web/components/filter-option.ts +9 -0
  65. package/src/web/composables/url-codec.ts +136 -0
  66. package/src/web/composables/useBeadTooltip.ts +148 -0
  67. package/src/web/composables/useKeyboard.ts +97 -0
  68. package/src/web/composables/useLiveRefresh.ts +69 -0
  69. package/src/web/composables/useTheme.ts +125 -0
  70. package/src/web/composables/useUrlState.ts +208 -0
  71. package/src/web/controls-scope.ts +83 -0
  72. package/src/web/cost.ts +251 -0
  73. package/src/web/dep-headings.ts +62 -0
  74. package/src/web/economics.ts +440 -0
  75. package/src/web/env.d.ts +85 -0
  76. package/src/web/frontier.ts +208 -0
  77. package/src/web/gantt-viewport.ts +99 -0
  78. package/src/web/highlights.ts +124 -0
  79. package/src/web/index.html +46 -0
  80. package/src/web/insights.ts +107 -0
  81. package/src/web/keybindings.ts +200 -0
  82. package/src/web/load-meter.ts +72 -0
  83. package/src/web/main.ts +20 -0
  84. package/src/web/markdown.ts +14 -0
  85. package/src/web/mine.ts +137 -0
  86. package/src/web/mutations.ts +21 -0
  87. package/src/web/person.ts +102 -0
  88. package/src/web/projects-text.ts +15 -0
  89. package/src/web/projects.ts +188 -0
  90. package/src/web/refresh.ts +47 -0
  91. package/src/web/search.ts +50 -0
  92. package/src/web/shortcuts.ts +113 -0
  93. package/src/web/status-filter.ts +48 -0
  94. package/src/web/store.ts +1378 -0
  95. package/src/web/style-audit.mjs +346 -0
  96. package/src/web/styles-alt.css +111 -0
  97. package/src/web/styles-ported.css +270 -0
  98. package/src/web/subgraph.ts +362 -0
  99. package/src/web/table.ts +201 -0
  100. package/src/web/theme.ts +88 -0
  101. package/src/web/tokens.css +168 -0
  102. package/src/web/triage.ts +914 -0
  103. package/src/web/view-model.ts +717 -0
  104. package/src/web/walkthrough.ts +133 -0
  105. package/src/web/watchlist.ts +47 -0
  106. package/src/web/whatif.ts +291 -0
  107. package/src/web/window.ts +73 -0
  108. package/src/web/wip.ts +83 -0
@@ -0,0 +1,468 @@
1
+ <script setup lang="ts">
2
+ // BeadsGrid.vue — DOM card grid view of beads, grouped by the same
3
+ // rules as the Gantt chart. Each bead renders as a card with id,
4
+ // title, priority, status, and assignee. Group headers show the
5
+ // same progress chip as the Gantt.
6
+ //
7
+ // Uses the same ViewModel prop as BeadsGantt so the grouping,
8
+ // filtering, and truncation are consistent.
9
+
10
+ import { computed } from "vue";
11
+ import type { ViewModel, GroupBy } from "../view-model";
12
+ import { useBeadsStore } from "../store";
13
+ import PriorityChip from "./PriorityChip.vue";
14
+ import TypeChip from "./TypeChip.vue";
15
+ import { initialsOf, colorsFor } from "../avatar";
16
+ import { Bot, CircleHelp, Pin } from "@lucide/vue";
17
+
18
+ const props = defineProps<{
19
+ vm: ViewModel | null;
20
+ }>();
21
+
22
+ const store = useBeadsStore();
23
+
24
+ const groupBy = computed<GroupBy>(() => store.groupBy);
25
+
26
+ // "none" is one synthetic bucket with nothing to label: the header, its
27
+ // progress chip and the group border all go, leaving a flat card list.
28
+ const isUngrouped = computed(() => store.groupBy === "none");
29
+
30
+ const blockedIds = computed(() => store.blockedBeadIds);
31
+ const readyNowIds = computed(() => store.readyNowBeadIds);
32
+ const overCapAssignees = computed(() => store.overCapAssignees);
33
+
34
+ function isGroupOverCap(gid: string): boolean {
35
+ if (!store.highlights["over-cap"]) return false;
36
+ if (store.groupBy !== "assignee") return false;
37
+ return overCapAssignees.value.has(gid);
38
+ }
39
+
40
+ function statusClass(status: string): string {
41
+ return `status-${status.replace(/_/g, "-")}`;
42
+ }
43
+
44
+ function priorityColor(priority: number): string {
45
+ switch (priority) {
46
+ case 0:
47
+ return "var(--c-danger)";
48
+ case 1:
49
+ return "var(--c-warning-strong)";
50
+ case 2:
51
+ return "var(--c-warning)";
52
+ case 3:
53
+ return "var(--c-accent)";
54
+ default:
55
+ return "var(--c-closed-noship)";
56
+ }
57
+ }
58
+
59
+ const watched = computed(() => store.watchedSet);
60
+ const isWatched = (id: string) => watched.value.has(id);
61
+
62
+ // When filtering to a person, beads that are only *inferred* to be theirs
63
+ // still belong in the result — that's what the chart shows — but they're a
64
+ // guess, so they recede rather than sitting level with recorded work.
65
+ const assigneeFilterActive = computed(
66
+ () => (store.filters.assignees?.length ?? 0) > 0,
67
+ );
68
+ const isInferredMatch = (row: { syntheticAssignee: boolean }) =>
69
+ assigneeFilterActive.value && row.syntheticAssignee;
70
+
71
+ /** The bead behind a group, when the group *is* a bead — epic grouping
72
+ * only, and never a "__" placeholder bucket. */
73
+ function groupBeadId(gid: string): string | null {
74
+ if (store.groupBy !== "epic") return null;
75
+ if (gid.startsWith("__")) return null;
76
+ return gid;
77
+ }
78
+
79
+ /** An assignee lane's header is a handle: it opens that person (bp-67g.43). */
80
+ function groupPerson(gid: string): string | null {
81
+ if (store.groupBy !== "assignee") return null;
82
+ if (gid.startsWith("__")) return null;
83
+ return gid;
84
+ }
85
+
86
+ const isGroupOpenable = (gid: string) => !!groupBeadId(gid) || !!groupPerson(gid);
87
+
88
+ function openGroupBead(gid: string) {
89
+ const id = groupBeadId(gid);
90
+ if (id) return store.openBead(id);
91
+ const who = groupPerson(gid);
92
+ if (who) store.openPerson(who);
93
+ }
94
+
95
+ function onBeadClick(beadId: string) {
96
+ store.openBead(beadId);
97
+ }
98
+
99
+ const progressFraction = (p: { closed: number; total: number }): number =>
100
+ p.total > 0 ? p.closed / p.total : 0;
101
+ </script>
102
+
103
+ <template>
104
+ <div class="beads-grid" v-if="vm">
105
+ <div
106
+ v-for="g in vm.groups"
107
+ :key="g.id"
108
+ class="grid-group"
109
+ :class="{
110
+ 'is-ungrouped': isUngrouped,
111
+ 'is-over-cap': isGroupOverCap(g.id),
112
+ }"
113
+ >
114
+ <div v-if="!isUngrouped" class="group-header">
115
+ <span
116
+ class="group-label"
117
+ :class="{ 'is-openable': isGroupOpenable(g.id) }"
118
+ :title="groupBeadId(g.id) ? `Open ${g.id}` : groupPerson(g.id) ? `Show ${g.id}` : g.label"
119
+ @click.stop="openGroupBead(g.id)"
120
+ >{{ g.label }}</span>
121
+ <Pin v-if="isWatched(g.id)" class="icon watch-pin" />
122
+ <span v-if="isGroupOverCap(g.id)" class="over-cap-badge">⚠ over cap</span>
123
+ <span v-if="g.progress.total > 0" class="progress-chip">
124
+ {{ g.progress.closed }}/{{ g.progress.total }}
125
+ </span>
126
+ <span class="group-count">{{ g.rowCount }} bead{{ g.rowCount === 1 ? "" : "s" }}</span>
127
+ </div>
128
+
129
+ <div class="grid-cards">
130
+ <div
131
+ v-for="row in vm.rows"
132
+ :key="row.beadId"
133
+ v-show="row.groupId === g.id"
134
+ class="grid-card"
135
+ :class="{
136
+ 'is-inferred-match': isInferredMatch(row),
137
+ 'hl-blocked': blockedIds.has(row.beadId),
138
+ 'hl-ready-now': readyNowIds.has(row.beadId),
139
+ 'is-open': store.openBeadId === row.beadId,
140
+ }"
141
+ @click="onBeadClick(row.beadId)"
142
+ @contextmenu.prevent="store.openContextMenu(row.beadId, $event.clientX, $event.clientY)"
143
+ >
144
+ <div class="card-head">
145
+ <code class="card-id">{{ row.beadId }}</code>
146
+ <PriorityChip :priority="row.priority" />
147
+ <span class="pill" :class="statusClass(row.status)">
148
+ {{ row.status.replace(/_/g, " ") }}
149
+ </span>
150
+ <Pin v-if="isWatched(row.beadId)" class="icon watch-pin" />
151
+ </div>
152
+ <div class="card-title" :title="row.title">{{ row.title }}</div>
153
+ <div class="card-meta">
154
+ <TypeChip :type="row.issueType" />
155
+ <!-- Three states, each with its own glyph. A recorded assignee
156
+ gets their colour and initials; an inferred one gets a bot
157
+ and no colour, because we don't actually know it's them. -->
158
+ <!-- The handle leads to that person's picture on incytes
159
+ (bp-67g.43); stopped so the card does not also open. -->
160
+ <span
161
+ v-if="row.assignee && !row.syntheticAssignee"
162
+ class="assignee is-link"
163
+ :title="`Show ${row.assignee}`"
164
+ @click.stop="store.openPerson(row.assignee)"
165
+ >
166
+ <span
167
+ class="assignee-chip"
168
+ :style="{
169
+ background: colorsFor(row.assignee).bg,
170
+ color: colorsFor(row.assignee).fg,
171
+ }"
172
+ >{{ initialsOf(row.assignee) }}</span>
173
+ <span class="assignee-handle">{{ row.assignee }}</span>
174
+ </span>
175
+ <span
176
+ v-else-if="row.assignee"
177
+ class="assignee is-inferred is-link"
178
+ :title="`${row.assignee} — inferred by the scheduler, not recorded on the bead. Show ${row.assignee}`"
179
+ @click.stop="store.openPerson(row.assignee)"
180
+ >
181
+ <span class="assignee-chip"><Bot class="icon" /></span>
182
+ <span class="assignee-handle">{{ row.assignee }}</span>
183
+ </span>
184
+ <span v-else class="assignee is-unassigned" title="No assignee">
185
+ <span class="assignee-chip"><CircleHelp class="icon" /></span>
186
+ <span class="assignee-handle">unassigned</span>
187
+ </span>
188
+ </div>
189
+ </div>
190
+ </div>
191
+ </div>
192
+
193
+ <p v-if="vm.rows.length === 0" class="empty-state">
194
+ No beads match the current filters and window.
195
+ </p>
196
+ </div>
197
+ </template>
198
+
199
+ <style>
200
+ .beads-grid {
201
+ display: flex;
202
+ flex-direction: column;
203
+ gap: 12px;
204
+ padding: 16px 20px;
205
+ background: var(--c-bg);
206
+ border: 1px solid var(--c-border);
207
+ border-radius: 4px;
208
+ }
209
+
210
+ /* flex: none is load-bearing. A flex item's automatic minimum size is
211
+ content-based ONLY while its overflow is visible; `overflow: hidden` here
212
+ (which rounds the corners over the header) drops that floor to zero, so
213
+ every group would shrink toward nothing and spill its header and cards
214
+ over the group below. Invisible until the grid holds more than fits. */
215
+ .grid-group {
216
+ flex: none;
217
+ border: 1px solid var(--c-border);
218
+ border-radius: 4px;
219
+ overflow: hidden;
220
+ transition: opacity 0.12s ease-out;
221
+ }
222
+
223
+ /* Ungrouped: no header, so the frame around a single bucket is just a box
224
+ drawn round the whole list. Drop it and let the cards stand alone. */
225
+ .grid-group.is-ungrouped {
226
+ border: none;
227
+ border-radius: 0;
228
+ }
229
+
230
+ .grid-group.is-over-cap {
231
+ border-color: var(--c-danger);
232
+ }
233
+
234
+ .beads-grid .watch-pin {
235
+ color: var(--c-accent);
236
+ flex: none;
237
+ }
238
+
239
+ /* Only a group backed by a real bead opens; a facet value must not look
240
+ like a link. */
241
+ .beads-grid .group-label.is-openable {
242
+ cursor: pointer;
243
+ }
244
+
245
+ .beads-grid .group-label.is-openable:hover {
246
+ text-decoration: underline;
247
+ }
248
+
249
+ .beads-grid .group-header {
250
+ display: flex;
251
+ align-items: center;
252
+ gap: 8px;
253
+ padding: 8px 12px;
254
+ background: var(--c-border);
255
+ background: oklch(from var(--c-border) l c h / 60%);
256
+ user-select: none;
257
+ flex-wrap: wrap;
258
+ }
259
+
260
+ .beads-grid .group-header:hover {
261
+ background: oklch(from var(--c-border) l c h / 90%);
262
+ }
263
+
264
+ /* Wraps rather than ellipsising: the header is a flex row that already
265
+ wraps, and an epic's full title is worth the second line. */
266
+ .beads-grid .group-label {
267
+ font-size: 12px;
268
+ font-weight: 600;
269
+ color: var(--c-epic);
270
+ overflow-wrap: anywhere;
271
+ }
272
+
273
+ .beads-grid .over-cap-badge {
274
+ font-size: 10px;
275
+ font-weight: 600;
276
+ color: var(--c-danger);
277
+ }
278
+
279
+ .beads-grid .progress-chip {
280
+ font-size: 10px;
281
+ font-family: ui-monospace, monospace;
282
+ color: var(--c-dim);
283
+ padding: 1px 6px;
284
+ border: 1px solid var(--c-border);
285
+ border-radius: 2px;
286
+ background: var(--c-bg);
287
+ }
288
+
289
+ .beads-grid .group-count {
290
+ font-size: 10px;
291
+ color: var(--c-dim);
292
+ margin-left: auto;
293
+ }
294
+
295
+ .grid-cards {
296
+ display: grid;
297
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
298
+ gap: 8px;
299
+ padding: 8px;
300
+ }
301
+
302
+ /* The open bead is the keyboard cursor (bp-67g.45): marked in the view too. */
303
+ .grid-card.is-open {
304
+ outline: 1px solid var(--c-accent);
305
+ outline-offset: -1px;
306
+ }
307
+
308
+ .grid-card {
309
+ background: var(--c-panel);
310
+ border: 1px solid var(--c-border);
311
+ border-radius: 4px;
312
+ padding: 10px 12px;
313
+ cursor: pointer;
314
+ transition: border-color 0.08s ease-out, background 0.08s ease-out;
315
+ }
316
+
317
+ /* Matched only by an inferred assignment: present, but visibly a guess. */
318
+ .grid-card.is-inferred-match {
319
+ opacity: var(--o-inferred);
320
+ }
321
+
322
+ .grid-card.is-inferred-match:hover {
323
+ opacity: 1;
324
+ }
325
+
326
+ .grid-card:hover {
327
+ border-color: var(--c-accent);
328
+ background: var(--c-accent-wash);
329
+ }
330
+
331
+ .grid-card.hl-blocked {
332
+ border-left: 3px solid var(--c-danger);
333
+ }
334
+
335
+ .grid-card.hl-ready-now {
336
+ border-left: 3px solid var(--c-accent);
337
+ box-shadow: 0 0 4px oklch(from var(--c-accent) l c h / 30%);
338
+ }
339
+
340
+ .beads-grid .card-head {
341
+ display: flex;
342
+ align-items: center;
343
+ gap: 6px;
344
+ flex-wrap: wrap;
345
+ margin-bottom: 4px;
346
+ }
347
+
348
+ .beads-grid .card-id {
349
+ color: var(--c-accent);
350
+ font-family: ui-monospace, monospace;
351
+ font-size: 11px;
352
+ }
353
+
354
+ /* Wrap to at most three lines. Unclamped, one long title would stretch its
355
+ whole grid row; clamped, cards stay comparable in height and the ellipsis
356
+ still signals there's more. Unlike a character cap this responds to the
357
+ actual column width. */
358
+ .beads-grid .card-title {
359
+ font-size: 12px;
360
+ color: var(--c-text);
361
+ margin-bottom: 6px;
362
+ display: -webkit-box;
363
+ -webkit-box-orient: vertical;
364
+ -webkit-line-clamp: 3;
365
+ line-clamp: 3;
366
+ overflow: hidden;
367
+ overflow-wrap: anywhere;
368
+ }
369
+
370
+ /* Assignee. A recorded assignee carries their own hashed colour; the two
371
+ uncertain states drop the colour entirely and mute down, so a guess never
372
+ competes with a fact for attention. The glyph says which kind it is: a bot
373
+ for a scheduler inference, a question mark for nobody at all. */
374
+ .beads-grid .assignee {
375
+ display: inline-flex;
376
+ align-items: center;
377
+ gap: 5px;
378
+ font-size: 11px;
379
+ color: var(--c-dim);
380
+ min-width: 0;
381
+ }
382
+
383
+ .beads-grid .assignee-chip {
384
+ display: inline-flex;
385
+ align-items: center;
386
+ justify-content: center;
387
+ width: 16px;
388
+ height: 16px;
389
+ border-radius: 3px;
390
+ font-size: 9px;
391
+ font-weight: 700;
392
+ letter-spacing: 0.02em;
393
+ flex: none;
394
+ }
395
+
396
+ .beads-grid .assignee.is-link {
397
+ cursor: pointer;
398
+ }
399
+
400
+ .beads-grid .assignee.is-link:hover .assignee-handle {
401
+ color: var(--c-accent);
402
+ text-decoration: underline dotted;
403
+ text-underline-offset: 2px;
404
+ }
405
+
406
+ .beads-grid .assignee-handle {
407
+ overflow: hidden;
408
+ text-overflow: ellipsis;
409
+ white-space: nowrap;
410
+ }
411
+
412
+ .beads-grid .assignee.is-inferred,
413
+ .beads-grid .assignee.is-unassigned {
414
+ opacity: 0.6;
415
+ }
416
+
417
+ .beads-grid .assignee.is-inferred .assignee-chip,
418
+ .beads-grid .assignee.is-unassigned .assignee-chip {
419
+ background: transparent;
420
+ color: var(--c-dim);
421
+ }
422
+
423
+ /* The chip is a 16px box; the glyph wants to fill it rather than sit at the
424
+ inherited text size. */
425
+ .beads-grid .assignee-chip .icon {
426
+ width: 13px;
427
+ height: 13px;
428
+ }
429
+
430
+ .beads-grid .card-meta {
431
+ display: flex;
432
+ gap: 6px;
433
+ align-items: center;
434
+ flex-wrap: wrap;
435
+ min-width: 0;
436
+ }
437
+
438
+ .beads-grid .pill {
439
+ font-size: 10px;
440
+ padding: 1px 6px;
441
+ border-radius: 2px;
442
+ border: 1px solid var(--c-border);
443
+ color: var(--c-dim);
444
+ text-transform: lowercase;
445
+ }
446
+
447
+ .beads-grid .pill.status-closed {
448
+ color: var(--c-shipped);
449
+ border-color: var(--c-shipped-stroke);
450
+ }
451
+
452
+ .beads-grid .pill.status-in-progress {
453
+ color: var(--c-in-progress);
454
+ border-color: var(--c-in-progress);
455
+ }
456
+
457
+ .beads-grid .pill.status-deferred {
458
+ color: var(--c-dim);
459
+ }
460
+
461
+ .beads-grid .empty-state {
462
+ color: var(--c-dim);
463
+ font-size: 12px;
464
+ text-align: center;
465
+ padding: 24px;
466
+ margin: 0;
467
+ }
468
+ </style>