paseo-beads 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cuongntr
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,171 @@
1
+ # paseo-beads
2
+
3
+ A read-only Beads console for Paseo workspaces. For the workspace you are in it shows how far
4
+ along the project is per parent issue, what is in progress, what can start now, how the project's
5
+ own labels spread over open work, and the critical chain of dependencies; a whole-project board
6
+ by work state;
7
+ execution tracks; risks; issue search; Markdown-rendered issue detail; and a composer attachment
8
+ source for Beads issues.
9
+
10
+ The plugin never invokes a mutating `br` or `bd` command. There is no claim, close,
11
+ update, or create path in the code. `bv` may refresh its own compatibility export while
12
+ loading a `bd`/Dolt workspace, so `bv` calls are serialized per workspace.
13
+
14
+ ## Screenshots
15
+
16
+ Shown on a made-up demo project.
17
+
18
+ **Overview**: progress per parent, what is in progress, held and ready now, the project's labels
19
+ over open work, and the critical chain.
20
+
21
+ ![Overview](images/overview.png)
22
+
23
+ **Board**: one column per work state in the order work moves, filtered by parent or label, each
24
+ column with its definition.
25
+
26
+ ![Board](images/board.png)
27
+
28
+ **Plan**: `bv`'s parallel execution tracks, containers left out.
29
+
30
+ ![Plan](images/plan.png)
31
+
32
+ **Risks**: stuck and parked work, alerts by severity, and the keystones that unblock the most.
33
+
34
+ ![Risks](images/risks.png)
35
+
36
+ ## Requirements
37
+
38
+ - Paseo `>=0.8.0` (daemon and app).
39
+ - The [`bv`](https://github.com/Dicklesworthstone/beads_viewer) analysis CLI on the daemon machine's `PATH`.
40
+ Verified against `bv v0.25.0`.
41
+ - A project that uses Beads through `br` or `bd`. The tracker CLI matching the project must
42
+ also be on the daemon `PATH` for issue detail and for issue types and assignees; everything
43
+ else works without it.
44
+
45
+ Everything else degrades gracefully: a missing `bv`, a workspace without a `.beads` source,
46
+ and an empty-but-healthy project are three distinct, clearly labelled states.
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ paseo plugin add cuongntr/paseo-beads-viewer
52
+ paseo plugin ls paseo-beads # expect paseo-beads running
53
+ ```
54
+
55
+ Update to the latest release with `paseo plugin update paseo-beads`. If the panel does not
56
+ appear, `paseo plugin logs paseo-beads` shows why; the most common cause is `bv` missing from
57
+ the daemon's `PATH`.
58
+
59
+ To run a local checkout instead:
60
+
61
+ ```bash
62
+ npm install && npm run typecheck && npm test
63
+ paseo plugin install /absolute/path/to/paseo-beads
64
+ ```
65
+
66
+ After editing source, run `paseo plugin reload paseo-beads`.
67
+
68
+ ## Development
69
+
70
+ ```bash
71
+ npm install
72
+ npm run typecheck
73
+ npm test
74
+ ```
75
+
76
+ Mobile audit — a hit here is a bug:
77
+
78
+ ```bash
79
+ rg -n "document\.|window\.|localStorage|navigator\.|<[a-z]+[ >]|className=|onClick=" client/
80
+ ```
81
+
82
+ ## What it contributes
83
+
84
+ | Contribution | Where |
85
+ | --- | --- |
86
+ | Workspace panel `beads` ("Beads") | Workspace tab bar and the Explorer |
87
+ | Command Center: **Open Beads** | Workspace context |
88
+ | Command Center: **Refresh Beads triage** | Workspace context; re-reads `bv` then opens the panel |
89
+ | Slash command `/beads` | Opens the panel for the current workspace |
90
+ | Slash command `/bead <issue-id>` | Opens the panel with that issue selected |
91
+ | Attachment source **Beads issue** | Composer attachment picker |
92
+
93
+ ## Security and read-only behaviour
94
+
95
+ - Only the daemon-side `server/` code touches Node, the process table, or the filesystem.
96
+ Client code imports no Node module.
97
+ - Subprocesses are spawned with `spawn` using a **literal argv** and an absolute executable
98
+ path resolved by scanning `PATH` directly. `shell` is never enabled, so no input can be
99
+ interpreted as a shell operator.
100
+ - The working directory always comes from Paseo's own workspace record
101
+ (`paseo.workspaces.ref(id).refresh()`), never from client input, and must be an existing
102
+ absolute directory.
103
+ - Only an explicit allowlist of read-only commands can run: `bv --version`,
104
+ `bv --robot-triage`, `bv --robot-plan`, `bv --robot-alerts`, `bv --robot-graph`,
105
+ `bv --robot-search`,
106
+ `<tracker> --db <validated-route> list --status all --fields id,issue_type,assignee --format csv`,
107
+ and `<tracker> --db <validated-route> show --json -- <id>`. `br` reads also pass
108
+ `--no-auto-import --no-auto-flush`. Bare `bv` is never invoked. Neither tracker argv takes any
109
+ user input beyond the validated route and, for `show`, a pattern-checked issue id.
110
+ - The search query is whitespace-collapsed, control-character-stripped, length-bounded, and
111
+ passed as a single argv value. Issue ids are pattern-checked and passed after `--` so an id
112
+ can never be read as a flag.
113
+ - `bv` calls clear inherited `BEADS_DIR`, `BEADS_DB`, `BEADS_JSONL`, and `BD_DB` values so
114
+ daemon-level environment configuration cannot redirect a workspace read. Tracker detail calls set those
115
+ variables only to the validated route.
116
+ - Every subprocess has a timeout and an output size cap, and is killed when either is
117
+ exceeded. Unavailable binary, timeout, non-zero exit, oversized output, and malformed JSON
118
+ are reported as distinct error codes. Plugin cleanup kills any process still running.
119
+ - Plugin code never parses or writes `.beads/*.jsonl` or a Beads SQLite/Dolt database. It reads
120
+ only `.beads/metadata.json` to bind tracker identity to `bv`'s selected source; issue data comes
121
+ from CLI output. `bv` itself may refresh a `bd` compatibility export, so its calls are serialized.
122
+ - `bv --robot-*` is the authority for ranking, recommendations, execution tracks, alerts, cycle
123
+ detection, and velocity. The plugin derives only each issue's work state (from its status and
124
+ its still-open blockers) and the longest remaining dependency chain, because `bv`'s own counts
125
+ treat dependency-blocked work as unblocked and containers as ready; see the design doc. `bv` and its `source_authority`, freshness, and `data_hash` are shown as
126
+ reported.
127
+ - Only short-lived normalized command results are cached (15 s for the dashboard, 2 min for
128
+ tracker identity and its validated route), invalidated purely by expiry. No derived graph is
129
+ ever cached.
130
+
131
+ ## Limitations
132
+
133
+ - Read-only by design. Claiming, closing, and editing issues stay in `br`/`bd`.
134
+ - Issue detail requires an exact tracker and database route. The plugin checks `bv`, then the
135
+ selected source's `.beads/metadata.json`; if identity or routing remains inconclusive, detail
136
+ reads are disabled rather than guessed.
137
+ - Attachment search has no workspace context, so it scans a bounded set of recently active
138
+ Paseo workspaces, searches at most 4 Beads-enabled ones, and returns at most 8 items. An
139
+ issue in a workspace outside that window will not appear.
140
+ - Search relevance, ranking, and analysis semantics are entirely `bv`'s; the plugin only
141
+ reshapes them.
142
+ - Statuses, readiness values, and alert severities are treated as opaque strings, so a newer
143
+ `bv` renders without a plugin update but without bespoke styling for new values.
144
+ - The panel serves any Beads project, so it interprets only what Beads and `bv` define and what
145
+ the data's structure says. Statuses are Beads' built-in set (`open`, `in_progress`, `hooked`,
146
+ `blocked`, `deferred`, `draft`, `pinned`, `closed`, `tombstone`); a custom status declared in a
147
+ project's `.beads/policy.yaml` is shown verbatim under **Other status**, not guessed to be ready
148
+ or done. Groups follow parent links, never type names or id patterns. Labels are the project's
149
+ own vocabulary: they are shown and counted as written, a label carried by every open item is
150
+ set aside as uninformative, and no label is given a meaning — so there is no built-in "needs a
151
+ human". To keep agents off some work, use `bv`'s own `BV_ROBOT_NOT_READY_LABELS`, which the
152
+ panel's `bv` reads inherit.
153
+ - Work states, in Beads' own terms (Ready and Waiting match `br ready` and `br blocked`):
154
+ **Ready** — status `open`, and nothing it depends on is still open. **Waiting** — status
155
+ `open`, but a dependency, or one of its parent's, is still open; a parent's dependency on its
156
+ own children does not count. **In progress** — `in_progress` or `hooked`. **Held** — status
157
+ set by hand to `blocked`, `deferred`, `draft` or `pinned`. **Other status** — a custom status.
158
+ **Done** — `closed`. Each Board column shows its definition under its title.
159
+ - The **Board** is a plain board: one column per work state in the order work moves — Ready,
160
+ Waiting, In progress, Held and Other status (each only when non-empty), and Done (only with
161
+ **Show done**) — each column
162
+ one list that scrolls on its own, with no swimlanes. A **Filter** narrows every column at once
163
+ to one parent's subtree or one of the project's labels; each card names its parent. On a phone
164
+ the columns become a state picker over one list. Issues that other issues name as parent are
165
+ filter options and card context, never cards. Each column renders at most 60 cards with the
166
+ rest reported as `+N more`, and a project past 2000 issues drops closed issues from the payload
167
+ first and says so. If the graph read fails, the panel falls back to the triage and plan
168
+ working set and says so. It is read-only: no drag, no drop, no status change.
169
+ - Issue prose renders through a bounded in-repo Markdown subset (headings h1–h3, lists, task
170
+ items, quotes, rules, code, bold, italic, links). HTML, images, and tables are not rendered,
171
+ and link targets are shown as text — the panel never opens a URL.
@@ -0,0 +1,290 @@
1
+ import type { PluginTheme } from "@getpaseo/plugin";
2
+ import { Icon } from "@getpaseo/plugin/client/react-native";
3
+ import { useState, type ReactNode } from "react";
4
+ import { Pressable, ScrollView, Text, View } from "react-native";
5
+ import {
6
+ boardFilters,
7
+ filterKey,
8
+ parentLabel,
9
+ type BoardColumn,
10
+ type BoardFilter,
11
+ type BoardModel,
12
+ } from "./board";
13
+ import { stateDescription, stateIconName, stateLabel, stateTone, toneColor } from "./format";
14
+ import type { ProjectModel, WorkItem, WorkState } from "./project";
15
+ import { Empty, facetContext, WorkFacets, workAccessibility, type FacetContext } from "./rows";
16
+ import type { PanelStyles } from "./styles";
17
+
18
+ /**
19
+ * Read-only project board: one column per work state, each an independent
20
+ * list, narrowed by one filter. On a compact panel the columns become a state
21
+ * picker over a single list, as board apps do on a phone. There is no drag,
22
+ * drop, or mutation affordance anywhere in this file.
23
+ */
24
+ export function BoardView({
25
+ styles,
26
+ theme,
27
+ project,
28
+ board,
29
+ compact,
30
+ selectedId,
31
+ onSelect,
32
+ onFilterChange,
33
+ onShowDoneChange,
34
+ }: {
35
+ readonly styles: PanelStyles;
36
+ readonly theme: PluginTheme;
37
+ readonly project: ProjectModel;
38
+ readonly board: BoardModel;
39
+ readonly compact: boolean;
40
+ readonly selectedId: string | null;
41
+ readonly onSelect: (issueId: string) => void;
42
+ readonly onFilterChange: (filter: BoardFilter) => void;
43
+ readonly onShowDoneChange: (showDone: boolean) => void;
44
+ }) {
45
+ // Compact shows one column at a time; start where work can be picked up.
46
+ const [compactState, setCompactState] = useState<WorkState>("ready");
47
+ const context = facetContext(project);
48
+ const activeKey = filterKey(board.filter);
49
+
50
+ const caveats = [
51
+ project.complete
52
+ ? null
53
+ : "Whole-project graph unavailable: only triage picks and plan items appear, without their parents, so containers among them cannot be told from work.",
54
+ project.truncated ? "Some closed issues were left out to bound the payload; open work is all here." : null,
55
+ ].filter((line): line is string => line !== null);
56
+
57
+ const controls = (
58
+ <View style={compact ? styles.boardHeaderStacked : styles.boardHeader}>
59
+ <View style={styles.boardControlRow}>
60
+ <Text style={styles.segmentCaption}>Filter</Text>
61
+ <ScrollView
62
+ horizontal
63
+ style={styles.filterScroll}
64
+ contentContainerStyle={styles.filterRow}
65
+ accessibilityRole="tablist"
66
+ accessibilityLabel="Filter the board"
67
+ >
68
+ {boardFilters(project).map((option) => {
69
+ const selected = option.key === activeKey;
70
+ return (
71
+ <Pressable
72
+ key={option.key}
73
+ accessibilityRole="tab"
74
+ accessibilityState={{ selected }}
75
+ accessibilityLabel={`Show ${option.label}, ${option.live} open`}
76
+ onPress={() => onFilterChange(option.filter)}
77
+ style={({ pressed }) => [
78
+ styles.filterChip,
79
+ option.filter.kind === "label" ? styles.filterChipLabel : null,
80
+ selected ? styles.filterChipSelected : null,
81
+ pressed ? styles.actionPressed : null,
82
+ ]}
83
+ >
84
+ <Text
85
+ style={selected ? styles.segmentLabelSelected : styles.segmentLabel}
86
+ numberOfLines={1}
87
+ >
88
+ {option.depth > 0 ? "› " : ""}
89
+ {option.label}
90
+ </Text>
91
+ <Text style={styles.switcherCount}>{option.live}</Text>
92
+ </Pressable>
93
+ );
94
+ })}
95
+ </ScrollView>
96
+ <Pressable
97
+ accessibilityRole="switch"
98
+ accessibilityState={{ checked: board.showDone }}
99
+ accessibilityLabel="Show finished work"
100
+ onPress={() => onShowDoneChange(!board.showDone)}
101
+ style={({ pressed }) => [
102
+ styles.filterChip,
103
+ board.showDone ? styles.filterChipSelected : null,
104
+ pressed ? styles.actionPressed : null,
105
+ ]}
106
+ >
107
+ <Text style={board.showDone ? styles.segmentLabelSelected : styles.segmentLabel}>
108
+ {board.showDone ? "Showing done" : "Show done"}
109
+ </Text>
110
+ </Pressable>
111
+ </View>
112
+ {caveats.map((line) => (
113
+ <Text key={line} style={styles.muted}>
114
+ {line}
115
+ </Text>
116
+ ))}
117
+ </View>
118
+ );
119
+
120
+ if (project.work.length === 0) {
121
+ return (
122
+ <View style={compact ? styles.boardStack : styles.boardPane}>
123
+ {controls}
124
+ <View style={compact ? styles.stateBlock : styles.paneContent}>
125
+ <Empty styles={styles} theme={theme} message="bv reported no issues in this project, so there is nothing to lay out." />
126
+ </View>
127
+ </View>
128
+ );
129
+ }
130
+
131
+ const card = (item: WorkItem, showState: boolean) => (
132
+ <Card
133
+ key={item.id}
134
+ styles={styles}
135
+ theme={theme}
136
+ item={item}
137
+ // The chosen parent is already the whole board's context.
138
+ parent={board.filter.kind === "parent" && board.filter.id === item.parentId ? null : parentLabel(item, project)}
139
+ showState={showState}
140
+ context={context}
141
+ selected={selectedId === item.id}
142
+ onSelect={onSelect}
143
+ />
144
+ );
145
+
146
+ if (compact) {
147
+ const shown = board.columns.find((column) => column.state === compactState) ?? board.columns[0];
148
+ return (
149
+ <View style={styles.boardStack}>
150
+ {controls}
151
+ <View style={styles.segmentRow} accessibilityRole="tablist" accessibilityLabel="Board column">
152
+ {board.columns.map((column) => {
153
+ const selected = column.state === shown?.state;
154
+ return (
155
+ <Pressable
156
+ key={column.state}
157
+ accessibilityRole="tab"
158
+ accessibilityState={{ selected }}
159
+ accessibilityLabel={`${stateLabel(column.state)}, ${column.total}`}
160
+ onPress={() => setCompactState(column.state)}
161
+ style={({ pressed }) => [
162
+ styles.segmentItem,
163
+ styles.segmentItemGrow,
164
+ selected ? styles.segmentItemSelected : null,
165
+ pressed ? styles.actionPressed : null,
166
+ ]}
167
+ >
168
+ <Text style={selected ? styles.segmentLabelSelected : styles.segmentLabel} numberOfLines={1}>
169
+ {stateLabel(column.state)} {column.total}
170
+ </Text>
171
+ </Pressable>
172
+ );
173
+ })}
174
+ </View>
175
+ {shown === undefined ? null : (
176
+ <View style={styles.boardColumnBody}>
177
+ <Text style={styles.boardColumnHint}>{stateDescription(shown.state)}</Text>
178
+ {shown.cards.length === 0 ? <Text style={styles.muted}>Nothing here.</Text> : null}
179
+ {shown.cards.map((item) => card(item, false))}
180
+ <MoreNote styles={styles} hidden={shown.hidden} />
181
+ </View>
182
+ )}
183
+ </View>
184
+ );
185
+ }
186
+
187
+ return (
188
+ <View style={styles.boardPane}>
189
+ {controls}
190
+ <View style={styles.boardColumns}>
191
+ {board.columns.map((column) => (
192
+ <Column key={column.state} styles={styles} theme={theme} column={column}>
193
+ {column.cards.map((item) => card(item, false))}
194
+ </Column>
195
+ ))}
196
+ </View>
197
+ </View>
198
+ );
199
+ }
200
+
201
+ /** One state's column: a fixed header over its own scrolling list. */
202
+ function Column({
203
+ styles,
204
+ theme,
205
+ column,
206
+ children,
207
+ }: {
208
+ readonly styles: PanelStyles;
209
+ readonly theme: PluginTheme;
210
+ readonly column: BoardColumn;
211
+ readonly children: ReactNode;
212
+ }) {
213
+ const tone = stateTone(column.state);
214
+ return (
215
+ <View style={styles.boardColumn}>
216
+ <View
217
+ style={styles.boardColumnHead}
218
+ accessibilityRole="header"
219
+ accessibilityLabel={`${stateLabel(column.state)}, ${column.total}. ${stateDescription(column.state)}`}
220
+ >
221
+ <View style={styles.boardColumnHeader}>
222
+ <Icon
223
+ name={stateIconName(column.state)}
224
+ size={13}
225
+ color={tone === "neutral" ? theme.colors.foregroundMuted : toneColor(theme, tone)}
226
+ />
227
+ <Text style={styles.boardColumnTitle}>{stateLabel(column.state)}</Text>
228
+ <Text style={styles.boardColumnCount}>{column.total}</Text>
229
+ </View>
230
+ <Text style={styles.boardColumnHint}>{stateDescription(column.state)}</Text>
231
+ </View>
232
+ <ScrollView style={styles.boardScroll} contentContainerStyle={styles.boardColumnBody}>
233
+ {column.total === 0 ? <Text style={styles.muted}>Nothing here.</Text> : null}
234
+ {children}
235
+ <MoreNote styles={styles} hidden={column.hidden} />
236
+ </ScrollView>
237
+ </View>
238
+ );
239
+ }
240
+
241
+ function MoreNote({ styles, hidden }: { readonly styles: PanelStyles; readonly hidden: number }) {
242
+ if (hidden === 0) return null;
243
+ return <Text style={styles.muted}>+{hidden} more. Narrow the filter or search to reach them.</Text>;
244
+ }
245
+
246
+ function Card({
247
+ styles,
248
+ theme,
249
+ item,
250
+ parent,
251
+ showState,
252
+ context,
253
+ selected,
254
+ onSelect,
255
+ }: {
256
+ readonly styles: PanelStyles;
257
+ readonly theme: PluginTheme;
258
+ readonly item: WorkItem;
259
+ /** The parent's title, shown as the card's context; null when it would repeat the filter. */
260
+ readonly parent: string | null;
261
+ readonly showState: boolean;
262
+ readonly context: FacetContext;
263
+ readonly selected: boolean;
264
+ readonly onSelect: (issueId: string) => void;
265
+ }) {
266
+ return (
267
+ <Pressable
268
+ accessibilityRole="button"
269
+ accessibilityState={{ selected }}
270
+ accessibilityLabel={`${workAccessibility(item)}${parent === null ? "" : `, in ${parent}`}`}
271
+ onPress={() => onSelect(item.id)}
272
+ style={({ pressed }) => [styles.boardCard, selected || pressed ? styles.boardCardSelected : null]}
273
+ >
274
+ <View style={[styles.boardCardRail, { backgroundColor: toneColor(theme, stateTone(item.state)) }]} />
275
+ <View style={styles.boardCardBody}>
276
+ {parent === null ? null : (
277
+ <Text style={styles.boardCardParent} numberOfLines={1}>
278
+ {parent}
279
+ </Text>
280
+ )}
281
+ <Text style={styles.boardCardTitle} numberOfLines={2}>
282
+ {item.title}
283
+ </Text>
284
+ <View style={styles.facetRow}>
285
+ <WorkFacets styles={styles} theme={theme} item={item} showState={showState} context={context} />
286
+ </View>
287
+ </View>
288
+ </Pressable>
289
+ );
290
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Board layout for the read-only project board: a plain board, one column per
3
+ * derived {@link WorkState}, each column one continuous list.
4
+ *
5
+ * Columns are the derived state, not the raw status: on a real project every
6
+ * live issue had status `open`, so a status board was one column of 27 cards
7
+ * that could not tell ready work from work waiting on three others.
8
+ *
9
+ * Grouping is a filter, not swimlanes. A lanes × columns matrix left most cells
10
+ * empty, repeated a card in every label lane it carried, and made the reader
11
+ * scan two ways at once; the usual boards (GitHub Projects, Linear, Jira) keep
12
+ * swimlanes off by default for the same reasons. Each card names its parent
13
+ * instead, and choosing a parent or a label narrows every column at once.
14
+ *
15
+ * Only work is carded; a container is a filter option and a card's context.
16
+ * Everything is pure for the Vitest node environment.
17
+ */
18
+ import { countStates, WORK_STATES, type ProjectModel, type StateCounts, type WorkItem, type WorkState } from "./project";
19
+
20
+ /** What the board is narrowed to: everything, one parent's subtree, or one label. */
21
+ export type BoardFilter =
22
+ | { readonly kind: "all" }
23
+ | { readonly kind: "parent"; readonly id: string }
24
+ | { readonly kind: "label"; readonly label: string };
25
+
26
+ export const ALL_WORK: BoardFilter = { kind: "all" };
27
+
28
+ export interface BoardFilterOption {
29
+ readonly key: string;
30
+ readonly filter: BoardFilter;
31
+ readonly label: string;
32
+ /** Unfinished work the option would show. */
33
+ readonly live: number;
34
+ /** Nesting depth for parents: 0 for a top-level issue. */
35
+ readonly depth: number;
36
+ }
37
+
38
+ /** Cards rendered per column before the rest is reported as "+N more". */
39
+ export const BOARD_COLUMN_CARD_LIMIT = 60;
40
+
41
+ export interface BoardColumn {
42
+ readonly state: WorkState;
43
+ readonly cards: readonly WorkItem[];
44
+ readonly total: number;
45
+ readonly hidden: number;
46
+ }
47
+
48
+ export interface BoardModel {
49
+ /** The filter applied, which falls back to all work when the chosen one no longer matches. */
50
+ readonly filter: BoardFilter;
51
+ /** Columns to render, in {@link WORK_STATES} order. */
52
+ readonly columns: readonly BoardColumn[];
53
+ /** Work per state under the filter. */
54
+ readonly counts: StateCounts;
55
+ readonly showDone: boolean;
56
+ }
57
+
58
+ export function filterKey(filter: BoardFilter): string {
59
+ switch (filter.kind) {
60
+ case "all":
61
+ return "all";
62
+ case "parent":
63
+ return `parent:${filter.id}`;
64
+ case "label":
65
+ return `label:${filter.label}`;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Filters this project's data supports: every parent that still has open
71
+ * work, nested under its top-level issue in plan order, then the project's own
72
+ * labels, most used first. Nothing here comes from a fixed list.
73
+ */
74
+ export function boardFilters(project: ProjectModel): readonly BoardFilterOption[] {
75
+ const options: BoardFilterOption[] = [
76
+ {
77
+ key: "all",
78
+ filter: ALL_WORK,
79
+ label: "All work",
80
+ live: project.work.length - project.counts.done,
81
+ depth: 0,
82
+ },
83
+ ];
84
+ for (const root of project.roots) {
85
+ if (root.id === null || root.settled) continue;
86
+ options.push(parentOption(root.id, root.title, root.total - root.done, 0));
87
+ for (const pkg of root.packages) {
88
+ if (pkg.id === null || pkg.id === root.id || pkg.settled) continue;
89
+ options.push(parentOption(pkg.id, pkg.title, pkg.total - pkg.done, 1));
90
+ }
91
+ }
92
+ for (const stat of project.labels) {
93
+ const filter: BoardFilter = { kind: "label", label: stat.label };
94
+ options.push({ key: filterKey(filter), filter, label: stat.label, live: stat.live, depth: 0 });
95
+ }
96
+ return options;
97
+ }
98
+
99
+ function parentOption(id: string, title: string, live: number, depth: number): BoardFilterOption {
100
+ const filter: BoardFilter = { kind: "parent", id };
101
+ return { key: filterKey(filter), filter, label: title, live, depth };
102
+ }
103
+
104
+ export function buildBoard(project: ProjectModel, filter: BoardFilter, showDone: boolean): BoardModel {
105
+ // A filter chosen on an earlier read may have nothing left to match.
106
+ const known = boardFilters(project).some((option) => option.key === filterKey(filter));
107
+ const active = known ? filter : ALL_WORK;
108
+ const items = project.work.filter((item) => matches(item, active, project));
109
+ const counts = countStates(items);
110
+
111
+ // Held and other statuses are exceptions, so their columns appear only when
112
+ // something is in them. Done is a column only on request.
113
+ const states = WORK_STATES.filter((state) => {
114
+ if (state === "held") return counts.held > 0;
115
+ if (state === "other") return counts.other > 0;
116
+ if (state === "done") return showDone;
117
+ return true;
118
+ });
119
+
120
+ const columns = states.map((state) => {
121
+ // Work arrives in project order, which is already the in-column order.
122
+ const inState = items.filter((item) => item.state === state);
123
+ return {
124
+ state,
125
+ cards: inState.slice(0, BOARD_COLUMN_CARD_LIMIT),
126
+ total: inState.length,
127
+ hidden: Math.max(0, inState.length - BOARD_COLUMN_CARD_LIMIT),
128
+ };
129
+ });
130
+
131
+ return { filter: active, columns, counts, showDone };
132
+ }
133
+
134
+ function matches(item: WorkItem, filter: BoardFilter, project: ProjectModel): boolean {
135
+ switch (filter.kind) {
136
+ case "all":
137
+ return true;
138
+ case "label":
139
+ return item.labels.includes(filter.label);
140
+ case "parent":
141
+ return hasAncestor(item, filter.id, project);
142
+ }
143
+ }
144
+
145
+ /** True when `id` is on the item's parent chain; a cyclic chain ends the walk. */
146
+ function hasAncestor(item: WorkItem, id: string, project: ProjectModel): boolean {
147
+ const visited = new Set<string>([item.id]);
148
+ for (let parent = item.parentId; parent !== null && !visited.has(parent); ) {
149
+ if (parent === id) return true;
150
+ visited.add(parent);
151
+ parent = project.byId.get(parent)?.parentId ?? null;
152
+ }
153
+ return false;
154
+ }
155
+
156
+ /** A card's context line: its direct parent's title, or its id when the parent is not loaded. */
157
+ export function parentLabel(item: WorkItem, project: ProjectModel): string | null {
158
+ if (item.parentId === null) return null;
159
+ return project.byId.get(item.parentId)?.title ?? item.parentId;
160
+ }