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,191 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// WipBullets.vue — per-person work in flight against the calibrated cap, as
|
|
3
|
+
// bullet rows on one shared scale (bp-67g.29).
|
|
4
|
+
//
|
|
5
|
+
// The bar is in-flight work in --c-in-progress up to the cap; the run past
|
|
6
|
+
// the cap continues in --c-danger at its real length; the notch at the cap
|
|
7
|
+
// is --c-text. Those three are the "wip bullet" group the style audit
|
|
8
|
+
// already guards, so a named style cannot collapse them. The value sits at
|
|
9
|
+
// the tip and the cap/rate text at the end of the row, where the old chips
|
|
10
|
+
// put it.
|
|
11
|
+
|
|
12
|
+
import { computed } from "vue";
|
|
13
|
+
import { capBlurb, rateBlurb, wipScaleMax, type WipRow } from "../wip";
|
|
14
|
+
|
|
15
|
+
const props = defineProps<{
|
|
16
|
+
rows: WipRow[];
|
|
17
|
+
/** The person the incytes view is scoped to; their row is emphasised (bp-dgq). */
|
|
18
|
+
selected?: string | null;
|
|
19
|
+
}>();
|
|
20
|
+
// The handle is a way in to that person's picture (bp-67g.43); the parent
|
|
21
|
+
// owns the navigation, like everything else in this presentational strip.
|
|
22
|
+
const emit = defineEmits<{ select: [handle: string] }>();
|
|
23
|
+
|
|
24
|
+
const scaleMax = computed(() => wipScaleMax(props.rows));
|
|
25
|
+
const pos = (v: number) => `${Math.min(100, (v / scaleMax.value) * 100)}%`;
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<template>
|
|
29
|
+
<ul class="wip-bullets" :aria-label="`Work in flight against calibrated cap, scale 0 to ${scaleMax}`">
|
|
30
|
+
<li
|
|
31
|
+
v-for="r in rows"
|
|
32
|
+
:key="r.handle"
|
|
33
|
+
class="wip-row"
|
|
34
|
+
:class="{ 'is-over': r.over > 0, 'is-idle': r.inFlight === 0, 'is-nudged': r.rawCap !== r.cap, 'is-selected': selected === r.handle }"
|
|
35
|
+
>
|
|
36
|
+
<button type="button" class="who" :title="`Show ${r.handle}`" @click="emit('select', r.handle)"><code>{{ r.handle }}</code></button>
|
|
37
|
+
<div
|
|
38
|
+
class="track"
|
|
39
|
+
role="img"
|
|
40
|
+
:aria-label="`${r.handle}: ${r.inFlight} in flight against a cap of ${r.cap}${r.over ? `, ${r.over} over` : ''}`"
|
|
41
|
+
>
|
|
42
|
+
<i class="fill" :style="{ width: pos(Math.min(r.inFlight, r.cap)) }" />
|
|
43
|
+
<i v-if="r.over > 0" class="over" :style="{ left: pos(r.cap), width: pos(r.over) }" />
|
|
44
|
+
<i class="cap" :style="{ left: pos(r.cap) }" />
|
|
45
|
+
<span class="tip" :style="{ left: pos(r.inFlight) }">{{ r.inFlight }}</span>
|
|
46
|
+
</div>
|
|
47
|
+
<span class="blurb">
|
|
48
|
+
<span class="cap-blurb">{{ capBlurb(r) }}</span>
|
|
49
|
+
<span class="rate">{{ rateBlurb(r) }}</span>
|
|
50
|
+
</span>
|
|
51
|
+
</li>
|
|
52
|
+
</ul>
|
|
53
|
+
</template>
|
|
54
|
+
|
|
55
|
+
<style>
|
|
56
|
+
.wip-bullets {
|
|
57
|
+
list-style: none;
|
|
58
|
+
margin: 0;
|
|
59
|
+
padding: 0;
|
|
60
|
+
display: flex;
|
|
61
|
+
flex-direction: column;
|
|
62
|
+
gap: 4px;
|
|
63
|
+
flex: 1 1 100%;
|
|
64
|
+
min-width: 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.wip-row {
|
|
68
|
+
display: grid;
|
|
69
|
+
grid-template-columns: minmax(64px, 110px) minmax(160px, 1fr) auto;
|
|
70
|
+
align-items: center;
|
|
71
|
+
gap: 10px;
|
|
72
|
+
font-size: 11px;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.wip-row .who {
|
|
76
|
+
all: unset;
|
|
77
|
+
cursor: pointer;
|
|
78
|
+
font-family: ui-monospace, monospace;
|
|
79
|
+
font-size: 11px;
|
|
80
|
+
color: var(--c-accent);
|
|
81
|
+
overflow: hidden;
|
|
82
|
+
text-overflow: ellipsis;
|
|
83
|
+
white-space: nowrap;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.wip-row .who code {
|
|
87
|
+
font: inherit;
|
|
88
|
+
color: inherit;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.wip-row .who:hover code,
|
|
92
|
+
.wip-row .who:focus-visible code {
|
|
93
|
+
text-decoration: underline dotted;
|
|
94
|
+
text-underline-offset: 2px;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.wip-row.is-idle .who,
|
|
98
|
+
.wip-row.is-idle .blurb {
|
|
99
|
+
opacity: 0.6;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* The selected person, when incytes is scoped to one: the comparison with
|
|
103
|
+
everyone else is the point of a shared scale, so the others stay. */
|
|
104
|
+
.wip-row.is-selected {
|
|
105
|
+
box-shadow: inset 3px 0 0 var(--c-accent);
|
|
106
|
+
padding-left: 6px;
|
|
107
|
+
margin-left: -9px;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.wip-row.is-selected .who,
|
|
111
|
+
.wip-row.is-selected .blurb {
|
|
112
|
+
opacity: 1;
|
|
113
|
+
color: var(--c-text);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* The measure. Padding-right leaves room for a tip value at 100%. */
|
|
117
|
+
.wip-row .track {
|
|
118
|
+
position: relative;
|
|
119
|
+
height: 10px;
|
|
120
|
+
margin-right: 28px;
|
|
121
|
+
background: var(--c-lift);
|
|
122
|
+
border-radius: 2px;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.wip-row .fill,
|
|
126
|
+
.wip-row .over {
|
|
127
|
+
position: absolute;
|
|
128
|
+
top: 0;
|
|
129
|
+
bottom: 0;
|
|
130
|
+
border-radius: 2px;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.wip-row .fill {
|
|
134
|
+
left: 0;
|
|
135
|
+
background: var(--c-in-progress);
|
|
136
|
+
opacity: 0.9;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.wip-row .over {
|
|
140
|
+
background: var(--c-danger);
|
|
141
|
+
opacity: 0.9;
|
|
142
|
+
border-radius: 0 2px 2px 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* The cap is a notch ON the measure, not the end of the scale. */
|
|
146
|
+
.wip-row .cap {
|
|
147
|
+
position: absolute;
|
|
148
|
+
top: -3px;
|
|
149
|
+
bottom: -3px;
|
|
150
|
+
width: 2px;
|
|
151
|
+
background: var(--c-text);
|
|
152
|
+
transform: translateX(-1px);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.wip-row .tip {
|
|
156
|
+
position: absolute;
|
|
157
|
+
top: -3px;
|
|
158
|
+
margin-left: 5px;
|
|
159
|
+
font-size: 10px;
|
|
160
|
+
line-height: 16px;
|
|
161
|
+
color: var(--c-text);
|
|
162
|
+
font-variant-numeric: tabular-nums;
|
|
163
|
+
white-space: nowrap;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.wip-row.is-over .tip {
|
|
167
|
+
color: var(--c-danger);
|
|
168
|
+
font-weight: 600;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.wip-row .blurb {
|
|
172
|
+
display: inline-flex;
|
|
173
|
+
gap: 8px;
|
|
174
|
+
white-space: nowrap;
|
|
175
|
+
font-variant-numeric: tabular-nums;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.wip-row .cap-blurb {
|
|
179
|
+
color: var(--c-text);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.wip-row.is-nudged .cap-blurb {
|
|
183
|
+
color: var(--c-accent);
|
|
184
|
+
font-weight: 600;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.wip-row .rate {
|
|
188
|
+
color: var(--c-dim);
|
|
189
|
+
font-size: 10px;
|
|
190
|
+
}
|
|
191
|
+
</style>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Shared row shape for FilterPopover.vue. Kept out of the SFC so consumers
|
|
2
|
+
// can `import type { FilterOption } from "./filter-option"` without pulling
|
|
3
|
+
// the whole component's runtime.
|
|
4
|
+
|
|
5
|
+
export interface FilterOption<T> {
|
|
6
|
+
value: T;
|
|
7
|
+
label: string;
|
|
8
|
+
hint?: string;
|
|
9
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// url-codec.ts — query-string encoding for shareable view state.
|
|
2
|
+
//
|
|
3
|
+
// Also owns the enum guards for the query string's closed-set params.
|
|
4
|
+
//
|
|
5
|
+
// Pure: no store, no window, no Vue. useUrlState.ts owns the reactive
|
|
6
|
+
// wiring; this owns the wire format, so the format is testable on its own
|
|
7
|
+
// (node:test can't resolve the composable's store import).
|
|
8
|
+
|
|
9
|
+
import type { GroupBy } from "../view-model";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Serialize an array of values to a comma-separated string.
|
|
13
|
+
* Empty/absent arrays serialize to "" so callers can drop the param.
|
|
14
|
+
*/
|
|
15
|
+
export function serializeArray(
|
|
16
|
+
arr: (string | number | null)[] | null | undefined,
|
|
17
|
+
): string {
|
|
18
|
+
if (!arr || arr.length === 0) return "";
|
|
19
|
+
return arr.map((v) => (v === null ? "" : String(v))).join(",");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Deserialize a comma-separated string to a string array.
|
|
24
|
+
*
|
|
25
|
+
* Deliberately does NOT coerce numeric-looking segments to numbers: ids,
|
|
26
|
+
* labels, statuses and assignees are opaque strings, and a label like "2026"
|
|
27
|
+
* must round-trip as a string or it stops matching the bead it came from.
|
|
28
|
+
*/
|
|
29
|
+
export function deserializeStrings(str: string): string[] {
|
|
30
|
+
if (!str) return [];
|
|
31
|
+
return str
|
|
32
|
+
.split(",")
|
|
33
|
+
.map((s) => s.trim())
|
|
34
|
+
.filter((s) => s.length > 0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Deserialize a comma-separated string to a number array, dropping any
|
|
39
|
+
* segment that is not a number (a hand-edited URL is untrusted input).
|
|
40
|
+
*/
|
|
41
|
+
export function deserializeNumbers(str: string): number[] {
|
|
42
|
+
if (!str) return [];
|
|
43
|
+
return str
|
|
44
|
+
.split(",")
|
|
45
|
+
.map((s) => Number(s.trim()))
|
|
46
|
+
.filter((n) => Number.isFinite(n));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Serialize a date to a plain ISO day (YYYY-MM-DD).
|
|
51
|
+
*/
|
|
52
|
+
export function serializeDate(date: Date | null | undefined): string {
|
|
53
|
+
if (!date) return "";
|
|
54
|
+
const t = date.getTime();
|
|
55
|
+
if (!Number.isFinite(t)) return "";
|
|
56
|
+
return date.toISOString().split("T")[0];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Parse a date string: ISO (2026-09-01) or relative ("-30d", "+90d").
|
|
61
|
+
* Returns null for anything unparseable -- `new Date(garbage)` yields an
|
|
62
|
+
* Invalid Date rather than throwing, so the result must be range-checked.
|
|
63
|
+
*/
|
|
64
|
+
export function parseDate(str: string): Date | null {
|
|
65
|
+
if (!str) return null;
|
|
66
|
+
const rel = str.match(/^([+-])(\d+)d$/);
|
|
67
|
+
if (rel) {
|
|
68
|
+
const sign = rel[1] === "+" ? 1 : -1;
|
|
69
|
+
const date = new Date();
|
|
70
|
+
date.setDate(date.getDate() + sign * Number(rel[2]));
|
|
71
|
+
return date;
|
|
72
|
+
}
|
|
73
|
+
const date = new Date(str);
|
|
74
|
+
return Number.isFinite(date.getTime()) ? date : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Which view is mounted. Part of the URL rather than localStorage: a shared
|
|
79
|
+
* link should land the recipient on the view the sender was looking at.
|
|
80
|
+
*/
|
|
81
|
+
export type ViewMode = "mine" | "gantt" | "grid" | "table" | "incytes";
|
|
82
|
+
|
|
83
|
+
const VIEW_MODES: readonly ViewMode[] = ["mine", "gantt", "grid", "table", "incytes"];
|
|
84
|
+
|
|
85
|
+
const GROUP_MODES: readonly GroupBy[] = [
|
|
86
|
+
"epic",
|
|
87
|
+
"assignee",
|
|
88
|
+
"seat",
|
|
89
|
+
"track",
|
|
90
|
+
"status",
|
|
91
|
+
"priority",
|
|
92
|
+
"none",
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse `?view=`, falling back to the mine view (the default tab). The query string is
|
|
97
|
+
* user-editable, so an unrecognised value is ignored rather than trusted —
|
|
98
|
+
* which is also what keeps links written before a view existed working.
|
|
99
|
+
*/
|
|
100
|
+
export function parseView(str: string | null | undefined): ViewMode {
|
|
101
|
+
return str && (VIEW_MODES as readonly string[]).includes(str)
|
|
102
|
+
? (str as ViewMode)
|
|
103
|
+
: "mine";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parse `?group=`, returning null when the value isn't a real mode so the
|
|
108
|
+
* caller can leave the store's default alone. An unrecognised value used to
|
|
109
|
+
* be cast straight through, landing the view model on its "__all" fallback
|
|
110
|
+
* with no mode showing as active in the controls.
|
|
111
|
+
*/
|
|
112
|
+
export function parseGroupBy(str: string | null | undefined): GroupBy | null {
|
|
113
|
+
return str && (GROUP_MODES as readonly string[]).includes(str)
|
|
114
|
+
? (str as GroupBy)
|
|
115
|
+
: null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Which normally-hidden statuses a pre-bp-67g.40 link was asking for.
|
|
120
|
+
*
|
|
121
|
+
* `?showClosed=true` and `?includeDeferred=true` were separate booleans that
|
|
122
|
+
* silently overrode `?status=`. They are no longer written, but old links are
|
|
123
|
+
* shareable state and have to keep resolving to the same view, so they are
|
|
124
|
+
* still read — as statuses to add to the default selection.
|
|
125
|
+
*
|
|
126
|
+
* Returns [] when neither is present or both are false, which is also the
|
|
127
|
+
* shape a modern link produces. An explicit `?status=` in the same URL is the
|
|
128
|
+
* newer and more specific instruction and should win outright; that call
|
|
129
|
+
* belongs to the caller, which has the parsed status list.
|
|
130
|
+
*/
|
|
131
|
+
export function legacyHiddenStatuses(params: URLSearchParams): string[] {
|
|
132
|
+
const out: string[] = [];
|
|
133
|
+
if (params.get("showClosed") === "true") out.push("closed");
|
|
134
|
+
if (params.get("includeDeferred") === "true") out.push("deferred");
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// useBeadTooltip.ts — one hover state machine for every BeadTooltip host
|
|
2
|
+
// (bp-67g.42).
|
|
3
|
+
//
|
|
4
|
+
// The Gantt grew this for its bars (bp-6y1, bp-67g.41): hovering a mark pins
|
|
5
|
+
// the panel at the entry point, a short grace period on leave lets the pointer
|
|
6
|
+
// cross the gap onto the panel, and the panel's rows — detail, triage score
|
|
7
|
+
// and rank, time spent, watched — are resolved through the store. The drawer's
|
|
8
|
+
// dependency subgraph needs exactly the same behaviour for its nodes, so the
|
|
9
|
+
// machine lives here and both hosts render `<BeadTooltip v-bind="tipProps">`.
|
|
10
|
+
// One definition, so the two hovers cannot drift apart in what they say.
|
|
11
|
+
|
|
12
|
+
import { computed, onUnmounted, ref } from "vue";
|
|
13
|
+
import { useBeadsStore } from "../store";
|
|
14
|
+
import { describeRank } from "../triage";
|
|
15
|
+
import { buildEconomics } from "../economics";
|
|
16
|
+
|
|
17
|
+
export interface BeadTooltipOptions {
|
|
18
|
+
/** Grace period in ms between leaving the mark and the panel closing. */
|
|
19
|
+
leaveDelayMs?: number;
|
|
20
|
+
/**
|
|
21
|
+
* Close the panel when anything scrolls — the host's own overflow, the
|
|
22
|
+
* drawer body, the page. The panel is fixed to the viewport while the mark
|
|
23
|
+
* it describes scrolls away, so a scroll would otherwise leave it stranded.
|
|
24
|
+
*/
|
|
25
|
+
dismissOnScroll?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* The horizontal extent, in viewport px, the panel must stay inside. A host
|
|
28
|
+
* that lives in a column (the drawer) passes its own edges; the Gantt has
|
|
29
|
+
* the whole viewport and passes nothing. Read at hover time.
|
|
30
|
+
*/
|
|
31
|
+
bounds?: () => { left: number; right: number } | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function useBeadTooltip(opts: BeadTooltipOptions = {}) {
|
|
35
|
+
const store = useBeadsStore();
|
|
36
|
+
const leaveDelay = opts.leaveDelayMs ?? 180;
|
|
37
|
+
|
|
38
|
+
const hoveredBeadId = ref<string | null>(null);
|
|
39
|
+
const hoverX = ref(0);
|
|
40
|
+
const hoverY = ref(0);
|
|
41
|
+
// The hovered mark's band in viewport px, so the panel can stay off it.
|
|
42
|
+
const hoverBand = ref<{ top: number; bottom: number } | null>(null);
|
|
43
|
+
const hoverBounds = ref<{ left: number; right: number } | null>(null);
|
|
44
|
+
let leaveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
45
|
+
|
|
46
|
+
const detail = computed(() =>
|
|
47
|
+
hoveredBeadId.value ? store.beadDetailFor(hoveredBeadId.value) : null,
|
|
48
|
+
);
|
|
49
|
+
const watched = computed(
|
|
50
|
+
() => !!hoveredBeadId.value && store.watchedSet.has(hoveredBeadId.value),
|
|
51
|
+
);
|
|
52
|
+
// Reading store.triage from a hover was the cost bp-67g.41 flagged;
|
|
53
|
+
// bp-67g.25 took it from ~300ms to a few ms on a 600-bead graph, so the
|
|
54
|
+
// first hover pays a blink, not a stall. Economics is a per-bead derivation
|
|
55
|
+
// over its own metadata and timestamps.
|
|
56
|
+
const triage = computed(() =>
|
|
57
|
+
hoveredBeadId.value ? store.triage.get(hoveredBeadId.value) ?? null : null,
|
|
58
|
+
);
|
|
59
|
+
const rank = computed(() =>
|
|
60
|
+
triage.value ? describeRank(store.triageScorePopulation, triage.value.score) : null,
|
|
61
|
+
);
|
|
62
|
+
const economics = computed(() => {
|
|
63
|
+
const id = hoveredBeadId.value;
|
|
64
|
+
const b = id ? store.beads.find((x) => x.id === id) : undefined;
|
|
65
|
+
return b ? buildEconomics(b) : null;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/** Everything BeadTooltip takes, ready for `v-bind`. */
|
|
69
|
+
const props = computed(() => ({
|
|
70
|
+
detail: detail.value,
|
|
71
|
+
x: hoverX.value,
|
|
72
|
+
y: hoverY.value,
|
|
73
|
+
watched: watched.value,
|
|
74
|
+
bandTop: hoverBand.value?.top,
|
|
75
|
+
bandBottom: hoverBand.value?.bottom,
|
|
76
|
+
boundsLeft: hoverBounds.value?.left,
|
|
77
|
+
boundsRight: hoverBounds.value?.right,
|
|
78
|
+
triage: triage.value,
|
|
79
|
+
rank: rank.value,
|
|
80
|
+
economics: economics.value,
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
let listeningForScroll = false;
|
|
84
|
+
const onAnyScroll = () => dismiss();
|
|
85
|
+
function listenForScroll() {
|
|
86
|
+
if (listeningForScroll || !opts.dismissOnScroll) return;
|
|
87
|
+
// Scroll events do not bubble, but they do pass document in the capture
|
|
88
|
+
// phase, so one listener sees every scroll container on the page.
|
|
89
|
+
document.addEventListener("scroll", onAnyScroll, { capture: true, passive: true });
|
|
90
|
+
listeningForScroll = true;
|
|
91
|
+
}
|
|
92
|
+
function stopListeningForScroll() {
|
|
93
|
+
if (!listeningForScroll) return;
|
|
94
|
+
document.removeEventListener("scroll", onAnyScroll, { capture: true });
|
|
95
|
+
listeningForScroll = false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function cancelLeave() {
|
|
99
|
+
if (leaveTimer) {
|
|
100
|
+
clearTimeout(leaveTimer);
|
|
101
|
+
leaveTimer = null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The mark or the panel was left: close after the grace period. */
|
|
106
|
+
function scheduleLeave() {
|
|
107
|
+
cancelLeave();
|
|
108
|
+
leaveTimer = setTimeout(() => dismiss(), leaveDelay);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Close now: the bead was opened, the picture changed, something scrolled. */
|
|
112
|
+
function dismiss() {
|
|
113
|
+
cancelLeave();
|
|
114
|
+
hoveredBeadId.value = null;
|
|
115
|
+
stopListeningForScroll();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A mark for `id` was entered. The panel is pinned where the pointer came
|
|
120
|
+
* in (it does not follow the mouse, so the pointer can leave the mark for
|
|
121
|
+
* the panel) and anchored to `mark`'s band — the event's currentTarget
|
|
122
|
+
* unless the host names a tighter element, as the subgraph does with its
|
|
123
|
+
* node box inside a larger hit area.
|
|
124
|
+
*/
|
|
125
|
+
function enter(id: string, e: MouseEvent, mark?: Element | null) {
|
|
126
|
+
cancelLeave();
|
|
127
|
+
hoveredBeadId.value = id;
|
|
128
|
+
hoverX.value = e.clientX;
|
|
129
|
+
hoverY.value = e.clientY;
|
|
130
|
+
const r = (mark ?? (e.currentTarget as Element | null))?.getBoundingClientRect();
|
|
131
|
+
hoverBand.value = r ? { top: r.top - 4, bottom: r.bottom + 4 } : null;
|
|
132
|
+
hoverBounds.value = opts.bounds?.() ?? null;
|
|
133
|
+
listenForScroll();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Pin from the panel. The panel stays open: the user is looking at the
|
|
138
|
+
* bead they just pinned, and the button flips to its unwatch state.
|
|
139
|
+
*/
|
|
140
|
+
function toggleWatch(id: string) {
|
|
141
|
+
store.toggleWatch(id);
|
|
142
|
+
cancelLeave();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
onUnmounted(dismiss);
|
|
146
|
+
|
|
147
|
+
return { hoveredBeadId, props, enter, scheduleLeave, cancelLeave, dismiss, toggleWatch };
|
|
148
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// useKeyboard.ts — listens for the shortcuts keybindings.ts declares and
|
|
2
|
+
// carries each out (bp-67g.45). One document listener, installed by App.vue.
|
|
3
|
+
//
|
|
4
|
+
// The table decides WHAT is bound; this file decides what each action does,
|
|
5
|
+
// against the store and the theme. Nothing here adds a key the table does
|
|
6
|
+
// not list, so the ? overlay stays the whole truth.
|
|
7
|
+
|
|
8
|
+
import { onBeforeUnmount, onMounted } from "vue";
|
|
9
|
+
import { useBeadsStore } from "../store";
|
|
10
|
+
import { useTheme } from "./useTheme";
|
|
11
|
+
import { STYLE_NAMES, type StyleName } from "../theme";
|
|
12
|
+
import { WINDOW_PRESETS } from "../window";
|
|
13
|
+
import { isTypingTarget, matchBinding, type KeyAction } from "../keybindings";
|
|
14
|
+
import { markWalkthroughSeen } from "../walkthrough";
|
|
15
|
+
import type { GroupBy } from "../view-model";
|
|
16
|
+
|
|
17
|
+
const GROUP_CYCLE: readonly GroupBy[] = ["epic", "assignee", "seat", "track", "status", "priority", "none"];
|
|
18
|
+
|
|
19
|
+
export function useKeyboard() {
|
|
20
|
+
const store = useBeadsStore();
|
|
21
|
+
const theme = useTheme();
|
|
22
|
+
|
|
23
|
+
function next<T>(list: readonly T[], current: T): T {
|
|
24
|
+
const i = list.indexOf(current);
|
|
25
|
+
return list[(i + 1) % list.length]!;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function run(action: KeyAction) {
|
|
29
|
+
switch (action) {
|
|
30
|
+
case "view-mine": return store.setView("mine");
|
|
31
|
+
case "view-gantt": return store.setView("gantt");
|
|
32
|
+
case "view-grid": return store.setView("grid");
|
|
33
|
+
case "view-table": return store.setView("table");
|
|
34
|
+
case "view-incytes": return store.setView("incytes");
|
|
35
|
+
case "cycle-group": return store.setGroupBy(next(GROUP_CYCLE, store.groupBy));
|
|
36
|
+
case "cycle-window": {
|
|
37
|
+
const ids = WINDOW_PRESETS.map((p) => p.id);
|
|
38
|
+
const cur = store.activeWindowPreset;
|
|
39
|
+
return store.setWindowFromPreset(cur ? next(ids, cur) : ids[0]!);
|
|
40
|
+
}
|
|
41
|
+
case "today":
|
|
42
|
+
store.setWindowFromPreset("90d");
|
|
43
|
+
return store.requestToday();
|
|
44
|
+
case "next-bead": return store.moveCursor(1);
|
|
45
|
+
case "prev-bead": return store.moveCursor(-1);
|
|
46
|
+
case "open-first": return store.openBeadId ? undefined : store.moveCursor(1);
|
|
47
|
+
case "close":
|
|
48
|
+
if (store.contextMenu) return store.closeContextMenu();
|
|
49
|
+
if (store.helpOpen) return store.closeHelp();
|
|
50
|
+
if (store.changelogOpen) return store.closeChangelog();
|
|
51
|
+
if (store.openBeadId) return store.closeBead();
|
|
52
|
+
if (store.walkthroughOpen) {
|
|
53
|
+
markWalkthroughSeen();
|
|
54
|
+
return store.closeWalkthrough();
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
case "toggle-watch": return store.openBeadId ? store.toggleWatch(store.openBeadId) : undefined;
|
|
58
|
+
case "toggle-scope": return store.toggleDrawerSubgraphScope();
|
|
59
|
+
case "actions": {
|
|
60
|
+
if (!store.openBeadId) return;
|
|
61
|
+
// Anchor at the drawer's Actions button when it is on screen.
|
|
62
|
+
const r = document.querySelector(".drawer .actions-btn")?.getBoundingClientRect();
|
|
63
|
+
return store.openContextMenu(store.openBeadId, r ? r.left : 24, r ? r.bottom + 4 : 24);
|
|
64
|
+
}
|
|
65
|
+
case "copy-id":
|
|
66
|
+
if (store.openBeadId) void navigator.clipboard?.writeText(store.openBeadId);
|
|
67
|
+
return;
|
|
68
|
+
case "hl-blocked": return store.toggleHighlight("blocked");
|
|
69
|
+
case "hl-over-cap": return store.toggleHighlight("over-cap");
|
|
70
|
+
case "hl-ready-now": return store.toggleHighlight("ready-now");
|
|
71
|
+
case "toggle-deps": return store.toggleShowDependencies();
|
|
72
|
+
case "clear-filters": return store.resetFilters();
|
|
73
|
+
case "toggle-closed": return store.toggleStatusInFilter("closed");
|
|
74
|
+
case "cycle-theme": return theme.cycleMode();
|
|
75
|
+
case "cycle-style": return theme.setStyle(next(STYLE_NAMES as readonly StyleName[], theme.style.value));
|
|
76
|
+
case "options": return store.requestOptions();
|
|
77
|
+
case "search": return store.requestSearch();
|
|
78
|
+
case "help": return store.helpOpen ? store.closeHelp() : store.openHelp();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function onKeydown(e: KeyEvent) {
|
|
83
|
+
if (e.defaultPrevented) return;
|
|
84
|
+
if (isTypingTarget(e.target as HTMLElement | null)) return;
|
|
85
|
+
const b = matchBinding(e);
|
|
86
|
+
if (!b) return;
|
|
87
|
+
// Escape with nothing to close is the browser's; everything else is ours.
|
|
88
|
+
if (b.action === "close" && !store.contextMenu && !store.helpOpen && !store.changelogOpen && !store.openBeadId && !store.walkthroughOpen) return;
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
run(b.action);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type KeyEvent = KeyboardEvent;
|
|
94
|
+
|
|
95
|
+
onMounted(() => document.addEventListener("keydown", onKeydown));
|
|
96
|
+
onBeforeUnmount(() => document.removeEventListener("keydown", onKeydown));
|
|
97
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// useLiveRefresh.ts — the poll timer.
|
|
2
|
+
//
|
|
3
|
+
// The interval and the "off" choice are settings owned by the options menu
|
|
4
|
+
// (refresh.ts); this acts on them.
|
|
5
|
+
//
|
|
6
|
+
// Three rules, each for a reason:
|
|
7
|
+
//
|
|
8
|
+
// - Paused while the tab is hidden. Every tick shells out to `bd`, and a
|
|
9
|
+
// backgrounded tab burning that for nobody is pure waste.
|
|
10
|
+
// - No backfill on return. A tab hidden for an hour owes one fetch, not
|
|
11
|
+
// sixty; setInterval would queue them, so the timer is torn down on hide
|
|
12
|
+
// and rebuilt on show.
|
|
13
|
+
// - Refetches on becoming visible even when polling is OFF. Off means "no
|
|
14
|
+
// timer", not "show me stale data without saying so" — and returning to
|
|
15
|
+
// a tab is exactly when stale data is most likely to be acted on.
|
|
16
|
+
|
|
17
|
+
import { onBeforeUnmount, onMounted, watch } from "vue";
|
|
18
|
+
import { useBeadsStore } from "../store";
|
|
19
|
+
import { isRefreshOff } from "../refresh";
|
|
20
|
+
|
|
21
|
+
export function useLiveRefresh() {
|
|
22
|
+
const store = useBeadsStore();
|
|
23
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
24
|
+
|
|
25
|
+
const stop = () => {
|
|
26
|
+
if (timer !== null) {
|
|
27
|
+
clearInterval(timer);
|
|
28
|
+
timer = null;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const start = () => {
|
|
33
|
+
stop();
|
|
34
|
+
const ms = store.pollIntervalMs;
|
|
35
|
+
if (isRefreshOff(ms)) return;
|
|
36
|
+
if (typeof document !== "undefined" && document.hidden) return;
|
|
37
|
+
timer = setInterval(() => {
|
|
38
|
+
// Skip a tick that lands while a fetch is still in flight rather than
|
|
39
|
+
// stacking requests on a slow repo.
|
|
40
|
+
if (!store.loading) void store.fetch();
|
|
41
|
+
}, ms);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function onVisibilityChange() {
|
|
45
|
+
if (document.hidden) {
|
|
46
|
+
stop();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
// One catch-up fetch, then resume ticking. This runs whatever the
|
|
50
|
+
// interval is, including off.
|
|
51
|
+
if (!store.loading) void store.fetch();
|
|
52
|
+
start();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
onMounted(() => {
|
|
56
|
+
start();
|
|
57
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
onBeforeUnmount(() => {
|
|
61
|
+
stop();
|
|
62
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Changing the interval takes effect now, not after the old one fires.
|
|
66
|
+
watch(() => store.pollIntervalMs, start);
|
|
67
|
+
|
|
68
|
+
return { restart: start, stop };
|
|
69
|
+
}
|