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,188 @@
|
|
|
1
|
+
// projects.ts — the browser's list of beads project locations.
|
|
2
|
+
//
|
|
3
|
+
// Pure: no localStorage, no fetch, no Vue. The store owns persistence and
|
|
4
|
+
// the server round-trip; this owns the format and the list operations, so
|
|
5
|
+
// they can be tested without a browser.
|
|
6
|
+
//
|
|
7
|
+
// The list lives in the browser because it's a personal set of places on
|
|
8
|
+
// this machine, like the watch list. The server still has to validate and
|
|
9
|
+
// admit each path before it will read one (see src/projects.mjs) — this
|
|
10
|
+
// side only remembers where the user said to look.
|
|
11
|
+
|
|
12
|
+
export interface Project {
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
/** Canonical absolute path, as returned by the server on registration. */
|
|
16
|
+
path: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const PROJECTS_STORAGE_KEY = "beadcyte:projects";
|
|
20
|
+
export const ACTIVE_PROJECT_STORAGE_KEY = "beadcyte:project-active";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse a stored registry. Anything malformed yields an empty list rather
|
|
24
|
+
* than throwing: a corrupt value must not take the app down on boot, and
|
|
25
|
+
* the launch project gets seeded back in immediately anyway.
|
|
26
|
+
*
|
|
27
|
+
* Entries without a usable path are dropped, duplicates by path collapse to
|
|
28
|
+
* the first, and order is preserved because the user set it.
|
|
29
|
+
*/
|
|
30
|
+
export function parseProjects(raw: string | null | undefined): Project[] {
|
|
31
|
+
if (!raw) return [];
|
|
32
|
+
let parsed: unknown;
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(raw);
|
|
35
|
+
} catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
if (!Array.isArray(parsed)) return [];
|
|
39
|
+
|
|
40
|
+
const out: Project[] = [];
|
|
41
|
+
const seenPath = new Set<string>();
|
|
42
|
+
const seenId = new Set<string>();
|
|
43
|
+
for (const entry of parsed) {
|
|
44
|
+
if (!entry || typeof entry !== "object") continue;
|
|
45
|
+
const e = entry as Record<string, unknown>;
|
|
46
|
+
const path = typeof e.path === "string" ? e.path.trim() : "";
|
|
47
|
+
if (!path || seenPath.has(path)) continue;
|
|
48
|
+
// A missing or colliding id is repairable; a missing path is not.
|
|
49
|
+
let id = typeof e.id === "string" && e.id.trim() ? e.id.trim() : "";
|
|
50
|
+
if (!id || seenId.has(id)) id = idForPath(path, seenId);
|
|
51
|
+
const label =
|
|
52
|
+
typeof e.label === "string" && e.label.trim()
|
|
53
|
+
? e.label.trim()
|
|
54
|
+
: labelForPath(path);
|
|
55
|
+
seenPath.add(path);
|
|
56
|
+
seenId.add(id);
|
|
57
|
+
out.push({ id, label, path });
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function serializeProjects(list: readonly Project[]): string {
|
|
63
|
+
return JSON.stringify(list);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Last path segment, which is what a project is usually called. */
|
|
67
|
+
export function labelForPath(path: string): string {
|
|
68
|
+
const parts = path.split("/").filter(Boolean);
|
|
69
|
+
return parts.length ? parts[parts.length - 1] : path;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Stable-ish id derived from the path, avoiding collisions in `taken`. */
|
|
73
|
+
function idForPath(path: string, taken: ReadonlySet<string>): string {
|
|
74
|
+
const base = labelForPath(path).replace(/[^a-zA-Z0-9-]+/g, "-") || "project";
|
|
75
|
+
if (!taken.has(base)) return base;
|
|
76
|
+
for (let n = 2; ; n++) {
|
|
77
|
+
const candidate = `${base}-${n}`;
|
|
78
|
+
if (!taken.has(candidate)) return candidate;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Add a project, or return the list unchanged when the path is already
|
|
84
|
+
* known — re-adding a path the user already has should be a no-op rather
|
|
85
|
+
* than a duplicate row pointing at the same directory.
|
|
86
|
+
*/
|
|
87
|
+
export function addProject(
|
|
88
|
+
list: readonly Project[],
|
|
89
|
+
entry: { path: string; label?: string },
|
|
90
|
+
): Project[] {
|
|
91
|
+
const path = entry.path.trim();
|
|
92
|
+
if (!path) return [...list];
|
|
93
|
+
if (list.some((p) => p.path === path)) return [...list];
|
|
94
|
+
const id = idForPath(path, new Set(list.map((p) => p.id)));
|
|
95
|
+
const label = entry.label?.trim() || labelForPath(path);
|
|
96
|
+
return [...list, { id, label, path }];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Which of the registry's paths the server does NOT currently admit.
|
|
101
|
+
*
|
|
102
|
+
* The two sides have deliberately different lifetimes: this list is
|
|
103
|
+
* persisted in localStorage because it records where the user looks, while
|
|
104
|
+
* the server's allowlist is per-process on purpose, so that restarting it
|
|
105
|
+
* does not inherit read permission from a previous session's browser state
|
|
106
|
+
* (see src/projects.mjs). The consequence is that after every server
|
|
107
|
+
* restart the client holds paths the server has forgotten, and reading one
|
|
108
|
+
* 403s. This is the diff the client has to close by re-registering.
|
|
109
|
+
*/
|
|
110
|
+
export function unadmittedPaths(
|
|
111
|
+
list: readonly Project[],
|
|
112
|
+
admitted: readonly string[] | null | undefined,
|
|
113
|
+
): string[] {
|
|
114
|
+
const have = new Set(admitted ?? []);
|
|
115
|
+
return list.map((p) => p.path).filter((path) => !have.has(path));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function removeProject(
|
|
119
|
+
list: readonly Project[],
|
|
120
|
+
id: string,
|
|
121
|
+
): Project[] {
|
|
122
|
+
return list.filter((p) => p.id !== id);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function renameProject(
|
|
126
|
+
list: readonly Project[],
|
|
127
|
+
id: string,
|
|
128
|
+
label: string,
|
|
129
|
+
): Project[] {
|
|
130
|
+
const next = label.trim();
|
|
131
|
+
// An empty rename falls back to the path's own name rather than leaving a
|
|
132
|
+
// nameless row the user can't identify.
|
|
133
|
+
return list.map((p) =>
|
|
134
|
+
p.id === id ? { ...p, label: next || labelForPath(p.path) } : p,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Move an entry by `delta` places, clamped to the ends. */
|
|
139
|
+
export function moveProject(
|
|
140
|
+
list: readonly Project[],
|
|
141
|
+
id: string,
|
|
142
|
+
delta: number,
|
|
143
|
+
): Project[] {
|
|
144
|
+
const from = list.findIndex((p) => p.id === id);
|
|
145
|
+
if (from < 0) return [...list];
|
|
146
|
+
const to = Math.min(list.length - 1, Math.max(0, from + delta));
|
|
147
|
+
if (to === from) return [...list];
|
|
148
|
+
const next = [...list];
|
|
149
|
+
const [moved] = next.splice(from, 1);
|
|
150
|
+
next.splice(to, 0, moved);
|
|
151
|
+
return next;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Which project is active.
|
|
156
|
+
*
|
|
157
|
+
* `?project=` selects BY LABEL, not by path: a path in a URL is meaningless
|
|
158
|
+
* on another machine and leaks local filesystem layout into a shareable
|
|
159
|
+
* link. An unmatched label is ignored rather than clearing the selection.
|
|
160
|
+
*
|
|
161
|
+
* Falls back to the stored id, then the launch project, then the first
|
|
162
|
+
* entry — so there is always a selection when the list is non-empty.
|
|
163
|
+
*/
|
|
164
|
+
export function resolveActive(
|
|
165
|
+
list: readonly Project[],
|
|
166
|
+
opts: {
|
|
167
|
+
urlLabel?: string | null;
|
|
168
|
+
storedId?: string | null;
|
|
169
|
+
launchPath?: string | null;
|
|
170
|
+
} = {},
|
|
171
|
+
): Project | null {
|
|
172
|
+
if (!list.length) return null;
|
|
173
|
+
|
|
174
|
+
const byLabel = opts.urlLabel
|
|
175
|
+
? list.find((p) => p.label === opts.urlLabel)
|
|
176
|
+
: undefined;
|
|
177
|
+
if (byLabel) return byLabel;
|
|
178
|
+
|
|
179
|
+
const byId = opts.storedId
|
|
180
|
+
? list.find((p) => p.id === opts.storedId)
|
|
181
|
+
: undefined;
|
|
182
|
+
if (byId) return byId;
|
|
183
|
+
|
|
184
|
+
const launch = opts.launchPath
|
|
185
|
+
? list.find((p) => p.path === opts.launchPath)
|
|
186
|
+
: undefined;
|
|
187
|
+
return launch ?? list[0];
|
|
188
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// refresh.ts — the refresh-interval choices and their storage format.
|
|
2
|
+
//
|
|
3
|
+
// This bead owns the SETTING and where it's stored; bp-1kh owns the timer
|
|
4
|
+
// that acts on it. Pure so the choices and their validation are testable.
|
|
5
|
+
|
|
6
|
+
export const REFRESH_OFF = 0;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Offered intervals, in ms. The default is deliberately slow: every tick
|
|
10
|
+
* shells out to `bd`, and this is a tool people leave open all day. "Off"
|
|
11
|
+
* is a first-class choice rather than an omission, because the header's
|
|
12
|
+
* "updated Ns ago" makes stale data visible enough to choose it safely.
|
|
13
|
+
*/
|
|
14
|
+
export const REFRESH_CHOICES: ReadonlyArray<{ ms: number; label: string }> = [
|
|
15
|
+
{ ms: REFRESH_OFF, label: "off — manual only" },
|
|
16
|
+
{ ms: 30_000, label: "30s" },
|
|
17
|
+
{ ms: 60_000, label: "1m" },
|
|
18
|
+
{ ms: 120_000, label: "2m" },
|
|
19
|
+
{ ms: 300_000, label: "5m" },
|
|
20
|
+
{ ms: 900_000, label: "15m" },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_REFRESH_MS = 120_000;
|
|
24
|
+
|
|
25
|
+
export const REFRESH_STORAGE_KEY = "beadcyte:refresh-ms";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse a stored interval. Anything that isn't one of the offered choices
|
|
29
|
+
* falls back to the default — including a plausible-looking number, because
|
|
30
|
+
* an arbitrary interval has no UI to display it and would look like the
|
|
31
|
+
* setting had been ignored.
|
|
32
|
+
*/
|
|
33
|
+
export function parseRefreshMs(raw: string | null | undefined): number {
|
|
34
|
+
if (raw === null || raw === undefined || raw === "") return DEFAULT_REFRESH_MS;
|
|
35
|
+
const n = Number(raw);
|
|
36
|
+
if (!Number.isFinite(n)) return DEFAULT_REFRESH_MS;
|
|
37
|
+
return REFRESH_CHOICES.some((c) => c.ms === n) ? n : DEFAULT_REFRESH_MS;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isRefreshOff(ms: number): boolean {
|
|
41
|
+
return ms === REFRESH_OFF;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Human label for an interval, for the header's status line. */
|
|
45
|
+
export function refreshLabel(ms: number): string {
|
|
46
|
+
return REFRESH_CHOICES.find((c) => c.ms === ms)?.label ?? `${ms}ms`;
|
|
47
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// search.ts — ranking for the ⌘K palette.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from SearchPalette.vue so the ranking rules are testable
|
|
4
|
+
// without mounting a component (node:test can't mount .vue).
|
|
5
|
+
|
|
6
|
+
export interface SearchableBead {
|
|
7
|
+
id: string;
|
|
8
|
+
title?: string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SearchResult<B extends SearchableBead> {
|
|
12
|
+
bead: B;
|
|
13
|
+
/** 3 = exact id, 2 = id prefix, 1 = title substring. 0 never surfaces. */
|
|
14
|
+
score: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Results shown at once; the palette is a jump target, not a report. */
|
|
18
|
+
export const SEARCH_LIMIT = 20;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Rank beads against a query, best first.
|
|
22
|
+
*
|
|
23
|
+
* An exact id beats an id prefix beats a title substring, so typing a full
|
|
24
|
+
* id always lands that bead at the top even when its id is a prefix of
|
|
25
|
+
* others (bp-1 vs bp-12). Ties keep input order, which is the caller's
|
|
26
|
+
* ordering (schedule order), not an arbitrary one — Array.sort is stable.
|
|
27
|
+
*/
|
|
28
|
+
export function rankBeads<B extends SearchableBead>(
|
|
29
|
+
beads: readonly B[],
|
|
30
|
+
query: string,
|
|
31
|
+
limit: number = SEARCH_LIMIT,
|
|
32
|
+
): SearchResult<B>[] {
|
|
33
|
+
const q = query.trim().toLowerCase();
|
|
34
|
+
if (!q) return [];
|
|
35
|
+
|
|
36
|
+
const scored: SearchResult<B>[] = [];
|
|
37
|
+
for (const bead of beads) {
|
|
38
|
+
const id = bead.id.toLowerCase();
|
|
39
|
+
const title = (bead.title ?? "").toLowerCase();
|
|
40
|
+
|
|
41
|
+
let score = 0;
|
|
42
|
+
if (id === q) score = 3;
|
|
43
|
+
else if (id.startsWith(q)) score = 2;
|
|
44
|
+
else if (title.includes(q)) score = 1;
|
|
45
|
+
|
|
46
|
+
if (score > 0) scored.push({ bead, score });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return scored.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
50
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// shortcuts.ts — the MINE buttons: one click to "my ready work" or "my work
|
|
2
|
+
// in review" (bp-egy).
|
|
3
|
+
//
|
|
4
|
+
// A shortcut is not a mode layered over the filters; it IS a filter state.
|
|
5
|
+
// Clicking one replaces every filter term with the shortcut's own, so the
|
|
6
|
+
// result never depends on what was selected before, and the popover pills
|
|
7
|
+
// show exactly what is active. Whether a shortcut is "active" is therefore
|
|
8
|
+
// derived: the current filters equal the shortcut's filters. Change any pill
|
|
9
|
+
// by hand and the mark drops, which is the honest reading.
|
|
10
|
+
//
|
|
11
|
+
// The readiness rule itself (approved, workable status, unblocked) lives in
|
|
12
|
+
// highlights.ts and is shared with the ready-now highlight. This module only
|
|
13
|
+
// decides how that rule is expressed as filter terms, plus "assigned to me".
|
|
14
|
+
//
|
|
15
|
+
// Pure: no store, no DOM. The store supplies the current user and the
|
|
16
|
+
// statuses present in the data.
|
|
17
|
+
|
|
18
|
+
import type { Filters } from "./view-model";
|
|
19
|
+
import { NOT_WORKABLE_STATUSES, READY_LABEL } from "./highlights.ts";
|
|
20
|
+
|
|
21
|
+
/** Where the chosen current user is remembered between reloads. */
|
|
22
|
+
export const CURRENT_USER_STORAGE_KEY = "beadcyte:current-user";
|
|
23
|
+
|
|
24
|
+
export type Shortcut = "my-ready" | "my-review";
|
|
25
|
+
|
|
26
|
+
export const SHORTCUT_IDS: readonly Shortcut[] = ["my-ready", "my-review"];
|
|
27
|
+
|
|
28
|
+
export const SHORTCUTS: ReadonlyArray<{ id: Shortcut; label: string; title: string }> = [
|
|
29
|
+
{
|
|
30
|
+
id: "my-ready",
|
|
31
|
+
label: "ready work",
|
|
32
|
+
title:
|
|
33
|
+
"Assigned to you, carrying spec:ready, not finished or already moving, and with every blocker closed",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "my-review",
|
|
37
|
+
label: "in review",
|
|
38
|
+
title: "Assigned to you and in review",
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The statuses "my ready work" keeps: everything this repo has that is not a
|
|
44
|
+
* finished, parked or already-moving status. Falls back to plain `open` when
|
|
45
|
+
* the data has no workable status at all, because an empty status selection
|
|
46
|
+
* would mean "every status" and show finished work as ready.
|
|
47
|
+
*/
|
|
48
|
+
export function workableStatuses(present: Iterable<string>): string[] {
|
|
49
|
+
const excluded = new Set<string>(NOT_WORKABLE_STATUSES);
|
|
50
|
+
const kept = [...new Set(present)].filter((s) => !excluded.has(s)).sort();
|
|
51
|
+
return kept.length ? kept : ["open"];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The complete filter state a shortcut stands for. Every key is set, so
|
|
56
|
+
* applying it replaces whatever was there.
|
|
57
|
+
*/
|
|
58
|
+
export function shortcutFilters(
|
|
59
|
+
kind: Shortcut,
|
|
60
|
+
me: string,
|
|
61
|
+
presentStatuses: Iterable<string>,
|
|
62
|
+
): Filters {
|
|
63
|
+
switch (kind) {
|
|
64
|
+
case "my-ready":
|
|
65
|
+
return {
|
|
66
|
+
types: null,
|
|
67
|
+
priorities: null,
|
|
68
|
+
statuses: workableStatuses(presentStatuses),
|
|
69
|
+
labels: [READY_LABEL],
|
|
70
|
+
assignees: [me],
|
|
71
|
+
unblocked: true,
|
|
72
|
+
};
|
|
73
|
+
case "my-review":
|
|
74
|
+
return {
|
|
75
|
+
types: null,
|
|
76
|
+
priorities: null,
|
|
77
|
+
statuses: ["in_review"],
|
|
78
|
+
labels: null,
|
|
79
|
+
assignees: [me],
|
|
80
|
+
unblocked: null,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Does this filter state equal the shortcut's? Compared term by term with
|
|
87
|
+
* arrays order-insensitive and null / empty treated alike, since both mean
|
|
88
|
+
* "no constraint" to the view model.
|
|
89
|
+
*/
|
|
90
|
+
export function isShortcutActive(
|
|
91
|
+
filters: Filters,
|
|
92
|
+
kind: Shortcut,
|
|
93
|
+
me: string,
|
|
94
|
+
presentStatuses: Iterable<string>,
|
|
95
|
+
): boolean {
|
|
96
|
+
const want = shortcutFilters(kind, me, presentStatuses);
|
|
97
|
+
return (
|
|
98
|
+
sameSet(filters.types, want.types) &&
|
|
99
|
+
sameSet(filters.priorities, want.priorities) &&
|
|
100
|
+
sameSet(filters.statuses, want.statuses) &&
|
|
101
|
+
sameSet(filters.labels, want.labels) &&
|
|
102
|
+
sameSet(filters.assignees, want.assignees) &&
|
|
103
|
+
!!filters.unblocked === !!want.unblocked
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function sameSet<T>(a: T[] | null | undefined, b: T[] | null | undefined): boolean {
|
|
108
|
+
const aa = a ?? [];
|
|
109
|
+
const bb = b ?? [];
|
|
110
|
+
if (aa.length !== bb.length) return false;
|
|
111
|
+
const set = new Set(aa);
|
|
112
|
+
return bb.every((v) => set.has(v));
|
|
113
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// status-filter.ts — what the status filter starts out selecting.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from store.ts for the same reason window.ts was: the rule is
|
|
4
|
+
// worth a test, and the store drags Pinia and a fetch into `node --test`.
|
|
5
|
+
//
|
|
6
|
+
// There used to be two boolean toggles — `showClosed` and `includeDeferred`
|
|
7
|
+
// — applied ahead of the status filter and unconditionally. Asking for
|
|
8
|
+
// `status=closed` with the toggle off returned an empty view and said nothing
|
|
9
|
+
// about why, and no combination the toggles enabled was expressible only by
|
|
10
|
+
// them. So they are gone, and what they used to enforce is now the initial
|
|
11
|
+
// *value* of the filter they used to override (bp-67g.40).
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Statuses kept out of the first-paint selection.
|
|
15
|
+
*
|
|
16
|
+
* The timeline is normally about what is next, and in a mature project
|
|
17
|
+
* past-closed rows dominate the space. `tombstone` is here because a
|
|
18
|
+
* tombstoned bead is not work at all.
|
|
19
|
+
*/
|
|
20
|
+
export const STATUSES_HIDDEN_BY_DEFAULT = ["closed", "deferred", "tombstone"];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The selection to start with, given the statuses a repo actually has.
|
|
24
|
+
*
|
|
25
|
+
* Enumerated from the data rather than from a fixed list, so a repo with no
|
|
26
|
+
* `blocked` beads never offers `blocked` — the same rule the other filter
|
|
27
|
+
* popovers follow.
|
|
28
|
+
*
|
|
29
|
+
* @param present statuses observed on real (non-epic) beads
|
|
30
|
+
* @param alsoKeep normally-hidden statuses to keep anyway, which is how a
|
|
31
|
+
* legacy `?showClosed=true` link survives
|
|
32
|
+
* @returns the statuses to select, or null when there is nothing to seed
|
|
33
|
+
* from yet — null means "no selection", which shows everything
|
|
34
|
+
*/
|
|
35
|
+
export function defaultStatusSelection(
|
|
36
|
+
present: Iterable<string>,
|
|
37
|
+
alsoKeep: readonly string[] = [],
|
|
38
|
+
): string[] | null {
|
|
39
|
+
const all = [...new Set(present)];
|
|
40
|
+
if (all.length === 0) return null;
|
|
41
|
+
const keep = all.filter(
|
|
42
|
+
(s) => !STATUSES_HIDDEN_BY_DEFAULT.includes(s) || alsoKeep.includes(s),
|
|
43
|
+
);
|
|
44
|
+
// A repo of nothing but closed beads would otherwise open on an empty
|
|
45
|
+
// view, which reads as broken. Showing everything is at least legible, and
|
|
46
|
+
// the panel then says plainly that nothing is filtered out.
|
|
47
|
+
return keep.length ? keep : all;
|
|
48
|
+
}
|