@nklisch/pi-agile-workflow 0.15.3
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/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +28 -0
- package/CHANGELOG.md +591 -0
- package/README.md +107 -0
- package/docs/ARCHITECTURE.md +716 -0
- package/docs/MIGRATION.md +419 -0
- package/docs/PRINCIPLES.md +464 -0
- package/docs/ROADMAP.md +95 -0
- package/docs/SPEC.md +855 -0
- package/docs/VISION.md +106 -0
- package/extensions/agile-workflow.test.ts +419 -0
- package/extensions/agile-workflow.ts +467 -0
- package/hooks/hooks.json +52 -0
- package/hooks/scripts/prompt-context.py +628 -0
- package/hooks/scripts/substrate-maintainer.py +359 -0
- package/hooks/scripts/test_prompt_context.py +757 -0
- package/package.json +30 -0
- package/scripts/install-work-view.sh +165 -0
- package/scripts/tests/agent-metadata.test.sh +75 -0
- package/scripts/tests/bump-version.test.sh +552 -0
- package/scripts/tests/channel-parity.test.sh +117 -0
- package/scripts/tests/convert-content-integrity.test.sh +246 -0
- package/scripts/tests/convert-install-routing.test.sh +159 -0
- package/scripts/tests/install-work-view.test.sh +411 -0
- package/scripts/tests/pi-package-metadata.test.sh +119 -0
- package/scripts/tests/work-board-shim.test.sh +239 -0
- package/scripts/tests/work-view-dist-version.test.sh +105 -0
- package/scripts/work-board.sh +64 -0
- package/scripts/work-view.sh +423 -0
- package/skills/autopilot/SKILL.md +330 -0
- package/skills/autopilot/agents/openai.yaml +6 -0
- package/skills/board/SKILL.md +62 -0
- package/skills/board/agents/openai.yaml +6 -0
- package/skills/bold-refactor/SKILL.md +244 -0
- package/skills/bold-refactor/agents/openai.yaml +6 -0
- package/skills/bug-scan/SKILL.md +378 -0
- package/skills/bug-scan/agents/openai.yaml +6 -0
- package/skills/bug-scan/references/async-promises.md +119 -0
- package/skills/bug-scan/references/concurrency-races.md +148 -0
- package/skills/bug-scan/references/data-layer.md +167 -0
- package/skills/bug-scan/references/error-handling.md +197 -0
- package/skills/bug-scan/references/gate-item-template.md +94 -0
- package/skills/bug-scan/references/language-footguns.md +219 -0
- package/skills/bug-scan/references/parked-item-template.md +103 -0
- package/skills/bug-scan/references/report-template.md +115 -0
- package/skills/bug-scan/references/resource-leaks.md +199 -0
- package/skills/bug-scan/references/state-closures.md +148 -0
- package/skills/bug-scan/references/time-numbers.md +158 -0
- package/skills/convert/SKILL.md +1463 -0
- package/skills/convert/agents/openai.yaml +6 -0
- package/skills/convert/references/legacy-overlap-migration.md +179 -0
- package/skills/deep-code-scan/SKILL.md +378 -0
- package/skills/deep-code-scan/agents/openai.yaml +6 -0
- package/skills/deep-code-scan/references/artifact-ledger.md +148 -0
- package/skills/deep-code-scan/references/consolidation.md +91 -0
- package/skills/deep-code-scan/references/decomposition.md +127 -0
- package/skills/deep-code-scan/references/item-templates.md +263 -0
- package/skills/deep-code-scan/references/lane-catalog.md +129 -0
- package/skills/deep-code-scan/references/review-gauntlet.md +108 -0
- package/skills/deep-code-scan/references/scanner-brief.md +152 -0
- package/skills/e2e-test-design/SKILL.md +517 -0
- package/skills/e2e-test-design/references/anti-tautology.md +261 -0
- package/skills/e2e-test-design/references/service-mocks.md +234 -0
- package/skills/e2e-test-design/references/test-taxonomy.md +211 -0
- package/skills/epic-design/SKILL.md +476 -0
- package/skills/epicize/SKILL.md +210 -0
- package/skills/epicize/agents/openai.yaml +6 -0
- package/skills/feature-design/SKILL.md +472 -0
- package/skills/fix/SKILL.md +173 -0
- package/skills/gate-cruft/SKILL.md +258 -0
- package/skills/gate-docs/SKILL.md +278 -0
- package/skills/gate-patterns/SKILL.md +389 -0
- package/skills/gate-refactor/SKILL.md +408 -0
- package/skills/gate-security/SKILL.md +275 -0
- package/skills/gate-tests/SKILL.md +367 -0
- package/skills/groom/SKILL.md +162 -0
- package/skills/groom/agents/openai.yaml +6 -0
- package/skills/ideate/SKILL.md +185 -0
- package/skills/ideate/agents/openai.yaml +6 -0
- package/skills/implement/SKILL.md +288 -0
- package/skills/implement-orchestrator/SKILL.md +604 -0
- package/skills/park/SKILL.md +111 -0
- package/skills/perf-design/SKILL.md +427 -0
- package/skills/perf-scout/SKILL.md +404 -0
- package/skills/perf-scout/agents/openai.yaml +6 -0
- package/skills/perf-scout/references/algorithmic-and-data-structures.md +126 -0
- package/skills/perf-scout/references/approximation-and-precomputation.md +136 -0
- package/skills/perf-scout/references/caching-and-memoization.md +123 -0
- package/skills/perf-scout/references/compiler-runtime-and-language.md +136 -0
- package/skills/perf-scout/references/database-and-storage-internals.md +144 -0
- package/skills/perf-scout/references/distributed-systems.md +150 -0
- package/skills/perf-scout/references/game-engine-and-realtime.md +136 -0
- package/skills/perf-scout/references/gpu-and-accelerators.md +130 -0
- package/skills/perf-scout/references/idea-ranking.md +75 -0
- package/skills/perf-scout/references/io-and-batching.md +135 -0
- package/skills/perf-scout/references/memory-and-data-locality.md +137 -0
- package/skills/perf-scout/references/parallelism-and-vectorization.md +126 -0
- package/skills/perf-scout/references/parked-item-template.md +117 -0
- package/skills/perf-scout/references/peer-review-pass.md +150 -0
- package/skills/perf-scout/references/report-template.md +148 -0
- package/skills/principles/SKILL.md +678 -0
- package/skills/principles/references/models.md +191 -0
- package/skills/principles/references/subagents.md +158 -0
- package/skills/prose-author/SKILL.md +185 -0
- package/skills/refactor-conventions-creator/SKILL.md +359 -0
- package/skills/refactor-conventions-creator/agents/openai.yaml +6 -0
- package/skills/refactor-conventions-creator/references/common-structures.md +91 -0
- package/skills/refactor-conventions-creator/references/common-styles.md +90 -0
- package/skills/refactor-design/SKILL.md +384 -0
- package/skills/release-deploy/SKILL.md +667 -0
- package/skills/release-deploy/agents/openai.yaml +6 -0
- package/skills/research/SKILL.md +166 -0
- package/skills/research/agents/openai.yaml +6 -0
- package/skills/review/SKILL.md +267 -0
- package/skills/review/references/deep-review.md +98 -0
- package/skills/review/references/review-lenses.md +68 -0
- package/skills/review/references/substrate-side-effects.md +161 -0
- package/skills/review/references/target-resolution.md +52 -0
- package/skills/scope/SKILL.md +486 -0
- package/work-view/Cargo.lock +486 -0
- package/work-view/Cargo.toml +9 -0
- package/work-view/crates/cli/.work-view-version +1 -0
- package/work-view/crates/cli/Cargo.toml +18 -0
- package/work-view/crates/cli/src/actionable.rs +500 -0
- package/work-view/crates/cli/src/args.rs +792 -0
- package/work-view/crates/cli/src/board/assets/board.css +855 -0
- package/work-view/crates/cli/src/board/assets/board.js +259 -0
- package/work-view/crates/cli/src/board/assets/card.js +139 -0
- package/work-view/crates/cli/src/board/assets/components.css +290 -0
- package/work-view/crates/cli/src/board/assets/dependency.js +1193 -0
- package/work-view/crates/cli/src/board/assets/detail.js +256 -0
- package/work-view/crates/cli/src/board/assets/filters.js +389 -0
- package/work-view/crates/cli/src/board/assets/index.html +77 -0
- package/work-view/crates/cli/src/board/assets/kanban.js +231 -0
- package/work-view/crates/cli/src/board/assets/markdown.js +260 -0
- package/work-view/crates/cli/src/board/assets/motion.css +131 -0
- package/work-view/crates/cli/src/board/assets/state.js +245 -0
- package/work-view/crates/cli/src/board/assets/table.js +304 -0
- package/work-view/crates/cli/src/board/assets/tokens.css +602 -0
- package/work-view/crates/cli/src/board/assets/views.js +41 -0
- package/work-view/crates/cli/src/board/assets.rs +162 -0
- package/work-view/crates/cli/src/board/feed.rs +204 -0
- package/work-view/crates/cli/src/board/mod.rs +246 -0
- package/work-view/crates/cli/src/board/open.rs +144 -0
- package/work-view/crates/cli/src/board/server.rs +407 -0
- package/work-view/crates/cli/src/main.rs +195 -0
- package/work-view/crates/cli/src/render.rs +354 -0
- package/work-view/crates/cli/src/scope.rs +157 -0
- package/work-view/crates/cli/src/stale.rs +581 -0
- package/work-view/crates/cli/tests/board-js/dependency-table.test.mjs +64 -0
- package/work-view/crates/cli/tests/board-js/expanded-browsing.test.mjs +79 -0
- package/work-view/crates/cli/tests/board-js/harness-smoke.test.mjs +11 -0
- package/work-view/crates/cli/tests/board-js/harness.mjs +437 -0
- package/work-view/crates/cli/tests/board-js/kanban-detail.test.mjs +75 -0
- package/work-view/crates/cli/tests/board-js/markdown-filter.test.mjs +60 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/CONVENTIONS.md +3 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/epics/epic-alpha.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/features/feat-a.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/features/feat-b.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/stories/story-alpha-1.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/stories/story-research-1.md +19 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/archive/feat-done.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/archive/feat-shipped.md +18 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/backlog/idea-backlog.md +7 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/releases/v1.0/v1.0.md +16 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/CONVENTIONS.md +6 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-dep-done.md +17 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-design-ready.md +17 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-impl-ready.md +17 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/stories/story-review-ready.md +17 -0
- package/work-view/crates/cli/tests/fixtures/with-malformed/.work/CONVENTIONS.md +4 -0
- package/work-view/crates/cli/tests/fixtures/with-malformed/.work/active/epics/malformed-no-closing-fence.md +8 -0
- package/work-view/crates/cli/tests/fixtures/with-malformed/.work/active/features/good-item.md +16 -0
- package/work-view/crates/cli/tests/integration.rs +2833 -0
- package/work-view/crates/core/Cargo.toml +13 -0
- package/work-view/crates/core/src/error.rs +61 -0
- package/work-view/crates/core/src/filter.rs +804 -0
- package/work-view/crates/core/src/graph.rs +324 -0
- package/work-view/crates/core/src/index.rs +590 -0
- package/work-view/crates/core/src/lib.rs +54 -0
- package/work-view/crates/core/src/model.rs +202 -0
- package/work-view/crates/core/src/parse.rs +494 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/CONVENTIONS.md +15 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/epics/epic-core.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/features/feat-parser.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/features/feat-query.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/malformed-no-id.md +15 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/story-parse-unit.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/story-security-gate.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/archive/feat-old.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/archive/feat-parser.md +17 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/backlog/idea-future-board.md +7 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/releases/v1.0/v1.0.md +16 -0
- package/work-view/crates/core/tests/fixtures/precedence/.work/CONVENTIONS.md +10 -0
- package/work-view/crates/core/tests/fixtures/precedence/.work/archive/dup-item.md +18 -0
- package/work-view/crates/core/tests/fixtures/precedence/.work/releases/v2.0/dup-item.md +18 -0
- package/work-view/crates/core/tests/integration.rs +541 -0
- package/work-view/dist/.gitattributes +1 -0
- package/work-view/dist/README.md +42 -0
- package/work-view/dist/aarch64-apple-darwin/work-view +0 -0
- package/work-view/dist/aarch64-unknown-linux-musl/work-view +0 -0
- package/work-view/dist/x86_64-apple-darwin/work-view +0 -0
- package/work-view/dist/x86_64-unknown-linux-musl/work-view +0 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cloneFilters,
|
|
3
|
+
matchesFilters,
|
|
4
|
+
normalizeFilterValue,
|
|
5
|
+
normalizeFilters,
|
|
6
|
+
serializeFilters,
|
|
7
|
+
} from "/assets/filters.js";
|
|
8
|
+
|
|
9
|
+
const STORAGE_KEY = "agile-workflow-board:v1";
|
|
10
|
+
const VIEWS = new Set(["kanban", "dependency", "table"]);
|
|
11
|
+
const ACCENTS = new Set(["teal", "amber", "violet", "azure", "lime", "candy"]);
|
|
12
|
+
const MODES = new Set(["system", "light", "dark"]);
|
|
13
|
+
|
|
14
|
+
function defaultTheme() {
|
|
15
|
+
return {
|
|
16
|
+
accent: "teal",
|
|
17
|
+
mode: "system",
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function loadPersisted(storage) {
|
|
22
|
+
if (!storage) {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const raw = storage.getItem(STORAGE_KEY);
|
|
27
|
+
if (!raw) {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
const parsed = JSON.parse(raw);
|
|
31
|
+
if (!parsed || typeof parsed !== "object") {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
return parsed;
|
|
35
|
+
} catch (_error) {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function validateView(view) {
|
|
41
|
+
return VIEWS.has(view) ? view : "kanban";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function validateTheme(raw) {
|
|
45
|
+
const theme = defaultTheme();
|
|
46
|
+
if (!raw || typeof raw !== "object") {
|
|
47
|
+
return theme;
|
|
48
|
+
}
|
|
49
|
+
if (ACCENTS.has(raw.accent)) {
|
|
50
|
+
theme.accent = raw.accent;
|
|
51
|
+
}
|
|
52
|
+
if (MODES.has(raw.mode)) {
|
|
53
|
+
theme.mode = raw.mode;
|
|
54
|
+
}
|
|
55
|
+
return theme;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeSnapshot(snapshot) {
|
|
59
|
+
if (!snapshot || typeof snapshot !== "object") {
|
|
60
|
+
throw new Error("Substrate feed was not a JSON object");
|
|
61
|
+
}
|
|
62
|
+
if (!Array.isArray(snapshot.items)) {
|
|
63
|
+
throw new Error("Substrate feed did not include an items array");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const items = snapshot.items.map((item) => (item && typeof item === "object" ? item : {}));
|
|
67
|
+
const diagnostics = snapshot.diagnostics && typeof snapshot.diagnostics === "object"
|
|
68
|
+
? snapshot.diagnostics
|
|
69
|
+
: {};
|
|
70
|
+
const clientDuplicateIds = duplicateIds(items);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
...snapshot,
|
|
74
|
+
items,
|
|
75
|
+
diagnostics: {
|
|
76
|
+
parse_errors: Array.isArray(diagnostics.parse_errors) ? diagnostics.parse_errors : [],
|
|
77
|
+
validation_warnings: Array.isArray(diagnostics.validation_warnings) ? diagnostics.validation_warnings : [],
|
|
78
|
+
duplicate_ids: Array.isArray(diagnostics.duplicate_ids) ? diagnostics.duplicate_ids : [],
|
|
79
|
+
client_duplicate_ids: clientDuplicateIds,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function duplicateIds(items) {
|
|
85
|
+
const seen = new Set();
|
|
86
|
+
const duplicates = new Set();
|
|
87
|
+
for (const item of items) {
|
|
88
|
+
if (typeof item.id !== "string" || item.id === "") {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (seen.has(item.id)) {
|
|
92
|
+
duplicates.add(item.id);
|
|
93
|
+
}
|
|
94
|
+
seen.add(item.id);
|
|
95
|
+
}
|
|
96
|
+
return Array.from(duplicates).sort();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function createBoardStore({ storage = globalThis.localStorage, fetchJson, root = document } = {}) {
|
|
100
|
+
const persisted = loadPersisted(storage);
|
|
101
|
+
const listeners = new Set();
|
|
102
|
+
const requestJson = fetchJson || ((url) => fetch(url).then((response) => {
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
throw new Error(`GET ${url} failed with ${response.status}`);
|
|
105
|
+
}
|
|
106
|
+
return response.json();
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
let state = {
|
|
110
|
+
snapshot: null,
|
|
111
|
+
loading: false,
|
|
112
|
+
error: null,
|
|
113
|
+
view: validateView(persisted.view),
|
|
114
|
+
selectedItemId: null,
|
|
115
|
+
filters: normalizeFilters(persisted.filters),
|
|
116
|
+
theme: validateTheme(persisted.theme),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
function getState() {
|
|
120
|
+
return {
|
|
121
|
+
...state,
|
|
122
|
+
filters: cloneFilters(state.filters),
|
|
123
|
+
theme: { ...state.theme },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function notify() {
|
|
128
|
+
const publicState = getState();
|
|
129
|
+
for (const listener of listeners) {
|
|
130
|
+
listener(publicState);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function persist() {
|
|
135
|
+
if (!storage) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
storage.setItem(STORAGE_KEY, JSON.stringify({
|
|
140
|
+
view: state.view,
|
|
141
|
+
filters: serializeFilters(state.filters),
|
|
142
|
+
theme: state.theme,
|
|
143
|
+
}));
|
|
144
|
+
} catch (_error) {
|
|
145
|
+
// Storage can be unavailable in private browsing; the board still works.
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function subscribe(fn) {
|
|
150
|
+
listeners.add(fn);
|
|
151
|
+
return () => listeners.delete(fn);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function refresh() {
|
|
155
|
+
state = { ...state, loading: true, error: null };
|
|
156
|
+
notify();
|
|
157
|
+
try {
|
|
158
|
+
const snapshot = normalizeSnapshot(await requestJson("/api/substrate"));
|
|
159
|
+
const selectedItemId = state.selectedItemId && snapshot.items.some((item) => item.id === state.selectedItemId)
|
|
160
|
+
? state.selectedItemId
|
|
161
|
+
: null;
|
|
162
|
+
state = { ...state, snapshot, selectedItemId, loading: false, error: null };
|
|
163
|
+
} catch (error) {
|
|
164
|
+
state = {
|
|
165
|
+
...state,
|
|
166
|
+
loading: false,
|
|
167
|
+
error: error instanceof Error ? error.message : String(error),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
notify();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function setView(id) {
|
|
174
|
+
const view = validateView(id);
|
|
175
|
+
if (state.view === view) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
state = { ...state, view };
|
|
179
|
+
persist();
|
|
180
|
+
notify();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function setTheme(partial) {
|
|
184
|
+
const next = validateTheme({ ...state.theme, ...(partial || {}) });
|
|
185
|
+
if (state.theme.accent === next.accent && state.theme.mode === next.mode) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
state = { ...state, theme: next };
|
|
189
|
+
persist();
|
|
190
|
+
notify();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function setFilter(key, value) {
|
|
194
|
+
const filters = cloneFilters(state.filters);
|
|
195
|
+
filters[key] = normalizeFilterValue(key, value);
|
|
196
|
+
state = { ...state, filters };
|
|
197
|
+
persist();
|
|
198
|
+
notify();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function setSelectedItem(id) {
|
|
202
|
+
const selectedItemId = id && state.snapshot?.items.some((item) => item.id === id)
|
|
203
|
+
? String(id)
|
|
204
|
+
: null;
|
|
205
|
+
if (state.selectedItemId === selectedItemId) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
state = { ...state, selectedItemId };
|
|
209
|
+
notify();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function matches(item) {
|
|
213
|
+
return matchesFilters(item, state.filters, state.snapshot);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function visibleItems() {
|
|
217
|
+
return state.snapshot ? state.snapshot.items.filter(matches) : [];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function getItemById(id) {
|
|
221
|
+
if (!state.snapshot) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
return state.snapshot.items.find((item) => item.id === id) || null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
root?.addEventListener?.("visibilitychange", () => {
|
|
228
|
+
if (root.visibilityState === "visible" && !state.snapshot && !state.loading) {
|
|
229
|
+
void refresh();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
getState,
|
|
235
|
+
subscribe,
|
|
236
|
+
refresh,
|
|
237
|
+
setView,
|
|
238
|
+
setTheme,
|
|
239
|
+
setFilter,
|
|
240
|
+
setSelectedItem,
|
|
241
|
+
visibleItems,
|
|
242
|
+
matches,
|
|
243
|
+
getItemById,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { deriveFilterOptions } from "/assets/filters.js";
|
|
2
|
+
|
|
3
|
+
const COLUMNS = [
|
|
4
|
+
{ id: "id", label: "id", className: "mono" },
|
|
5
|
+
{ id: "kind", label: "kind" },
|
|
6
|
+
{ id: "stage", label: "stage" },
|
|
7
|
+
{ id: "status", label: "status" },
|
|
8
|
+
{ id: "parent", label: "parent", className: "mono" },
|
|
9
|
+
{ id: "depends_on", label: "depends_on", className: "mono" },
|
|
10
|
+
{ id: "updated", label: "updated", className: "mono" },
|
|
11
|
+
];
|
|
12
|
+
const STATUS_ORDER = ["blocked", "ready", "active", "done"];
|
|
13
|
+
|
|
14
|
+
let sortState = null;
|
|
15
|
+
let pendingHeaderFocus = null;
|
|
16
|
+
let pendingFilterFocus = null;
|
|
17
|
+
const columnFilters = new Map();
|
|
18
|
+
|
|
19
|
+
function textElement(tag, className, text) {
|
|
20
|
+
const element = document.createElement(tag);
|
|
21
|
+
if (className) {
|
|
22
|
+
element.className = className;
|
|
23
|
+
}
|
|
24
|
+
element.textContent = text;
|
|
25
|
+
return element;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function arrayCount(value) {
|
|
29
|
+
return Array.isArray(value) ? value.length : 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function statusFor(item) {
|
|
33
|
+
if (item?.blocked) {
|
|
34
|
+
return "blocked";
|
|
35
|
+
}
|
|
36
|
+
if (item?.ready) {
|
|
37
|
+
return "ready";
|
|
38
|
+
}
|
|
39
|
+
if (item?.is_terminal || item?.stage === "done" || item?.stage === "released") {
|
|
40
|
+
return "done";
|
|
41
|
+
}
|
|
42
|
+
return "active";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function compareText(left, right) {
|
|
46
|
+
return String(left || "").localeCompare(String(right || ""));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function compareNumber(left, right) {
|
|
50
|
+
return left === right ? 0 : left < right ? -1 : 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function updatedTime(item) {
|
|
54
|
+
const time = Date.parse(item?.updated || "");
|
|
55
|
+
return Number.isNaN(time) ? 0 : time;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function dependencySortValue(item) {
|
|
59
|
+
return arrayCount(item?.unmet_deps) * 10000 + arrayCount(item?.depends_on);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function stageIndex(snapshot, stage) {
|
|
63
|
+
const stages = deriveFilterOptions(snapshot).stages;
|
|
64
|
+
const index = stages.indexOf(stage || "");
|
|
65
|
+
return index === -1 ? stages.length : index;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function tableValue(item, columnId) {
|
|
69
|
+
switch (columnId) {
|
|
70
|
+
case "id":
|
|
71
|
+
return item?.id || "";
|
|
72
|
+
case "kind":
|
|
73
|
+
return item?.kind || "";
|
|
74
|
+
case "stage":
|
|
75
|
+
return item?.stage || "";
|
|
76
|
+
case "status":
|
|
77
|
+
return statusFor(item);
|
|
78
|
+
case "parent":
|
|
79
|
+
return item?.parent || "—";
|
|
80
|
+
case "depends_on": {
|
|
81
|
+
const total = arrayCount(item?.depends_on);
|
|
82
|
+
const unmet = arrayCount(item?.unmet_deps);
|
|
83
|
+
return unmet > 0 ? `${unmet}/${total} unmet` : String(total);
|
|
84
|
+
}
|
|
85
|
+
case "updated":
|
|
86
|
+
return item?.updated || "";
|
|
87
|
+
default:
|
|
88
|
+
return "";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function compareRows(left, right, columnId, snapshot) {
|
|
93
|
+
switch (columnId) {
|
|
94
|
+
case "status":
|
|
95
|
+
return compareNumber(STATUS_ORDER.indexOf(statusFor(left)), STATUS_ORDER.indexOf(statusFor(right)));
|
|
96
|
+
case "stage":
|
|
97
|
+
return compareNumber(stageIndex(snapshot, left?.stage), stageIndex(snapshot, right?.stage));
|
|
98
|
+
case "depends_on":
|
|
99
|
+
return compareNumber(dependencySortValue(left), dependencySortValue(right));
|
|
100
|
+
case "updated":
|
|
101
|
+
return compareNumber(updatedTime(left), updatedTime(right));
|
|
102
|
+
default:
|
|
103
|
+
return compareText(tableValue(left, columnId), tableValue(right, columnId));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function defaultCompare(left, right, snapshot) {
|
|
108
|
+
return compareRows(left, right, "status", snapshot)
|
|
109
|
+
|| compareRows(left, right, "stage", snapshot)
|
|
110
|
+
|| -compareRows(left, right, "updated", snapshot)
|
|
111
|
+
|| compareRows(left, right, "id", snapshot);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function sortedItems(items, snapshot) {
|
|
115
|
+
const indexed = items.map((item, index) => ({ item, index }));
|
|
116
|
+
indexed.sort((left, right) => {
|
|
117
|
+
if (!sortState) {
|
|
118
|
+
return defaultCompare(left.item, right.item, snapshot) || compareNumber(left.index, right.index);
|
|
119
|
+
}
|
|
120
|
+
const direction = sortState.dir === "desc" ? -1 : 1;
|
|
121
|
+
return direction * compareRows(left.item, right.item, sortState.column, snapshot)
|
|
122
|
+
|| compareRows(left.item, right.item, "id", snapshot)
|
|
123
|
+
|| compareNumber(left.index, right.index);
|
|
124
|
+
});
|
|
125
|
+
return indexed.map(({ item }) => item);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function filterItems(items) {
|
|
129
|
+
const filters = Array.from(columnFilters.entries()).filter(([, value]) => value.trim() !== "");
|
|
130
|
+
if (filters.length === 0) {
|
|
131
|
+
return items;
|
|
132
|
+
}
|
|
133
|
+
return items.filter((item) => filters.every(([columnId, needle]) => (
|
|
134
|
+
tableValue(item, columnId).toLowerCase().includes(needle.trim().toLowerCase())
|
|
135
|
+
)));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function toggleSort(columnId) {
|
|
139
|
+
if (sortState?.column === columnId) {
|
|
140
|
+
sortState = { column: columnId, dir: sortState.dir === "asc" ? "desc" : "asc" };
|
|
141
|
+
} else {
|
|
142
|
+
sortState = { column: columnId, dir: "asc" };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function renderFilterRow(root, ctx) {
|
|
147
|
+
const row = document.createElement("tr");
|
|
148
|
+
row.className = "table-filter-row";
|
|
149
|
+
for (const column of COLUMNS) {
|
|
150
|
+
const th = document.createElement("th");
|
|
151
|
+
const input = document.createElement("input");
|
|
152
|
+
input.type = "search";
|
|
153
|
+
input.value = columnFilters.get(column.id) || "";
|
|
154
|
+
input.placeholder = column.label;
|
|
155
|
+
input.dataset.column = column.id;
|
|
156
|
+
input.setAttribute("aria-label", `Filter ${column.label}`);
|
|
157
|
+
input.addEventListener("input", () => {
|
|
158
|
+
if (input.value.trim() === "") {
|
|
159
|
+
columnFilters.delete(column.id);
|
|
160
|
+
} else {
|
|
161
|
+
columnFilters.set(column.id, input.value);
|
|
162
|
+
}
|
|
163
|
+
pendingFilterFocus = column.id;
|
|
164
|
+
tableView.mount(root, ctx);
|
|
165
|
+
});
|
|
166
|
+
th.append(input);
|
|
167
|
+
row.append(th);
|
|
168
|
+
}
|
|
169
|
+
return row;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function renderHeader(root, ctx) {
|
|
173
|
+
const thead = document.createElement("thead");
|
|
174
|
+
const row = document.createElement("tr");
|
|
175
|
+
for (const column of COLUMNS) {
|
|
176
|
+
const th = document.createElement("th");
|
|
177
|
+
const active = sortState?.column === column.id;
|
|
178
|
+
th.setAttribute("aria-sort", active ? (sortState.dir === "asc" ? "ascending" : "descending") : "none");
|
|
179
|
+
const button = document.createElement("button");
|
|
180
|
+
button.className = "table-sort-button";
|
|
181
|
+
button.type = "button";
|
|
182
|
+
button.dataset.column = column.id;
|
|
183
|
+
button.textContent = column.label;
|
|
184
|
+
button.addEventListener("click", () => {
|
|
185
|
+
toggleSort(column.id);
|
|
186
|
+
pendingHeaderFocus = column.id;
|
|
187
|
+
tableView.mount(root, ctx);
|
|
188
|
+
});
|
|
189
|
+
th.append(button);
|
|
190
|
+
if (active) {
|
|
191
|
+
th.append(textElement("span", "sort", sortState.dir === "asc" ? "↑" : "↓"));
|
|
192
|
+
}
|
|
193
|
+
row.append(th);
|
|
194
|
+
}
|
|
195
|
+
thead.append(row, renderFilterRow(root, ctx));
|
|
196
|
+
return thead;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function renderStatusCell(item) {
|
|
200
|
+
const status = statusFor(item);
|
|
201
|
+
const td = document.createElement("td");
|
|
202
|
+
if (status === "active") {
|
|
203
|
+
td.textContent = status;
|
|
204
|
+
return td;
|
|
205
|
+
}
|
|
206
|
+
td.append(textElement("span", `badge badge--${status}`, status));
|
|
207
|
+
return td;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function openItemDetail(item, ctx) {
|
|
211
|
+
if (item?.id) {
|
|
212
|
+
ctx.openDetail(item.id);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function renderRow(item, ctx) {
|
|
217
|
+
const row = document.createElement("tr");
|
|
218
|
+
row.tabIndex = 0;
|
|
219
|
+
row.dataset.itemId = item?.id || "";
|
|
220
|
+
row.setAttribute("role", "button");
|
|
221
|
+
row.setAttribute("aria-label", `Open ${item?.id || "item"} detail`);
|
|
222
|
+
row.addEventListener("click", () => openItemDetail(item, ctx));
|
|
223
|
+
row.addEventListener("keydown", (event) => {
|
|
224
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
225
|
+
event.preventDefault();
|
|
226
|
+
openItemDetail(item, ctx);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
for (const column of COLUMNS) {
|
|
231
|
+
if (column.id === "status") {
|
|
232
|
+
row.append(renderStatusCell(item));
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
row.append(textElement("td", column.className || "", tableValue(item, column.id)));
|
|
236
|
+
}
|
|
237
|
+
return row;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function renderTable(items, ctx, root) {
|
|
241
|
+
const table = document.createElement("table");
|
|
242
|
+
table.className = "table board-table";
|
|
243
|
+
const tbody = document.createElement("tbody");
|
|
244
|
+
for (const item of items) {
|
|
245
|
+
tbody.append(renderRow(item, ctx));
|
|
246
|
+
}
|
|
247
|
+
table.append(renderHeader(root, ctx), tbody);
|
|
248
|
+
return table;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function restoreHeaderFocus(root) {
|
|
252
|
+
if (!pendingHeaderFocus) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const focusColumn = pendingHeaderFocus;
|
|
256
|
+
pendingHeaderFocus = null;
|
|
257
|
+
for (const button of root.querySelectorAll(".table-sort-button")) {
|
|
258
|
+
if (button.dataset.column === focusColumn) {
|
|
259
|
+
button.focus({ preventScroll: true });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function restoreFilterFocus(root) {
|
|
266
|
+
if (!pendingFilterFocus) {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const focusColumn = pendingFilterFocus;
|
|
270
|
+
pendingFilterFocus = null;
|
|
271
|
+
for (const input of root.querySelectorAll(".table-filter-row input")) {
|
|
272
|
+
if (input.dataset.column === focusColumn) {
|
|
273
|
+
input.focus({ preventScroll: true });
|
|
274
|
+
if (typeof input.setSelectionRange === "function") {
|
|
275
|
+
input.setSelectionRange(input.value.length, input.value.length);
|
|
276
|
+
}
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export const tableView = {
|
|
283
|
+
id: "table",
|
|
284
|
+
label: "Table",
|
|
285
|
+
mount(root, ctx) {
|
|
286
|
+
const state = ctx.getState();
|
|
287
|
+
const items = sortedItems(filterItems(ctx.visibleItems()), state.snapshot);
|
|
288
|
+
const view = document.createElement("div");
|
|
289
|
+
view.className = "table-view";
|
|
290
|
+
const header = document.createElement("header");
|
|
291
|
+
header.className = "table-view__header";
|
|
292
|
+
header.append(
|
|
293
|
+
textElement("h1", "", "Table"),
|
|
294
|
+
textElement("p", "view-digest", `${items.length} filtered items in a dense table.`),
|
|
295
|
+
);
|
|
296
|
+
const tableWrap = document.createElement("div");
|
|
297
|
+
tableWrap.className = "table-wrap";
|
|
298
|
+
tableWrap.append(renderTable(items, ctx, root));
|
|
299
|
+
view.append(header, tableWrap);
|
|
300
|
+
root.replaceChildren(view);
|
|
301
|
+
restoreHeaderFocus(root);
|
|
302
|
+
restoreFilterFocus(root);
|
|
303
|
+
},
|
|
304
|
+
};
|