super-backlog 0.9.0 → 0.10.1
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/README.md +3 -2
- package/dist/cli.js +28 -0
- package/dist/commands/backlog-alias.js +21 -0
- package/dist/commands/serve.js +70 -0
- package/dist/dashboard/data.js +25 -1
- package/dist/dashboard/server.js +177 -19
- package/dist/templates/dashboard.html +195 -229
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -52,6 +52,7 @@ After installation:
|
|
|
52
52
|
|
|
53
53
|
```bash
|
|
54
54
|
npm run board # open the Backlog.md kanban board
|
|
55
|
+
sbl serve # dashboard server + Backlog browser with live reload
|
|
55
56
|
sbl dashboard --serve # live Project Dashboard on http://localhost:6428
|
|
56
57
|
```
|
|
57
58
|
|
|
@@ -74,7 +75,7 @@ sbl dashboard --serve # live Project Dashboard on http://localhost:6
|
|
|
74
75
|
| `.git/hooks/post-commit` | dashboard freshness block — regenerates `dashboard.html` after commits that touch `backlog/` (default; opt out with `--no-refresh-hook`) | appended marker block |
|
|
75
76
|
| `.git/hooks/pre-commit` | integrity guard hook — only with `--guard` (opt-in) | appended marker block |
|
|
76
77
|
|
|
77
|
-
Commands: `sbl init` · `sbl models` · `sbl doctor` · `sbl uninstall [--with-backlog]` · `sbl update` · `sbl dashboard [--serve] [--port <n>] [--no-open] [--out <file>]`. See `sbl help` for every flag.
|
|
78
|
+
Commands: `sbl init` · `sbl models` · `sbl doctor` · `sbl uninstall [--with-backlog]` · `sbl update` · `sbl dashboard [--serve] [--port <n>] [--no-open] [--out <file>]` · `sbl serve [--port <n>] [--no-open] [--out <file>]`. See `sbl help` for every flag.
|
|
78
79
|
|
|
79
80
|
## Model router (opt-in)
|
|
80
81
|
|
|
@@ -98,7 +99,7 @@ The router is fully owned by super-backlog and removed by `sbl uninstall`. See t
|
|
|
98
99
|
|
|
99
100
|
## Project Dashboard
|
|
100
101
|
|
|
101
|
-
`sbl dashboard` generates a single self-contained `dashboard.html`: a dark, HTS-style cockpit rendered from your Backlog data in seven sections — Board & Quick Actions, Status (donut), Milestones, Tasks (sortable/filterable table
|
|
102
|
+
`sbl dashboard` generates a single self-contained `dashboard.html`: a dark, HTS-style cockpit rendered from your Backlog data in seven sections — Board & Quick Actions, Status (donut), Milestones, Tasks (sortable/filterable table; click a row to open a modal detail view with acceptance criteria and dependencies), Feature Cycle (pipeline stepper plus an Up Next / Blocked flow view from task dependencies), Activity (30-day sparkline), and Decisions & Docs. Glossary tooltips explain domain terms inline; extend or override them project-wide via `backlog/docs/glossary.md` (`## Term` heading plus the text below it). No CDNs, no external fonts — works offline, diffs cleanly in git, hostable anywhere. Use `sbl dashboard --serve` for live mode: it watches `backlog/`, regenerates on change, and serves on port 6428; connected browser tabs reload automatically via Server-Sent Events. `sbl serve` starts the dashboard server and the Backlog browser together.
|
|
102
103
|
|
|
103
104
|
### Keeping it fresh
|
|
104
105
|
|
package/dist/cli.js
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
import { parseArgs } from 'node:util';
|
|
4
4
|
import process from 'node:process';
|
|
5
5
|
import { runDashboard } from './commands/dashboard.js';
|
|
6
|
+
import { runBacklogSubcommand } from './commands/backlog-alias.js';
|
|
6
7
|
import { runDoctor } from './commands/doctor.js';
|
|
7
8
|
import { runInit } from './commands/init.js';
|
|
8
9
|
import { runModels } from './commands/models.js';
|
|
10
|
+
import { runServe } from './commands/serve.js';
|
|
9
11
|
import { runUninstall } from './commands/uninstall.js';
|
|
10
12
|
import { runUpdate } from './commands/update.js';
|
|
11
13
|
import { assertNode20, KIT_VERSION } from './lib/version.js';
|
|
@@ -18,6 +20,9 @@ Commands:
|
|
|
18
20
|
uninstall Remove kit-managed files (project data kept unless --with-backlog)
|
|
19
21
|
update Refresh kit-managed files and report upstream versions
|
|
20
22
|
dashboard Generate the single-file project dashboard (--serve for live mode)
|
|
23
|
+
serve Start dashboard server and the Backlog browser together
|
|
24
|
+
browser Open the Backlog.md browser (delegates to backlog browser)
|
|
25
|
+
board Show the Backlog.md board (delegates to backlog board)
|
|
21
26
|
models Manage the model router (show, enable, disable, discover)
|
|
22
27
|
doctor Check the environment (node, PowerShell policy, backlog CLI)
|
|
23
28
|
|
|
@@ -45,6 +50,10 @@ dashboard options:
|
|
|
45
50
|
--no-open With --serve: do not open the browser automatically
|
|
46
51
|
--out <file> Output file name or path (default: dashboard.html)
|
|
47
52
|
|
|
53
|
+
serve options:
|
|
54
|
+
--port <n> Port for the dashboard server (default: 6428)
|
|
55
|
+
--no-open Do not open the dashboard browser automatically
|
|
56
|
+
|
|
48
57
|
doctor options:
|
|
49
58
|
(none) Prints one [ok]/[warn]/[skip] line per check; exit 4 on any warn
|
|
50
59
|
|
|
@@ -120,6 +129,25 @@ async function main(argv) {
|
|
|
120
129
|
positionals: parsed.positionals,
|
|
121
130
|
});
|
|
122
131
|
}
|
|
132
|
+
case 'serve': {
|
|
133
|
+
const parsed = parseArgs({
|
|
134
|
+
args: rest,
|
|
135
|
+
allowPositionals: true,
|
|
136
|
+
options: {
|
|
137
|
+
port: { type: 'string' },
|
|
138
|
+
'no-open': { type: 'boolean' },
|
|
139
|
+
out: { type: 'string' },
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
return await runServe(process.cwd(), {
|
|
143
|
+
values: parsed.values,
|
|
144
|
+
positionals: parsed.positionals,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
case 'browser':
|
|
148
|
+
return await runBacklogSubcommand(process.cwd(), 'browser', rest);
|
|
149
|
+
case 'board':
|
|
150
|
+
return await runBacklogSubcommand(process.cwd(), 'board', rest);
|
|
123
151
|
case 'models': {
|
|
124
152
|
const parsed = parseArgs({ args: rest, allowPositionals: true, options: {} });
|
|
125
153
|
return await runModels(process.cwd(), {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// src/commands/backlog-alias.ts
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { resolveBacklogBin } from '../lib/run.js';
|
|
5
|
+
/** Run a backlog.md subcommand by delegating to the resolved backlog binary. */
|
|
6
|
+
export function runBacklogSubcommand(cwd, subcommand, args = []) {
|
|
7
|
+
const bin = resolveBacklogBin(cwd);
|
|
8
|
+
if (!bin) {
|
|
9
|
+
console.error('error: backlog CLI not found; is backlog.md installed?');
|
|
10
|
+
return Promise.resolve(1);
|
|
11
|
+
}
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const child = spawn(bin, [subcommand, ...args], {
|
|
14
|
+
cwd,
|
|
15
|
+
stdio: 'inherit',
|
|
16
|
+
shell: process.platform === 'win32',
|
|
17
|
+
});
|
|
18
|
+
child.on('error', () => resolve(1));
|
|
19
|
+
child.on('exit', (code) => resolve(code ?? 1));
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// src/commands/serve.ts
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { collectDashboardData } from '../dashboard/data.js';
|
|
6
|
+
import { renderDashboard } from '../dashboard/render.js';
|
|
7
|
+
import { DASHBOARD_PORT, startServeServer } from '../dashboard/server.js';
|
|
8
|
+
import { atomicWrite } from '../lib/atomic.js';
|
|
9
|
+
import { resolveBacklogBin } from '../lib/run.js';
|
|
10
|
+
import { KIT_VERSION } from '../lib/version.js';
|
|
11
|
+
async function regenerateInto(outPath, cwd) {
|
|
12
|
+
const data = collectDashboardData(cwd, { kitVersion: KIT_VERSION });
|
|
13
|
+
atomicWrite(outPath, renderDashboard(data));
|
|
14
|
+
}
|
|
15
|
+
function spawnBacklogBrowser(cwd, port) {
|
|
16
|
+
const bin = resolveBacklogBin(cwd);
|
|
17
|
+
if (!bin) {
|
|
18
|
+
console.warn('warning: backlog CLI not found; dashboard will serve without the Backlog browser');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const url = `http://127.0.0.1:${port}/`;
|
|
22
|
+
try {
|
|
23
|
+
const child = spawn(bin, ['browser', '--no-open', '--non-interactive'], {
|
|
24
|
+
cwd,
|
|
25
|
+
detached: true,
|
|
26
|
+
stdio: 'ignore',
|
|
27
|
+
shell: process.platform === 'win32',
|
|
28
|
+
});
|
|
29
|
+
child.on('error', () => { });
|
|
30
|
+
child.unref();
|
|
31
|
+
console.log('started Backlog browser (dashboard still serves if browser fails)');
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
console.warn('warning: failed to start Backlog browser; dashboard still serves');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** CLI entry for `sbl serve [--port N] [--no-open]`. */
|
|
38
|
+
export async function runServe(cwd, args) {
|
|
39
|
+
const values = args.values;
|
|
40
|
+
let port = DASHBOARD_PORT;
|
|
41
|
+
if (values['port'] !== undefined) {
|
|
42
|
+
const parsed = Number(values['port']);
|
|
43
|
+
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) {
|
|
44
|
+
console.error(`error: invalid --port "${String(values['port'])}" (expected 0-65535)`);
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
port = parsed;
|
|
48
|
+
}
|
|
49
|
+
const noOpen = values['no-open'] === true;
|
|
50
|
+
const outFile = values['out'] === undefined ? 'dashboard.html' : String(values['out']);
|
|
51
|
+
const outPath = isAbsolute(outFile) ? outFile : resolve(cwd, outFile);
|
|
52
|
+
try {
|
|
53
|
+
await regenerateInto(outPath, cwd);
|
|
54
|
+
console.log(`dashboard written: ${outPath}`);
|
|
55
|
+
console.log(`serving dashboard at http://127.0.0.1:${port}/ (press Ctrl+C to stop)`);
|
|
56
|
+
// Start backlog browser in parallel; don't await so the dashboard server can listen immediately.
|
|
57
|
+
spawnBacklogBrowser(cwd, port);
|
|
58
|
+
await startServeServer(cwd, {
|
|
59
|
+
port,
|
|
60
|
+
file: outPath,
|
|
61
|
+
regenerate: () => regenerateInto(outPath, cwd),
|
|
62
|
+
openBrowser: !noOpen,
|
|
63
|
+
});
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
console.error(`error: serve failed (${err instanceof Error ? err.message : String(err)})`);
|
|
68
|
+
return 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
package/dist/dashboard/data.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/dashboard/data.ts
|
|
2
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
3
|
import { basename, join } from 'node:path';
|
|
4
4
|
import { resolveBacklogBin, runCapture } from '../lib/run.js';
|
|
5
5
|
import { readSimpleKeys } from '../lib/yamlmini.js';
|
|
@@ -216,6 +216,29 @@ function readProjectGlossary(cwd) {
|
|
|
216
216
|
return [];
|
|
217
217
|
}
|
|
218
218
|
}
|
|
219
|
+
function readDraftFile(path) {
|
|
220
|
+
const keys = readSimpleKeys(path, ['id', 'title', 'status']);
|
|
221
|
+
const id = asString(keys.id);
|
|
222
|
+
const title = asString(keys.title);
|
|
223
|
+
const status = asString(keys.status);
|
|
224
|
+
if (!id || !title || !status)
|
|
225
|
+
return null;
|
|
226
|
+
return { id, title, status };
|
|
227
|
+
}
|
|
228
|
+
export function readDrafts(cwd) {
|
|
229
|
+
const draftsDir = join(cwd, 'backlog', 'drafts');
|
|
230
|
+
if (!existsSync(draftsDir))
|
|
231
|
+
return [];
|
|
232
|
+
const out = [];
|
|
233
|
+
for (const entry of readdirSync(draftsDir, { withFileTypes: true })) {
|
|
234
|
+
if (!entry.isFile() || !entry.name.endsWith('.md'))
|
|
235
|
+
continue;
|
|
236
|
+
const draft = readDraftFile(join(draftsDir, entry.name));
|
|
237
|
+
if (draft)
|
|
238
|
+
out.push(draft);
|
|
239
|
+
}
|
|
240
|
+
return out.sort((a, b) => a.id.localeCompare(b.id));
|
|
241
|
+
}
|
|
219
242
|
function readProjectIdentity(cwd) {
|
|
220
243
|
const cfg = readSimpleKeys(join(cwd, 'backlog', 'config.yml'), [
|
|
221
244
|
'project_name',
|
|
@@ -252,6 +275,7 @@ export function collectDashboardData(cwd, opts) {
|
|
|
252
275
|
milestones: [],
|
|
253
276
|
tasks: [],
|
|
254
277
|
deps: [],
|
|
278
|
+
drafts: readDrafts(cwd),
|
|
255
279
|
activity: computeActivity([], today),
|
|
256
280
|
glossary: mergeGlossary(readProjectGlossary(cwd)),
|
|
257
281
|
source: 'fallback-empty',
|
package/dist/dashboard/server.js
CHANGED
|
@@ -6,7 +6,177 @@ import { createServer } from 'node:http';
|
|
|
6
6
|
import { isAbsolute, join } from 'node:path';
|
|
7
7
|
import process from 'node:process';
|
|
8
8
|
import { createModelApiHandler } from '../models/dashboard-api.js';
|
|
9
|
+
import { resolveBacklogBin } from '../lib/run.js';
|
|
9
10
|
export const DASHBOARD_PORT = 6428;
|
|
11
|
+
const WHITELIST = new Map([
|
|
12
|
+
['browser', ['browser']],
|
|
13
|
+
['board', ['board']],
|
|
14
|
+
]);
|
|
15
|
+
function isRecord(v) {
|
|
16
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
17
|
+
}
|
|
18
|
+
async function readBody(req) {
|
|
19
|
+
const chunks = [];
|
|
20
|
+
for await (const chunk of req) {
|
|
21
|
+
chunks.push(Buffer.from(chunk));
|
|
22
|
+
}
|
|
23
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
24
|
+
}
|
|
25
|
+
/** Safe /api/run handler: only whitelisted backlog subcommands may be spawned. */
|
|
26
|
+
export function createRunApiHandler(cwd) {
|
|
27
|
+
return async (req, res) => {
|
|
28
|
+
if (req.method !== 'POST' || req.url !== '/api/run') {
|
|
29
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
30
|
+
res.end('not found');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
let body;
|
|
34
|
+
try {
|
|
35
|
+
body = await readBody(req);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
39
|
+
res.end(JSON.stringify({ error: 'failed to read body' }));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
let payload;
|
|
43
|
+
try {
|
|
44
|
+
payload = JSON.parse(body);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
48
|
+
res.end(JSON.stringify({ error: 'invalid json' }));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (!isRecord(payload) || typeof payload.command !== 'string' || !WHITELIST.has(payload.command)) {
|
|
52
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
53
|
+
res.end(JSON.stringify({ error: 'unknown command' }));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const bin = resolveBacklogBin(cwd);
|
|
57
|
+
if (!bin) {
|
|
58
|
+
res.writeHead(503, { 'content-type': 'application/json' });
|
|
59
|
+
res.end(JSON.stringify({ error: 'backlog cli not found' }));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const args = WHITELIST.get(payload.command);
|
|
63
|
+
try {
|
|
64
|
+
const child = spawn(bin, args, {
|
|
65
|
+
cwd,
|
|
66
|
+
detached: true,
|
|
67
|
+
stdio: 'ignore',
|
|
68
|
+
shell: process.platform === 'win32',
|
|
69
|
+
});
|
|
70
|
+
child.on('error', () => { });
|
|
71
|
+
child.unref();
|
|
72
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
73
|
+
res.end(JSON.stringify({ ok: true }));
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
res.writeHead(500, { 'content-type': 'application/json' });
|
|
77
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** SSE broker: keeps a set of response objects and broadcasts named events. */
|
|
82
|
+
export function createReloadBroker() {
|
|
83
|
+
const clients = new Set();
|
|
84
|
+
let closed = false;
|
|
85
|
+
function handler(req, res) {
|
|
86
|
+
if (req.url !== '/api/events' || req.method !== 'GET')
|
|
87
|
+
return false;
|
|
88
|
+
res.writeHead(200, {
|
|
89
|
+
'content-type': 'text/event-stream',
|
|
90
|
+
'cache-control': 'no-cache',
|
|
91
|
+
connection: 'keep-alive',
|
|
92
|
+
});
|
|
93
|
+
res.write(':ok\n\n');
|
|
94
|
+
clients.add(res);
|
|
95
|
+
const cleanup = () => {
|
|
96
|
+
clients.delete(res);
|
|
97
|
+
};
|
|
98
|
+
req.on('close', cleanup);
|
|
99
|
+
req.on('error', cleanup);
|
|
100
|
+
res.on('close', cleanup);
|
|
101
|
+
res.on('error', cleanup);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
function broadcast(event) {
|
|
105
|
+
if (closed)
|
|
106
|
+
return;
|
|
107
|
+
// A data line is mandatory: per the HTML standard, EventSource never
|
|
108
|
+
// dispatches an event whose data buffer is empty, so `event: x\n\n`
|
|
109
|
+
// alone would silently never reach addEventListener('x', ...) clients.
|
|
110
|
+
const message = `event: ${event}\ndata: {}\n\n`;
|
|
111
|
+
for (const client of clients) {
|
|
112
|
+
try {
|
|
113
|
+
client.write(message);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
clients.delete(client);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function clientCount() {
|
|
121
|
+
return clients.size;
|
|
122
|
+
}
|
|
123
|
+
function close() {
|
|
124
|
+
if (closed)
|
|
125
|
+
return;
|
|
126
|
+
closed = true;
|
|
127
|
+
for (const client of clients) {
|
|
128
|
+
try {
|
|
129
|
+
client.end();
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// ignore
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
clients.clear();
|
|
136
|
+
}
|
|
137
|
+
return { handler, broadcast, clientCount, close };
|
|
138
|
+
}
|
|
139
|
+
/** Debounced wrapper around a regenerate callback; on success invokes onReload. */
|
|
140
|
+
export function createDebouncedReloader(regenerate, onReload, delayMs) {
|
|
141
|
+
let timer = null;
|
|
142
|
+
function trigger() {
|
|
143
|
+
if (!regenerate)
|
|
144
|
+
return;
|
|
145
|
+
if (timer !== null)
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
timer = setTimeout(() => {
|
|
148
|
+
timer = null;
|
|
149
|
+
void Promise.resolve()
|
|
150
|
+
.then(regenerate)
|
|
151
|
+
.then(() => {
|
|
152
|
+
onReload();
|
|
153
|
+
})
|
|
154
|
+
.catch(() => { });
|
|
155
|
+
}, delayMs);
|
|
156
|
+
}
|
|
157
|
+
function cancel() {
|
|
158
|
+
if (timer !== null) {
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
timer = null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { trigger, cancel };
|
|
164
|
+
}
|
|
165
|
+
function createApiHandler(cwd, broker) {
|
|
166
|
+
const modelApi = createModelApiHandler();
|
|
167
|
+
const runApi = createRunApiHandler(cwd);
|
|
168
|
+
return async (req, res) => {
|
|
169
|
+
const url = req.url ?? '/';
|
|
170
|
+
if (url === '/api/run') {
|
|
171
|
+
await runApi(req, res);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (broker.handler(req, res)) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
await modelApi(req, res);
|
|
178
|
+
};
|
|
179
|
+
}
|
|
10
180
|
export function recursiveWatchSupported(platform, nodeVersion) {
|
|
11
181
|
// Node 24 on Windows triggers a libuv assertion in recursive fs.watch:
|
|
12
182
|
// https://github.com/nodejs/node/issues/xxx (fs-event.c line 72)
|
|
@@ -43,25 +213,14 @@ export async function startServeServer(cwd, opts = {}) {
|
|
|
43
213
|
const file = opts.file ?? 'dashboard.html';
|
|
44
214
|
const filePath = isAbsolute(file) ? file : join(cwd, file);
|
|
45
215
|
const regenerate = opts.regenerate;
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
if (!regenerate)
|
|
49
|
-
return;
|
|
50
|
-
if (timer !== null)
|
|
51
|
-
clearTimeout(timer);
|
|
52
|
-
timer = setTimeout(() => {
|
|
53
|
-
timer = null;
|
|
54
|
-
void Promise.resolve()
|
|
55
|
-
.then(regenerate)
|
|
56
|
-
.catch(() => { }); // regeneration failures never kill the server
|
|
57
|
-
}, 300);
|
|
58
|
-
};
|
|
216
|
+
const broker = createReloadBroker();
|
|
217
|
+
const reloader = createDebouncedReloader(regenerate, () => broker.broadcast('reload'), 300);
|
|
59
218
|
let watcher = null;
|
|
60
219
|
const backlogDir = join(cwd, 'backlog');
|
|
61
220
|
if (recursiveWatchSupported(process.platform, process.versions.node)) {
|
|
62
221
|
try {
|
|
63
222
|
// recursive so subdirectory writes (e.g. backlog/tasks/*.md) fire on every platform
|
|
64
|
-
watcher = watch(backlogDir, { persistent: true, recursive: true },
|
|
223
|
+
watcher = watch(backlogDir, { persistent: true, recursive: true }, () => reloader.trigger());
|
|
65
224
|
watcher.on('error', () => { }); // e.g. watched dir removed mid-session
|
|
66
225
|
}
|
|
67
226
|
catch {
|
|
@@ -71,10 +230,10 @@ export async function startServeServer(cwd, opts = {}) {
|
|
|
71
230
|
else {
|
|
72
231
|
console.warn('warning: live reload is disabled because Node 24+ on Windows cannot reliably watch directories recursively (libuv fs-event bug); use Node 22 or Linux/macOS for --serve');
|
|
73
232
|
}
|
|
74
|
-
const
|
|
233
|
+
const apiHandler = createApiHandler(cwd, broker);
|
|
75
234
|
const server = createServer((req, res) => {
|
|
76
235
|
if (req.url?.startsWith('/api/')) {
|
|
77
|
-
void
|
|
236
|
+
void apiHandler(req, res);
|
|
78
237
|
return;
|
|
79
238
|
}
|
|
80
239
|
const url = req.url ?? '/';
|
|
@@ -114,9 +273,8 @@ export async function startServeServer(cwd, opts = {}) {
|
|
|
114
273
|
server,
|
|
115
274
|
port,
|
|
116
275
|
close() {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
timer = null;
|
|
276
|
+
reloader.cancel();
|
|
277
|
+
broker.close();
|
|
120
278
|
watcher?.close();
|
|
121
279
|
watcher = null;
|
|
122
280
|
return new Promise((resolveClose) => {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>__PROJECT_NAME__ · Project Dashboard</title>
|
|
7
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ccircle cx='50' cy='50' r='42' fill='%235cc8ff'/%3E%3C/svg%3E">
|
|
7
8
|
<style>
|
|
8
9
|
:root {
|
|
9
10
|
--bg:#0a0e16;
|
|
@@ -26,10 +27,25 @@
|
|
|
26
27
|
--danger-bg:#331718;
|
|
27
28
|
--mono:"Cascadia Code",Consolas,"Courier New",monospace;
|
|
28
29
|
}
|
|
30
|
+
@font-face {
|
|
31
|
+
font-family: 'Inter';
|
|
32
|
+
src: local('Inter'), local('Inter-Regular');
|
|
33
|
+
font-weight: 400; font-style: normal;
|
|
34
|
+
}
|
|
35
|
+
@font-face {
|
|
36
|
+
font-family: 'Inter';
|
|
37
|
+
src: local('Inter Medium'), local('Inter-Medium');
|
|
38
|
+
font-weight: 500; font-style: normal;
|
|
39
|
+
}
|
|
40
|
+
@font-face {
|
|
41
|
+
font-family: 'Inter';
|
|
42
|
+
src: local('Inter SemiBold'), local('Inter-SemiBold');
|
|
43
|
+
font-weight: 600; font-style: normal;
|
|
44
|
+
}
|
|
29
45
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
30
46
|
html { scroll-behavior: smooth; }
|
|
31
47
|
body {
|
|
32
|
-
font-family: "Segoe UI",
|
|
48
|
+
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
33
49
|
background:
|
|
34
50
|
radial-gradient(1100px 500px at 85% -10%, var(--bg-glow-1), transparent 60%),
|
|
35
51
|
radial-gradient(900px 500px at -10% 30%, var(--bg-glow-2), transparent 55%),
|
|
@@ -91,15 +107,22 @@
|
|
|
91
107
|
}
|
|
92
108
|
|
|
93
109
|
/* ---------- Detail panel ---------- */
|
|
94
|
-
#
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
110
|
+
#task-dialog {
|
|
111
|
+
width: min(640px, 92vw); max-height: 85vh; overflow-y: auto; padding: 0;
|
|
112
|
+
border: 1px solid var(--line-strong); border-radius: 14px;
|
|
113
|
+
background: var(--surface); color: var(--text);
|
|
114
|
+
box-shadow: 0 24px 80px rgba(0,0,0,.55);
|
|
115
|
+
}
|
|
116
|
+
#task-dialog::backdrop {
|
|
117
|
+
background: rgba(4,7,12,.65);
|
|
118
|
+
backdrop-filter: blur(2px);
|
|
119
|
+
}
|
|
120
|
+
#task-dialog[open] { animation: sbl-dialog-in .18s ease-out; }
|
|
121
|
+
@media (prefers-reduced-motion: reduce) {
|
|
122
|
+
#task-dialog[open] { animation: none; }
|
|
123
|
+
}
|
|
124
|
+
@keyframes sbl-dialog-in { from { transform: scale(.96); opacity: 0; } to { transform: none; opacity: 1; } }
|
|
125
|
+
.dialog-content { padding: 22px 24px 28px; }
|
|
103
126
|
.detail-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
|
104
127
|
.detail-id { font-family: var(--mono); color: var(--accent); font-weight: 700; }
|
|
105
128
|
.status-chip { font-family: var(--mono); font-size: .68rem; padding: 2px 10px; border-radius: 999px; border: 1px solid var(--line-strong); background: var(--surface-2); color: var(--muted); white-space: nowrap; }
|
|
@@ -109,9 +132,12 @@
|
|
|
109
132
|
.status-chip[data-tone="danger"] { color: var(--danger); border-color: #5c2c2c; background: var(--danger-bg); }
|
|
110
133
|
.detail-close { margin-left: auto; background: none; border: none; color: var(--dim); font-size: 1.3rem; cursor: pointer; line-height: 1; }
|
|
111
134
|
.detail-close:hover { color: var(--danger); }
|
|
112
|
-
.detail-title { font-size: 1.
|
|
113
|
-
#
|
|
114
|
-
#
|
|
135
|
+
.detail-title { font-size: 1.18rem; font-weight: 600; margin-bottom: 8px; overflow-wrap: anywhere; }
|
|
136
|
+
#task-dialog .detail-desc { color: var(--muted); margin-bottom: 14px; white-space: normal; }
|
|
137
|
+
#task-dialog h4 { color: var(--dim); font-size: .7rem; letter-spacing: 1.3px; text-transform: uppercase; margin: 16px 0 6px; }
|
|
138
|
+
.meta-row { display: flex; flex-wrap: wrap; gap: 14px; font-size: .85rem; color: var(--muted); }
|
|
139
|
+
.meta-row span { display: flex; align-items: center; gap: 4px; }
|
|
140
|
+
.meta-label { color: var(--dim); }
|
|
115
141
|
.dep-row { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
116
142
|
.dep-link {
|
|
117
143
|
font-family: var(--mono); font-size: .74rem; padding: 2px 10px; border-radius: 999px;
|
|
@@ -137,12 +163,18 @@
|
|
|
137
163
|
|
|
138
164
|
/* ---------- Quick actions ---------- */
|
|
139
165
|
.cmd-row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
|
140
|
-
.cmd {
|
|
166
|
+
.cmd-btn {
|
|
141
167
|
font-family: var(--mono); font-size: .84rem; padding: 6px 14px; border-radius: 999px;
|
|
142
168
|
background: var(--surface); border: 1px solid var(--line-strong); color: var(--text);
|
|
169
|
+
cursor: pointer;
|
|
143
170
|
}
|
|
144
|
-
.cmd
|
|
171
|
+
.cmd-btn:hover { background: var(--surface-2); border-color: var(--accent); color: var(--accent); }
|
|
172
|
+
.cmd-btn:active { transform: translateY(1px); }
|
|
173
|
+
.cmd-btn::before { content: "$ "; color: var(--accent); }
|
|
174
|
+
.cmd-btn[data-copy]::after { content: " copy"; font-size: .7em; color: var(--dim); }
|
|
145
175
|
.hint { color: var(--dim); font-size: .8rem; margin-top: 12px; }
|
|
176
|
+
.drafts-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
|
|
177
|
+
.drafts-list li { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 8px 12px; font-size: .88rem; }
|
|
146
178
|
code.inline {
|
|
147
179
|
font-family: var(--mono); font-size: .85em;
|
|
148
180
|
background: var(--surface-2); border: 1px solid var(--line);
|
|
@@ -170,14 +202,8 @@
|
|
|
170
202
|
.task-row { cursor: pointer; }
|
|
171
203
|
.task-row:hover td { background: var(--surface-2); }
|
|
172
204
|
.cell-id { color: var(--accent); font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
|
173
|
-
.cell-title { font-weight:
|
|
205
|
+
.cell-title { font-weight: 400; white-space: normal; min-width: 220px; }
|
|
174
206
|
.cell-updated { font-family: var(--mono); color: var(--muted); }
|
|
175
|
-
.task-detail td { white-space: normal; background: var(--surface-2); border-top: none; }
|
|
176
|
-
.detail-desc { margin: 0 0 8px; color: var(--muted); }
|
|
177
|
-
.detail-empty { margin: 0; color: var(--dim); }
|
|
178
|
-
.ac-list { margin: 0; padding: 0; list-style: none; }
|
|
179
|
-
.ac { display: flex; align-items: baseline; gap: 8px; padding: 2px 0; }
|
|
180
|
-
.ac.done span { text-decoration: line-through; color: var(--dim); }
|
|
181
207
|
|
|
182
208
|
/* ---------- Footer ---------- */
|
|
183
209
|
.footer {
|
|
@@ -234,24 +260,26 @@
|
|
|
234
260
|
.step-label b { display: block; color: var(--text); font-size: .71rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
235
261
|
.step.gate .step-label b { color: var(--warn); }
|
|
236
262
|
|
|
237
|
-
/* ---------- Dependency
|
|
263
|
+
/* ---------- Dependency flow ---------- */
|
|
238
264
|
.sub-head {
|
|
239
265
|
font-size: .78rem; letter-spacing: 1.4px; text-transform: uppercase;
|
|
240
266
|
color: var(--dim); margin: 30px 0 12px; font-weight: 600;
|
|
241
267
|
}
|
|
242
|
-
.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
.
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
.
|
|
251
|
-
.
|
|
252
|
-
.
|
|
253
|
-
.
|
|
254
|
-
.
|
|
268
|
+
.flow-wrap { display: grid; gap: 20px; }
|
|
269
|
+
.flow-block { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px 18px; }
|
|
270
|
+
.flow-block h4 { margin: 0 0 10px; color: var(--text); font-size: .82rem; font-weight: 500; letter-spacing: .4px; }
|
|
271
|
+
.flow-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
|
|
272
|
+
.flow-card {
|
|
273
|
+
background: var(--surface-2); border: 1px solid var(--line-strong); border-radius: 10px;
|
|
274
|
+
padding: 10px 12px; cursor: pointer; transition: background .12s ease, border-color .12s ease;
|
|
275
|
+
}
|
|
276
|
+
.flow-card:hover { background: var(--surface); border-color: var(--accent); }
|
|
277
|
+
.flow-card-head { display: flex; align-items: center; gap: 10px; margin-bottom: 5px; }
|
|
278
|
+
.flow-card-id { font-family: var(--mono); color: var(--accent); font-weight: 700; font-size: .78rem; }
|
|
279
|
+
.flow-card-title { color: var(--text); font-size: .92rem; }
|
|
280
|
+
.flow-card-blockers { margin-top: 7px; font-size: .8rem; color: var(--dim); }
|
|
281
|
+
.flow-card-blockers .dep-link { margin-left: 4px; }
|
|
282
|
+
.flow-empty { color: var(--dim); font-size: .85rem; margin: 0; }
|
|
255
283
|
|
|
256
284
|
@media (max-width:900px) {
|
|
257
285
|
.layout { grid-template-columns: 1fr; }
|
|
@@ -293,13 +321,17 @@
|
|
|
293
321
|
<section id="sec-01">
|
|
294
322
|
<div class="sec-head"><span class="sec-num">01</span><h2>Board & Quick Actions</h2><span class="tagline">commands · board mirror only</span></div>
|
|
295
323
|
<div id="quickactions" class="mount">
|
|
296
|
-
<div class="cmd-row">
|
|
297
|
-
<
|
|
298
|
-
<
|
|
299
|
-
<
|
|
324
|
+
<div class="cmd-row" id="cmd-buttons">
|
|
325
|
+
<button type="button" class="cmd-btn" data-cmd="browser">backlog browser</button>
|
|
326
|
+
<button type="button" class="cmd-btn" data-cmd="board">backlog board</button>
|
|
327
|
+
<button type="button" class="cmd-btn" data-copy="sbl dashboard --serve">sbl dashboard --serve</button>
|
|
300
328
|
</div>
|
|
301
329
|
<p class="hint">The board mirrors Backlog.md — it never writes execution state.</p>
|
|
302
330
|
</div>
|
|
331
|
+
<div id="drafts" class="mount">
|
|
332
|
+
<h3 class="sub-head">Drafts</h3>
|
|
333
|
+
<ul id="drafts-list" class="drafts-list"></ul>
|
|
334
|
+
</div>
|
|
303
335
|
</section>
|
|
304
336
|
|
|
305
337
|
<section id="sec-02">
|
|
@@ -313,7 +345,7 @@
|
|
|
313
345
|
</section>
|
|
314
346
|
|
|
315
347
|
<section id="sec-04">
|
|
316
|
-
<div class="sec-head"><span class="sec-num">04</span><h2>Tasks</h2><span class="tagline">sort · filter ·
|
|
348
|
+
<div class="sec-head"><span class="sec-num">04</span><h2>Tasks</h2><span class="tagline">sort · filter · click row for details</span></div>
|
|
317
349
|
<div id="tasks" class="mount">
|
|
318
350
|
<div class="toolbar">
|
|
319
351
|
<input id="taskfilter" type="search" placeholder="Filter tasks…" aria-label="Filter tasks">
|
|
@@ -340,7 +372,7 @@
|
|
|
340
372
|
<section id="sec-05">
|
|
341
373
|
<div class="sec-head"><span class="sec-num">05</span><h2>Feature Cycle</h2><span class="tagline">idea → merge · every <span class="term" data-term="Review Gate">Review Gate</span> included</span></div>
|
|
342
374
|
<div id="stepper" class="mount"></div>
|
|
343
|
-
<h3 class="sub-head">
|
|
375
|
+
<h3 class="sub-head">Flow</h3>
|
|
344
376
|
<div id="depgraph" class="mount"></div>
|
|
345
377
|
</section>
|
|
346
378
|
|
|
@@ -363,8 +395,7 @@
|
|
|
363
395
|
</main>
|
|
364
396
|
</div>
|
|
365
397
|
|
|
366
|
-
<
|
|
367
|
-
<aside id="sbl-detail" hidden aria-label="Task details"></aside>
|
|
398
|
+
<dialog id="task-dialog" aria-label="Task details"></dialog>
|
|
368
399
|
<div id="sbl-tip" role="tooltip" hidden></div>
|
|
369
400
|
|
|
370
401
|
<script type="application/json" id="sbl-data">__SBL_DATA_JSON__</script>
|
|
@@ -425,37 +456,12 @@
|
|
|
425
456
|
}
|
|
426
457
|
rows.forEach(function (task) {
|
|
427
458
|
var tr = el('tr', 'task-row');
|
|
459
|
+
tr.setAttribute('data-task', task.id);
|
|
428
460
|
KEYS.forEach(function (k) {
|
|
429
461
|
tr.appendChild(el('td', 'cell-' + k, field(task, k)));
|
|
430
462
|
});
|
|
431
|
-
|
|
432
|
-
var td = el('td');
|
|
433
|
-
td.colSpan = KEYS.length;
|
|
434
|
-
if (task.description) td.appendChild(el('p', 'detail-desc', task.description));
|
|
435
|
-
if (task.acs.length > 0) {
|
|
436
|
-
var list = el('ul', 'ac-list');
|
|
437
|
-
task.acs.forEach(function (ac) {
|
|
438
|
-
var li = el('li', ac.checked ? 'ac done' : 'ac');
|
|
439
|
-
var box = document.createElement('input');
|
|
440
|
-
box.type = 'checkbox';
|
|
441
|
-
box.checked = ac.checked;
|
|
442
|
-
box.disabled = true;
|
|
443
|
-
li.appendChild(box);
|
|
444
|
-
li.appendChild(document.createTextNode(ac.text));
|
|
445
|
-
list.appendChild(li);
|
|
446
|
-
});
|
|
447
|
-
td.appendChild(list);
|
|
448
|
-
} else {
|
|
449
|
-
td.appendChild(el('p', 'detail-empty', 'No acceptance criteria.'));
|
|
450
|
-
}
|
|
451
|
-
detail.style.display = 'none';
|
|
452
|
-
tr.addEventListener('click', function () {
|
|
453
|
-
var opening = detail.style.display === 'none';
|
|
454
|
-
detail.style.display = opening ? '' : 'none';
|
|
455
|
-
tr.classList.toggle('expanded', opening);
|
|
456
|
-
});
|
|
463
|
+
tr.addEventListener('click', function () { openDetail(task.id); });
|
|
457
464
|
tbody.appendChild(tr);
|
|
458
|
-
tbody.appendChild(detail);
|
|
459
465
|
});
|
|
460
466
|
}
|
|
461
467
|
document.querySelectorAll('#tasks-table th').forEach(function (th) {
|
|
@@ -499,6 +505,26 @@
|
|
|
499
505
|
});
|
|
500
506
|
renderTasks();
|
|
501
507
|
|
|
508
|
+
/* ---------- Quick action buttons ---------- */
|
|
509
|
+
document.querySelectorAll('#cmd-buttons .cmd-btn').forEach(function (b) {
|
|
510
|
+
b.addEventListener('click', function () {
|
|
511
|
+
var copy = b.getAttribute('data-copy');
|
|
512
|
+
if (copy) { navigator.clipboard && navigator.clipboard.writeText(copy).catch(function(){}); return; }
|
|
513
|
+
fetch('/api/run', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ command: b.getAttribute('data-cmd') }) })
|
|
514
|
+
.catch(function () { navigator.clipboard && navigator.clipboard.writeText(b.textContent || '').catch(function(){}); });
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
/* ---------- Drafts ---------- */
|
|
519
|
+
function renderDrafts(drafts) {
|
|
520
|
+
var list = document.getElementById('drafts-list');
|
|
521
|
+
if (!list) return;
|
|
522
|
+
list.textContent = '';
|
|
523
|
+
if (!drafts || drafts.length === 0) { list.appendChild(el('li', '', 'No drafts.')); return; }
|
|
524
|
+
drafts.forEach(function (d) { list.appendChild(el('li', '', d.id + ' \u2014 ' + d.title)); });
|
|
525
|
+
}
|
|
526
|
+
renderDrafts(data.drafts);
|
|
527
|
+
|
|
502
528
|
/* ---------- Shared diagram helpers ---------- */
|
|
503
529
|
var SVGNS = 'http://www.w3.org/2000/svg';
|
|
504
530
|
var TONE_VAR = { ok: 'var(--ok)', accent: 'var(--accent)', warn: 'var(--warn)', danger: 'var(--danger)', dim: 'var(--dim)' };
|
|
@@ -750,9 +776,8 @@
|
|
|
750
776
|
document.addEventListener('focusout', tipHide);
|
|
751
777
|
}
|
|
752
778
|
|
|
753
|
-
/* ---------- Detail
|
|
754
|
-
var
|
|
755
|
-
var backdrop = document.getElementById('sbl-backdrop');
|
|
779
|
+
/* ---------- Detail dialog ---------- */
|
|
780
|
+
var dialog = document.getElementById('task-dialog');
|
|
756
781
|
var depsOut = {};
|
|
757
782
|
var depsIn = {};
|
|
758
783
|
data.deps.forEach(function (d) {
|
|
@@ -766,11 +791,13 @@
|
|
|
766
791
|
});
|
|
767
792
|
return found;
|
|
768
793
|
}
|
|
794
|
+
function isDoneStatus(status) {
|
|
795
|
+
var s = String(status).toLowerCase();
|
|
796
|
+
return s === 'done' || s === 'complete' || s === 'completed';
|
|
797
|
+
}
|
|
769
798
|
function closeDetail() {
|
|
770
|
-
if (
|
|
771
|
-
if (backdrop) backdrop.hidden = true;
|
|
799
|
+
if (dialog && dialog.open) dialog.close();
|
|
772
800
|
}
|
|
773
|
-
window.closeDetail = closeDetail;
|
|
774
801
|
function acList(task) {
|
|
775
802
|
var list = el('ul', 'ac-list');
|
|
776
803
|
task.acs.forEach(function (ac) {
|
|
@@ -792,7 +819,6 @@
|
|
|
792
819
|
ids.filter(function (id) { return findTask(id); }).forEach(function (id) {
|
|
793
820
|
var btn = el('button', 'dep-link', id);
|
|
794
821
|
btn.type = 'button';
|
|
795
|
-
btn.setAttribute('data-dep', id);
|
|
796
822
|
btn.addEventListener('click', function () { openDetail(id); });
|
|
797
823
|
row.appendChild(btn);
|
|
798
824
|
});
|
|
@@ -800,10 +826,24 @@
|
|
|
800
826
|
block.appendChild(row);
|
|
801
827
|
return block;
|
|
802
828
|
}
|
|
829
|
+
function metaRow(task) {
|
|
830
|
+
var row = el('div', 'meta-row');
|
|
831
|
+
function item(label, value) {
|
|
832
|
+
var s = el('span');
|
|
833
|
+
s.appendChild(el('span', 'meta-label', label + ': '));
|
|
834
|
+
s.appendChild(document.createTextNode(value || '—'));
|
|
835
|
+
return s;
|
|
836
|
+
}
|
|
837
|
+
row.appendChild(item('Milestone', task.milestone));
|
|
838
|
+
row.appendChild(item('Priority', task.priority));
|
|
839
|
+
row.appendChild(item('Assignee', task.assignee));
|
|
840
|
+
row.appendChild(item('Updated', task.updated));
|
|
841
|
+
return row;
|
|
842
|
+
}
|
|
803
843
|
function openDetail(id) {
|
|
804
844
|
var t = findTask(id);
|
|
805
|
-
if (!t || !
|
|
806
|
-
|
|
845
|
+
if (!t || !dialog) return;
|
|
846
|
+
var content = el('div', 'dialog-content');
|
|
807
847
|
var head = el('div', 'detail-head');
|
|
808
848
|
head.appendChild(el('span', 'detail-id', t.id));
|
|
809
849
|
var chip = el('span', 'status-chip', t.status);
|
|
@@ -814,21 +854,22 @@
|
|
|
814
854
|
closeBtn.setAttribute('aria-label', 'Close details');
|
|
815
855
|
closeBtn.addEventListener('click', closeDetail);
|
|
816
856
|
head.appendChild(closeBtn);
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
857
|
+
content.appendChild(head);
|
|
858
|
+
content.appendChild(el('h3', 'detail-title', t.title));
|
|
859
|
+
content.appendChild(el('p', 'detail-desc', t.description || 'No description.'));
|
|
860
|
+
content.appendChild(metaRow(t));
|
|
820
861
|
if (t.acs.length > 0) {
|
|
821
|
-
|
|
822
|
-
|
|
862
|
+
content.appendChild(el('h4', '', 'Acceptance criteria'));
|
|
863
|
+
content.appendChild(acList(t));
|
|
823
864
|
}
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
865
|
+
content.appendChild(depSection('Depends on', depsOut[id] || []));
|
|
866
|
+
content.appendChild(depSection('Needed by', depsIn[id] || []));
|
|
867
|
+
dialog.textContent = '';
|
|
868
|
+
dialog.appendChild(content);
|
|
869
|
+
dialog.showModal();
|
|
829
870
|
}
|
|
830
871
|
window.__sblOpenDetail = openDetail;
|
|
831
|
-
if (
|
|
872
|
+
if (dialog) dialog.addEventListener('click', function (ev) { if (ev.target === dialog) closeDetail(); });
|
|
832
873
|
|
|
833
874
|
/* ---------- Keyboard shortcuts ---------- */
|
|
834
875
|
document.addEventListener('keydown', function (ev) {
|
|
@@ -847,154 +888,79 @@
|
|
|
847
888
|
}
|
|
848
889
|
});
|
|
849
890
|
|
|
850
|
-
/* ---------- Dependency
|
|
851
|
-
function
|
|
852
|
-
var nodeSet = {};
|
|
853
|
-
nodes.forEach(function (n) { nodeSet[n] = true; });
|
|
854
|
-
var prereqs = {};
|
|
855
|
-
nodes.forEach(function (n) { prereqs[n] = []; });
|
|
856
|
-
deps.forEach(function (d) {
|
|
857
|
-
if (!nodeSet[d.from] || !nodeSet[d.to]) return; // dangling ref
|
|
858
|
-
if (prereqs[d.from].indexOf(d.to) === -1) prereqs[d.from].push(d.to);
|
|
859
|
-
});
|
|
860
|
-
var layers = {};
|
|
861
|
-
var remaining = {};
|
|
862
|
-
nodes.forEach(function (n) { remaining[n] = true; });
|
|
863
|
-
var left = nodes.length;
|
|
864
|
-
var current = 0;
|
|
865
|
-
function take(n) {
|
|
866
|
-
layers[n] = current;
|
|
867
|
-
delete remaining[n];
|
|
868
|
-
left -= 1;
|
|
869
|
-
}
|
|
870
|
-
while (left > 0) {
|
|
871
|
-
var ready = nodes.filter(function (n) {
|
|
872
|
-
if (!remaining[n]) return false;
|
|
873
|
-
return prereqs[n].every(function (p) { return !remaining[p]; });
|
|
874
|
-
});
|
|
875
|
-
current += 1;
|
|
876
|
-
if (ready.length === 0) {
|
|
877
|
-
// stalled behind a cycle: snapshot self-reachable members first, then
|
|
878
|
-
// append them as one group (mirrors cycleMembers() in layering.ts)
|
|
879
|
-
var cycleSnap = [];
|
|
880
|
-
nodes.forEach(function (start) {
|
|
881
|
-
if (!remaining[start]) return;
|
|
882
|
-
var seen = {};
|
|
883
|
-
var stack = prereqs[start].filter(function (p) { return remaining[p]; });
|
|
884
|
-
var found = false;
|
|
885
|
-
while (stack.length > 0 && !found) {
|
|
886
|
-
var cur = stack.pop();
|
|
887
|
-
if (cur === start) { found = true; break; }
|
|
888
|
-
if (seen[cur]) continue;
|
|
889
|
-
seen[cur] = true;
|
|
890
|
-
stack = stack.concat(prereqs[cur].filter(function (p) { return remaining[p]; }));
|
|
891
|
-
}
|
|
892
|
-
if (found) cycleSnap.push(start);
|
|
893
|
-
});
|
|
894
|
-
cycleSnap.forEach(take);
|
|
895
|
-
if (cycleSnap.length === 0) break; // unreachable safety net
|
|
896
|
-
continue;
|
|
897
|
-
}
|
|
898
|
-
ready.forEach(take);
|
|
899
|
-
}
|
|
900
|
-
var out = {};
|
|
901
|
-
nodes.forEach(function (n) { out[n] = layers[n] || 1; });
|
|
902
|
-
return out;
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
function hotEdges(id, on) {
|
|
906
|
-
document.querySelectorAll('.depgraph .edge').forEach(function (edge) {
|
|
907
|
-
var hit = edge.getAttribute('data-from') === id || edge.getAttribute('data-to') === id;
|
|
908
|
-
edge.classList.toggle('hot', on && hit);
|
|
909
|
-
});
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
function renderDepGraph(mount, tasks, deps) {
|
|
891
|
+
/* ---------- Dependency flow ---------- */
|
|
892
|
+
function renderFlow(mount, tasks) {
|
|
913
893
|
mount.textContent = '';
|
|
914
|
-
if (tasks.length === 0) {
|
|
915
|
-
mount.appendChild(el('p', 'hint', 'No tasks to graph.'));
|
|
916
|
-
return;
|
|
917
|
-
}
|
|
918
|
-
var ids = tasks.map(function (t) { return t.id; });
|
|
919
|
-
var layers = assignLayers(ids, deps);
|
|
920
894
|
var byId = {};
|
|
921
895
|
tasks.forEach(function (t) { byId[t.id] = t; });
|
|
922
|
-
var
|
|
923
|
-
|
|
924
|
-
var
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
cols.forEach(function (c) { if (c.length > rows) rows = c.length; });
|
|
930
|
-
var width = PAD * 2 + cols.length * CHIP_W + (cols.length - 1) * GAP_X;
|
|
931
|
-
var height = PAD * 2 + Math.max(rows, 1) * CHIP_H + (Math.max(rows, 1) - 1) * GAP_Y;
|
|
932
|
-
var wrap = el('div', 'depgraph-wrap');
|
|
933
|
-
var svg = svgEl('svg', {
|
|
934
|
-
viewBox: '0 0 ' + width + ' ' + height, width: width, height: height,
|
|
935
|
-
'class': 'depgraph', role: 'img', 'aria-label': 'Task dependency graph'
|
|
936
|
-
});
|
|
937
|
-
var defs = svgEl('defs');
|
|
938
|
-
var marker = svgEl('marker', { id: 'sbl-arrow', markerWidth: '9', markerHeight: '7', refX: '8.5', refY: '3.5', orient: 'auto' });
|
|
939
|
-
marker.appendChild(svgEl('polygon', { points: '0 0, 9 3.5, 0 7', fill: 'var(--line-strong)' }));
|
|
940
|
-
defs.appendChild(marker);
|
|
941
|
-
svg.appendChild(defs);
|
|
942
|
-
var pos = {};
|
|
943
|
-
cols.forEach(function (col, ci) {
|
|
944
|
-
col.forEach(function (t, ri) {
|
|
945
|
-
pos[t.id] = {
|
|
946
|
-
x: PAD + ci * (CHIP_W + GAP_X),
|
|
947
|
-
y: PAD + ri * (CHIP_H + GAP_Y)
|
|
948
|
-
};
|
|
949
|
-
});
|
|
950
|
-
});
|
|
951
|
-
deps.forEach(function (d) {
|
|
952
|
-
if (!pos[d.from] || !pos[d.to] || d.from === d.to) return;
|
|
953
|
-
var a = pos[d.from];
|
|
954
|
-
var b = pos[d.to];
|
|
955
|
-
var x1 = a.x + CHIP_W, y1 = a.y + CHIP_H / 2, x2 = b.x, y2 = b.y + CHIP_H / 2;
|
|
956
|
-
var mx = (x1 + x2) / 2;
|
|
957
|
-
var path = svgEl('path', {
|
|
958
|
-
'class': 'edge', 'data-from': d.from, 'data-to': d.to,
|
|
959
|
-
d: 'M ' + x1 + ' ' + y1 + ' C ' + mx + ' ' + y1 + ', ' + mx + ' ' + y2 + ', ' + x2 + ' ' + y2,
|
|
960
|
-
'marker-end': 'url(#sbl-arrow)'
|
|
896
|
+
var pending = tasks.filter(function (t) { return !isDoneStatus(t.status); });
|
|
897
|
+
var upNext = [];
|
|
898
|
+
var blocked = [];
|
|
899
|
+
pending.forEach(function (t) {
|
|
900
|
+
var blockers = (depsOut[t.id] || []).filter(function (id) {
|
|
901
|
+
var d = byId[id];
|
|
902
|
+
return d && !isDoneStatus(d.status);
|
|
961
903
|
});
|
|
962
|
-
|
|
904
|
+
if (blockers.length === 0) upNext.push({ task: t, blockers: [] });
|
|
905
|
+
else blocked.push({ task: t, blockers: blockers });
|
|
963
906
|
});
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
907
|
+
function section(titleText, items, emptyText) {
|
|
908
|
+
var wrap = el('div', 'flow-block');
|
|
909
|
+
wrap.appendChild(el('h4', '', titleText));
|
|
910
|
+
if (items.length === 0) {
|
|
911
|
+
wrap.appendChild(el('p', 'flow-empty', emptyText));
|
|
912
|
+
} else {
|
|
913
|
+
var list = el('ul', 'flow-list');
|
|
914
|
+
items.forEach(function (item) {
|
|
915
|
+
var li = el('li', 'flow-card');
|
|
916
|
+
var head = el('div', 'flow-card-head');
|
|
917
|
+
head.appendChild(el('span', 'flow-card-id', item.task.id));
|
|
918
|
+
var chip = el('span', 'status-chip', item.task.status);
|
|
919
|
+
chip.setAttribute('data-tone', toneOf(item.task.status));
|
|
920
|
+
head.appendChild(chip);
|
|
921
|
+
li.appendChild(head);
|
|
922
|
+
li.appendChild(el('div', 'flow-card-title', item.task.title));
|
|
923
|
+
if (item.blockers.length > 0) {
|
|
924
|
+
var blockRow = el('div', 'flow-card-blockers');
|
|
925
|
+
blockRow.appendChild(document.createTextNode('Blocked by: '));
|
|
926
|
+
item.blockers.forEach(function (id, i) {
|
|
927
|
+
if (i > 0) blockRow.appendChild(document.createTextNode(', '));
|
|
928
|
+
var btn = el('button', 'dep-link', id);
|
|
929
|
+
btn.type = 'button';
|
|
930
|
+
btn.addEventListener('click', function () { openDetail(id); });
|
|
931
|
+
blockRow.appendChild(btn);
|
|
932
|
+
});
|
|
933
|
+
li.appendChild(blockRow);
|
|
988
934
|
}
|
|
935
|
+
li.addEventListener('click', function (ev) {
|
|
936
|
+
if (ev.target.closest('.dep-link')) return;
|
|
937
|
+
openDetail(item.task.id);
|
|
938
|
+
});
|
|
939
|
+
list.appendChild(li);
|
|
989
940
|
});
|
|
990
|
-
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
|
|
941
|
+
wrap.appendChild(list);
|
|
942
|
+
}
|
|
943
|
+
return wrap;
|
|
944
|
+
}
|
|
945
|
+
if (upNext.length === 0 && blocked.length === 0) {
|
|
946
|
+
mount.appendChild(el('p', 'hint', 'No pending tasks — everything is done.'));
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
var wrap = el('div', 'flow-wrap');
|
|
950
|
+
wrap.appendChild(section('Up Next', upNext, 'No tasks ready to start.'));
|
|
951
|
+
wrap.appendChild(section('Blocked', blocked, 'No blocked tasks.'));
|
|
994
952
|
mount.appendChild(wrap);
|
|
995
953
|
}
|
|
996
954
|
|
|
997
|
-
|
|
955
|
+
renderFlow($('#depgraph'), data.tasks);
|
|
956
|
+
})();
|
|
957
|
+
</script>
|
|
958
|
+
<script>
|
|
959
|
+
(function () {
|
|
960
|
+
if (!window.EventSource || (location.protocol !== 'http:' && location.protocol !== 'https:')) return;
|
|
961
|
+
var es = new EventSource('/api/events');
|
|
962
|
+
es.addEventListener('reload', function () { location.reload(); });
|
|
963
|
+
es.addEventListener('error', function () {});
|
|
998
964
|
})();
|
|
999
965
|
</script>
|
|
1000
966
|
</body>
|