@seanmars/tospec 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +17 -3
- package/assets/claude-mod/tospec-dashboard/.claude-plugin/plugin.json +7 -0
- package/assets/claude-mod/tospec-dashboard/hooks/hooks.json +1 -0
- package/assets/claude-mod/tospec-dashboard/hooks/register.ts +695 -0
- package/assets/claude-mod/tospec-dashboard/tests/register.test.ts +448 -0
- package/assets/claude-mod/tospec-dashboard/types/index.d.ts +8 -0
- package/assets/dashboard/app.js +218 -1180
- package/assets/dashboard/index.html +31 -22
- package/assets/dashboard/lib/charts.js +355 -0
- package/assets/dashboard/lib/derive.js +369 -0
- package/assets/dashboard/lib/dom.js +121 -0
- package/assets/dashboard/lib/routes.js +52 -0
- package/assets/dashboard/lib/store.js +324 -0
- package/assets/dashboard/style.css +3103 -664
- package/assets/dashboard/views/alerts.js +170 -0
- package/assets/dashboard/views/change.js +232 -0
- package/assets/dashboard/views/docs.js +214 -0
- package/assets/dashboard/views/home.js +357 -0
- package/assets/dashboard/views/project.js +168 -0
- package/assets/dashboard/views/session.js +270 -0
- package/assets/dashboard/views/sidebar.js +131 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +74 -95
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/dashboard-relay.d.ts +11 -0
- package/dist/commands/dashboard-relay.d.ts.map +1 -0
- package/dist/commands/dashboard-relay.js +88 -0
- package/dist/commands/dashboard-relay.js.map +1 -0
- package/dist/commands/dashboard.d.ts +62 -102
- package/dist/commands/dashboard.d.ts.map +1 -1
- package/dist/commands/dashboard.js +375 -609
- package/dist/commands/dashboard.js.map +1 -1
- package/dist/core/dashboard-hub.d.ts +258 -0
- package/dist/core/dashboard-hub.d.ts.map +1 -0
- package/dist/core/dashboard-hub.js +643 -0
- package/dist/core/dashboard-hub.js.map +1 -0
- package/dist/core/dashboard-routes.d.ts +56 -0
- package/dist/core/dashboard-routes.d.ts.map +1 -0
- package/dist/core/dashboard-routes.js +329 -0
- package/dist/core/dashboard-routes.js.map +1 -0
- package/dist/core/init.d.ts.map +1 -1
- package/dist/core/init.js +4 -1
- package/dist/core/init.js.map +1 -1
- package/dist/core/local-server.d.ts +1 -1
- package/dist/core/local-server.js +1 -1
- package/dist/core/shared/dashboard-mod.d.ts +24 -0
- package/dist/core/shared/dashboard-mod.d.ts.map +1 -0
- package/dist/core/shared/dashboard-mod.js +44 -0
- package/dist/core/shared/dashboard-mod.js.map +1 -0
- package/dist/core/shared/index.d.ts +1 -0
- package/dist/core/shared/index.d.ts.map +1 -1
- package/dist/core/shared/index.js +1 -0
- package/dist/core/shared/index.js.map +1 -1
- package/dist/core/update.d.ts.map +1 -1
- package/dist/core/update.js +4 -1
- package/dist/core/update.js.map +1 -1
- package/package.json +3 -2
- package/assets/dashboard/chart.umd.js +0 -14
package/assets/dashboard/app.js
CHANGED
|
@@ -1,1242 +1,280 @@
|
|
|
1
|
-
// tospec dashboard
|
|
2
|
-
//
|
|
1
|
+
// tospec dashboard: one hub, every project drawn as a line on a shared
|
|
2
|
+
// stage grid. Vanilla ES modules, no build step, no external resources.
|
|
3
|
+
|
|
4
|
+
import { el, icon, paintLine } from './lib/dom.js';
|
|
5
|
+
import { store, onStoreChange, loadProjects, loadAllProjects, loadDetail, subscribe, requestRefresh, projectByKey, slotOf } from './lib/store.js';
|
|
6
|
+
import { parseRoute, legacyRoute, routes } from './lib/routes.js';
|
|
7
|
+
import { releaseCharts } from './lib/charts.js';
|
|
8
|
+
import { renderHome } from './views/home.js';
|
|
9
|
+
import { renderProject } from './views/project.js';
|
|
10
|
+
import { renderChange } from './views/change.js';
|
|
11
|
+
import { renderSpec, renderDecisions, clearRenderCache } from './views/docs.js';
|
|
12
|
+
import { renderSidebar } from './views/sidebar.js';
|
|
13
|
+
import { updateSessionPanel } from './views/session.js';
|
|
14
|
+
import { checkSessionAlerts, setupNotifyButton } from './views/alerts.js';
|
|
3
15
|
|
|
4
16
|
const view = document.getElementById('view');
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const
|
|
17
|
+
const signbar = document.getElementById('signbar');
|
|
18
|
+
const crumbs = document.getElementById('crumbs');
|
|
19
|
+
const crumbBack = document.getElementById('crumb-back');
|
|
20
|
+
const hubStatus = document.getElementById('hub-status');
|
|
8
21
|
const themeSelect = document.getElementById('theme-select');
|
|
22
|
+
const refreshBtn = document.getElementById('refresh-btn');
|
|
23
|
+
const sidebar = document.getElementById('sidebar');
|
|
24
|
+
const navToggle = document.getElementById('nav-toggle');
|
|
25
|
+
const navScrim = document.getElementById('nav-scrim');
|
|
9
26
|
|
|
10
|
-
|
|
11
|
-
let activity = null; // cached /api/activity payload (git series + health)
|
|
12
|
-
let activityRange = '30'; // '30' | '90' | '365' | 'all' — overview chart window
|
|
13
|
-
|
|
14
|
-
// --- theme (persisted choice: system | light | dark | dracula) -------------
|
|
27
|
+
// --- theme (persisted choice: system | light | dark | dracula) ------------------
|
|
15
28
|
|
|
16
29
|
const THEME_KEY = 'tospec-theme';
|
|
17
30
|
const systemDark = window.matchMedia('(prefers-color-scheme: dark)');
|
|
18
31
|
|
|
19
|
-
//
|
|
20
|
-
//
|
|
32
|
+
// Every colour, chart marks included, reads a theme variable, so a switch
|
|
33
|
+
// needs no re-render.
|
|
21
34
|
function applyTheme(choice) {
|
|
22
35
|
const resolved = choice === 'system' ? (systemDark.matches ? 'dark' : 'light') : choice;
|
|
23
36
|
document.documentElement.setAttribute('data-theme', resolved);
|
|
24
37
|
themeSelect.value = choice;
|
|
25
38
|
}
|
|
26
39
|
|
|
27
|
-
|
|
28
|
-
applyTheme(savedTheme);
|
|
29
|
-
|
|
30
|
-
// Theme switches rebuild the route: charts read CSS variables at build time,
|
|
31
|
-
// so they must be recreated to pick up the new palette (rare event, morph
|
|
32
|
-
// loss is fine). The initial applyTheme above must NOT rebuild — chart state
|
|
33
|
-
// (liveCharts, ov) is declared further down and would still be in its TDZ.
|
|
40
|
+
applyTheme(localStorage.getItem(THEME_KEY) || 'system');
|
|
34
41
|
systemDark.addEventListener('change', () => {
|
|
35
|
-
if ((localStorage.getItem(THEME_KEY) || 'system') === 'system')
|
|
36
|
-
applyTheme('system');
|
|
37
|
-
rebuildRoute();
|
|
38
|
-
}
|
|
42
|
+
if ((localStorage.getItem(THEME_KEY) || 'system') === 'system') applyTheme('system');
|
|
39
43
|
});
|
|
40
44
|
themeSelect.addEventListener('change', () => {
|
|
41
45
|
localStorage.setItem(THEME_KEY, themeSelect.value);
|
|
42
46
|
applyTheme(themeSelect.value);
|
|
43
|
-
rebuildRoute();
|
|
44
47
|
});
|
|
45
48
|
|
|
46
|
-
// ---
|
|
47
|
-
|
|
48
|
-
async function api(path, options) {
|
|
49
|
-
const res = await fetch(path, options);
|
|
50
|
-
if (!res.ok) {
|
|
51
|
-
let detail = 'HTTP ' + res.status;
|
|
52
|
-
try {
|
|
53
|
-
const body = await res.json();
|
|
54
|
-
if (body && body.error) detail = body.error;
|
|
55
|
-
} catch {
|
|
56
|
-
// no JSON body; keep the status line
|
|
57
|
-
}
|
|
58
|
-
const err = new Error(detail);
|
|
59
|
-
err.status = res.status;
|
|
60
|
-
throw err;
|
|
61
|
-
}
|
|
62
|
-
return res.json();
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Writes a task checkbox back to its markdown file. */
|
|
66
|
-
function postTask(file, line, done) {
|
|
67
|
-
return api('/api/task', {
|
|
68
|
-
method: 'POST',
|
|
69
|
-
headers: { 'content-type': 'application/json' },
|
|
70
|
-
body: JSON.stringify({ file, line, done }),
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Minimal DOM builder; all text goes in as textContent (XSS-safe). */
|
|
75
|
-
function el(tag, props = {}, children = []) {
|
|
76
|
-
const node = document.createElement(tag);
|
|
77
|
-
for (const [k, v] of Object.entries(props)) {
|
|
78
|
-
if (v == null) continue;
|
|
79
|
-
if (k === 'class') node.className = v;
|
|
80
|
-
else if (k === 'text') node.textContent = v;
|
|
81
|
-
else node.setAttribute(k, v);
|
|
82
|
-
}
|
|
83
|
-
for (const child of [].concat(children)) {
|
|
84
|
-
if (child == null) continue;
|
|
85
|
-
node.append(child.nodeType ? child : document.createTextNode(String(child)));
|
|
86
|
-
}
|
|
87
|
-
return node;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function emptyNote(text) {
|
|
91
|
-
return el('p', { class: 'empty', text });
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function relTime(iso) {
|
|
95
|
-
const then = new Date(iso).getTime();
|
|
96
|
-
if (Number.isNaN(then)) return '';
|
|
97
|
-
const mins = Math.floor((Date.now() - then) / 60000);
|
|
98
|
-
const hours = Math.floor(mins / 60);
|
|
99
|
-
const days = Math.floor(hours / 24);
|
|
100
|
-
if (days > 30) return new Date(iso).toLocaleDateString();
|
|
101
|
-
if (days > 0) return days + 'd ago';
|
|
102
|
-
if (hours > 0) return hours + 'h ago';
|
|
103
|
-
if (mins > 0) return mins + 'm ago';
|
|
104
|
-
return 'just now';
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** `20260713_123649` -> `2026-07-13 12:36`. */
|
|
108
|
-
function fmtStamp(s) {
|
|
109
|
-
const m = /^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})$/.exec(s || '');
|
|
110
|
-
return m ? `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}` : s || '';
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function typeBadge(type) {
|
|
114
|
-
const label = type === 'requirement' ? 'sdd' : type || '?';
|
|
115
|
-
return el('span', { class: 'badge badge-' + (type || 'unknown'), text: label });
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function artifactBlocks(done, total) {
|
|
119
|
-
const wrap = el('span', { class: 'blocks' });
|
|
120
|
-
for (let i = 0; i < total; i++) {
|
|
121
|
-
wrap.append(el('span', { class: 'block ' + (i < done ? 'block-done' : 'block-todo') }));
|
|
122
|
-
}
|
|
123
|
-
return wrap;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function setConnected(ok) {
|
|
127
|
-
connEl.textContent = ok ? '' : 'disconnected';
|
|
128
|
-
connEl.classList.toggle('disconnected', !ok);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// --- charts (Chart.js, themed via CSS variables) ----------------------------
|
|
132
|
-
|
|
133
|
-
const hasChart = typeof Chart !== 'undefined';
|
|
134
|
-
const liveCharts = new Set();
|
|
135
|
-
|
|
136
|
-
function cssVar(name) {
|
|
137
|
-
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function newChart(canvas, config) {
|
|
141
|
-
const chart = new Chart(canvas, config);
|
|
142
|
-
liveCharts.add(chart);
|
|
143
|
-
return chart;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function destroyCharts() {
|
|
147
|
-
for (const chart of liveCharts) chart.destroy();
|
|
148
|
-
liveCharts.clear();
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** Shared recessive axes/legend styling read from the active theme. */
|
|
152
|
-
function chartTheme() {
|
|
153
|
-
return {
|
|
154
|
-
text: cssVar('--text'),
|
|
155
|
-
muted: cssVar('--muted'),
|
|
156
|
-
grid: cssVar('--border'),
|
|
157
|
-
surface: cssVar('--surface'),
|
|
158
|
-
series1: cssVar('--chart-1'),
|
|
159
|
-
series2: cssVar('--chart-2'),
|
|
160
|
-
done: cssVar('--done'),
|
|
161
|
-
ready: cssVar('--ready'),
|
|
162
|
-
blocked: cssVar('--blocked'),
|
|
163
|
-
track: cssVar('--border'),
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** Doughnut center text (e.g. "68%"), drawn in the theme's text color. */
|
|
168
|
-
const centerTextPlugin = {
|
|
169
|
-
id: 'centerText',
|
|
170
|
-
afterDraw(chart, _args, opts) {
|
|
171
|
-
if (!opts || !opts.text) return;
|
|
172
|
-
const meta = chart.getDatasetMeta(0);
|
|
173
|
-
const arc = meta.data[0];
|
|
174
|
-
if (!arc) return;
|
|
175
|
-
const { ctx } = chart;
|
|
176
|
-
ctx.save();
|
|
177
|
-
ctx.font = '600 16px system-ui, sans-serif';
|
|
178
|
-
ctx.fillStyle = opts.color;
|
|
179
|
-
ctx.textAlign = 'center';
|
|
180
|
-
ctx.textBaseline = 'middle';
|
|
181
|
-
ctx.fillText(opts.text, arc.x, arc.y);
|
|
182
|
-
ctx.restore();
|
|
183
|
-
},
|
|
184
|
-
};
|
|
185
|
-
if (hasChart) Chart.register(centerTextPlugin);
|
|
186
|
-
|
|
187
|
-
/** 30-day activity line chart: solid tospec series + dashed repo context. */
|
|
188
|
-
function buildActivityChart(canvas, tospecLabel) {
|
|
189
|
-
const t = chartTheme();
|
|
190
|
-
return newChart(canvas, {
|
|
191
|
-
type: 'line',
|
|
192
|
-
data: {
|
|
193
|
-
labels: [],
|
|
194
|
-
datasets: [
|
|
195
|
-
{
|
|
196
|
-
label: tospecLabel,
|
|
197
|
-
data: [],
|
|
198
|
-
borderColor: t.series1,
|
|
199
|
-
backgroundColor: t.series1,
|
|
200
|
-
borderWidth: 2,
|
|
201
|
-
pointRadius: 2,
|
|
202
|
-
pointHitRadius: 10,
|
|
203
|
-
tension: 0.2,
|
|
204
|
-
},
|
|
205
|
-
{
|
|
206
|
-
label: 'all commits',
|
|
207
|
-
data: [],
|
|
208
|
-
borderColor: t.series2,
|
|
209
|
-
backgroundColor: t.series2,
|
|
210
|
-
borderWidth: 2,
|
|
211
|
-
borderDash: [5, 4], // secondary encoding: identity is never color-alone
|
|
212
|
-
pointRadius: 2,
|
|
213
|
-
pointHitRadius: 10,
|
|
214
|
-
tension: 0.2,
|
|
215
|
-
},
|
|
216
|
-
],
|
|
217
|
-
},
|
|
218
|
-
options: {
|
|
219
|
-
responsive: true,
|
|
220
|
-
maintainAspectRatio: false,
|
|
221
|
-
interaction: { mode: 'index', intersect: false },
|
|
222
|
-
plugins: {
|
|
223
|
-
legend: { labels: { color: t.muted, boxWidth: 18, boxHeight: 2 } },
|
|
224
|
-
tooltip: { intersect: false },
|
|
225
|
-
},
|
|
226
|
-
scales: {
|
|
227
|
-
x: {
|
|
228
|
-
ticks: { color: t.muted, maxTicksLimit: 8, maxRotation: 0 },
|
|
229
|
-
grid: { display: false },
|
|
230
|
-
},
|
|
231
|
-
y: {
|
|
232
|
-
beginAtZero: true,
|
|
233
|
-
ticks: { color: t.muted, precision: 0 },
|
|
234
|
-
grid: { color: t.grid },
|
|
235
|
-
},
|
|
236
|
-
},
|
|
237
|
-
},
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function updateActivityChart(chart, days) {
|
|
242
|
-
// mm-dd is ambiguous once the window spans more than a year.
|
|
243
|
-
const short = days.length <= 366;
|
|
244
|
-
chart.data.labels = days.map((d) => (short ? d.date.slice(5) : d.date));
|
|
245
|
-
chart.data.datasets[0].data = days.map((d) => d.tospec);
|
|
246
|
-
chart.data.datasets[1].data = days.map((d) => d.repo);
|
|
247
|
-
chart.update();
|
|
248
|
-
}
|
|
49
|
+
// --- routing -------------------------------------------------------------------
|
|
249
50
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const t = chartTheme();
|
|
253
|
-
return newChart(canvas, {
|
|
254
|
-
type: 'doughnut',
|
|
255
|
-
data: {
|
|
256
|
-
labels,
|
|
257
|
-
datasets: [
|
|
258
|
-
{
|
|
259
|
-
data: labels.map(() => 0),
|
|
260
|
-
backgroundColor: colors,
|
|
261
|
-
borderColor: t.surface, // 2px surface gap between segments
|
|
262
|
-
borderWidth: 2,
|
|
263
|
-
},
|
|
264
|
-
],
|
|
265
|
-
},
|
|
266
|
-
options: {
|
|
267
|
-
responsive: true,
|
|
268
|
-
maintainAspectRatio: false,
|
|
269
|
-
cutout: '70%',
|
|
270
|
-
plugins: {
|
|
271
|
-
legend: { position: 'bottom', labels: { color: t.muted, boxWidth: 10 } },
|
|
272
|
-
centerText: { text: '', color: t.text },
|
|
273
|
-
},
|
|
274
|
-
},
|
|
275
|
-
});
|
|
276
|
-
}
|
|
51
|
+
const legacy = legacyRoute(location.search, location.hash);
|
|
52
|
+
if (legacy) history.replaceState(null, '', location.pathname + legacy);
|
|
277
53
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
chart.options.plugins.centerText.text = centerText;
|
|
281
|
-
chart.update();
|
|
282
|
-
}
|
|
54
|
+
/** Views that follow live pushes; reading views keep their place instead. */
|
|
55
|
+
const LIVE_VIEWS = new Set(['home', 'project', 'change']);
|
|
283
56
|
|
|
284
|
-
|
|
57
|
+
let lastRoute = null;
|
|
58
|
+
let viewAbort = null;
|
|
59
|
+
/** A reading view that rendered before its data arrived re-renders once it lands. */
|
|
60
|
+
let waitingForData = false;
|
|
285
61
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
if (
|
|
291
|
-
|
|
62
|
+
function render({ live = false } = {}) {
|
|
63
|
+
const route = parseRoute(location.hash);
|
|
64
|
+
const id = JSON.stringify(route);
|
|
65
|
+
const navigation = id !== lastRoute;
|
|
66
|
+
if (live && !navigation && !LIVE_VIEWS.has(route.view) && !waitingForData) {
|
|
67
|
+
renderChrome(route);
|
|
292
68
|
return;
|
|
293
69
|
}
|
|
294
|
-
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if (p < 1) requestAnimationFrame(tick);
|
|
301
|
-
}
|
|
302
|
-
requestAnimationFrame(tick);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/** Briefly highlights a row after an SSE-driven change. */
|
|
306
|
-
function flash(node) {
|
|
307
|
-
node.classList.add('flash');
|
|
308
|
-
node.addEventListener('animationend', () => node.classList.remove('flash'), { once: true });
|
|
309
|
-
}
|
|
70
|
+
lastRoute = id;
|
|
71
|
+
const trains = navigation ? null : captureTrains();
|
|
72
|
+
releaseCharts();
|
|
73
|
+
if (viewAbort) viewAbort.abort();
|
|
74
|
+
viewAbort = new AbortController();
|
|
75
|
+
const ctx = { signal: viewAbort.signal };
|
|
310
76
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
77
|
+
let page;
|
|
78
|
+
if (route.view === 'project') page = renderProject(route, ctx);
|
|
79
|
+
else if (route.view === 'change') page = renderChange(route, ctx);
|
|
80
|
+
else if (route.view === 'spec') page = renderSpec(route, ctx);
|
|
81
|
+
else if (route.view === 'decisions') page = renderDecisions(route, ctx);
|
|
82
|
+
else page = renderHome(ctx);
|
|
83
|
+
waitingForData = !LIVE_VIEWS.has(route.view) && Boolean(ctx.waiting);
|
|
84
|
+
view.replaceChildren(page);
|
|
85
|
+
renderChrome(route);
|
|
314
86
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
rootPathEl.textContent = overview.root;
|
|
322
|
-
rootPathEl.setAttribute('title', overview.root);
|
|
323
|
-
if (isOverviewRoute()) renderOverview();
|
|
324
|
-
} catch (e) {
|
|
325
|
-
setConnected(false);
|
|
326
|
-
if (isOverviewRoute() && !overview) {
|
|
327
|
-
const msg = e.status
|
|
328
|
-
? `Failed to load overview: ${e.message}`
|
|
329
|
-
: 'Cannot reach the dashboard server.';
|
|
330
|
-
view.replaceChildren(el('p', { class: 'error', text: msg }));
|
|
331
|
-
}
|
|
87
|
+
if (navigation) {
|
|
88
|
+
arrivals.clear();
|
|
89
|
+
window.scrollTo(0, 0);
|
|
90
|
+
view.focus({ preventScroll: true });
|
|
91
|
+
} else if (trains) {
|
|
92
|
+
animateTrains(trains);
|
|
332
93
|
}
|
|
333
94
|
}
|
|
334
95
|
|
|
335
|
-
|
|
336
|
-
try {
|
|
337
|
-
activity = await api('/api/activity?days=' + activityRange);
|
|
338
|
-
} catch {
|
|
339
|
-
activity = null; // health strip and charts degrade below
|
|
340
|
-
}
|
|
341
|
-
if (isOverviewRoute()) renderOverview();
|
|
342
|
-
}
|
|
96
|
+
// --- signature move: a train slides to its new station -----------------------------
|
|
343
97
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
count,
|
|
354
|
-
el('span', { class: 'card-label', text: label }),
|
|
355
|
-
]);
|
|
356
|
-
return { node, count };
|
|
357
|
-
}
|
|
98
|
+
const TRAVEL_MS = 900;
|
|
99
|
+
const ARRIVAL_MS = 2600;
|
|
100
|
+
/**
|
|
101
|
+
* Trains still travelling or glowing, keyed by `<project>/<change>`. One push
|
|
102
|
+
* sets off several renders (overview, then activity, archive, details as they
|
|
103
|
+
* land), and each replaces the DOM: without this, the second render would cut
|
|
104
|
+
* the journey off a few milliseconds after it began.
|
|
105
|
+
*/
|
|
106
|
+
const arrivals = new Map();
|
|
358
107
|
|
|
359
|
-
function
|
|
360
|
-
const
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
const panel = el('div', { class: 'chart-panel' }, [
|
|
366
|
-
el('h2', { class: 'section-title', text: title }),
|
|
367
|
-
wrap,
|
|
368
|
-
note,
|
|
369
|
-
]);
|
|
370
|
-
return { panel, canvas, wrap, note };
|
|
108
|
+
function captureTrains() {
|
|
109
|
+
const seen = new Map();
|
|
110
|
+
for (const node of view.querySelectorAll('[data-train]')) {
|
|
111
|
+
seen.set(node.dataset.train, { station: node.dataset.station, rect: node.getBoundingClientRect() });
|
|
112
|
+
}
|
|
113
|
+
return seen;
|
|
371
114
|
}
|
|
372
115
|
|
|
373
|
-
// --- task progress -----------------------------------------------------------
|
|
374
|
-
|
|
375
|
-
// Names of changes whose task list is expanded. Module-level so an SSE refresh,
|
|
376
|
-
// which rebuilds these rows, doesn't collapse what the user opened.
|
|
377
|
-
const expandedTasks = new Set();
|
|
378
|
-
|
|
379
116
|
/**
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
117
|
+
* FLIP: the new render puts the train at its new station; it is drawn back at
|
|
118
|
+
* its old position and released, so it travels along the line. Under reduced
|
|
119
|
+
* motion it only marks its arrival.
|
|
383
120
|
*/
|
|
384
|
-
function
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
box.disabled = true;
|
|
401
|
-
item.querySelectorAll('.task-error').forEach((n) => n.remove());
|
|
402
|
-
try {
|
|
403
|
-
await postTask(task.file, task.line, want);
|
|
404
|
-
item.classList.toggle('task-item-done', want);
|
|
405
|
-
// The file watcher normally refreshes us; reload anyway so the counts move
|
|
406
|
-
// even where the watch is unavailable.
|
|
407
|
-
await loadOverview();
|
|
408
|
-
} catch (e) {
|
|
409
|
-
box.checked = !want;
|
|
410
|
-
item.append(el('span', { class: 'task-error', text: 'save failed: ' + e.message }));
|
|
411
|
-
} finally {
|
|
412
|
-
box.disabled = false;
|
|
121
|
+
function animateTrains(before) {
|
|
122
|
+
const now = performance.now();
|
|
123
|
+
for (const node of view.querySelectorAll('[data-train]')) {
|
|
124
|
+
const prev = before.get(node.dataset.train);
|
|
125
|
+
if (!prev || prev.station === node.dataset.station) continue;
|
|
126
|
+
const rect = node.getBoundingClientRect();
|
|
127
|
+
arrivals.set(node.dataset.train, { dx: prev.rect.left - rect.left, dy: prev.rect.top - rect.top, start: now });
|
|
128
|
+
}
|
|
129
|
+
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
130
|
+
for (const node of view.querySelectorAll('[data-train]')) {
|
|
131
|
+
const arrival = arrivals.get(node.dataset.train);
|
|
132
|
+
if (!arrival) continue;
|
|
133
|
+
const elapsed = now - arrival.start;
|
|
134
|
+
if (elapsed >= ARRIVAL_MS) {
|
|
135
|
+
arrivals.delete(node.dataset.train);
|
|
136
|
+
continue;
|
|
413
137
|
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
const body = el('div', { class: 'task-body' });
|
|
421
|
-
const locked = change.phase === 'archiving';
|
|
422
|
-
if (locked) {
|
|
423
|
-
body.append(
|
|
424
|
-
el('p', {
|
|
425
|
-
class: 'task-locked-note',
|
|
426
|
-
text: '此 change 已通過 sync, 正在歸檔中, task 暫停編輯',
|
|
427
|
-
})
|
|
138
|
+
node.style.setProperty('animation-delay', `-${Math.round(elapsed)}ms`);
|
|
139
|
+
node.classList.add('just-arrived');
|
|
140
|
+
if (reduce || elapsed >= TRAVEL_MS || typeof node.animate !== 'function') continue;
|
|
141
|
+
const journey = node.animate(
|
|
142
|
+
[{ transform: `translate(${arrival.dx}px, ${arrival.dy}px)` }, { transform: 'none' }],
|
|
143
|
+
{ duration: TRAVEL_MS, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' }
|
|
428
144
|
);
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
145
|
+
journey.currentTime = elapsed;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- chrome: breadcrumbs, hub status, title --------------------------------------
|
|
150
|
+
|
|
151
|
+
function renderChrome(route) {
|
|
152
|
+
renderSidebar(sidebar, route);
|
|
153
|
+
updateSessionPanel();
|
|
154
|
+
const project = route.key ? projectByKey(route.key) : null;
|
|
155
|
+
const items = [route.view === 'home'
|
|
156
|
+
? el('li', {}, [el('span', { 'aria-current': 'page', text: '總覽' })])
|
|
157
|
+
: el('li', {}, [el('a', { href: routes.home(), text: '總覽' })])];
|
|
158
|
+
const titleParts = ['總覽'];
|
|
159
|
+
if (route.key) {
|
|
160
|
+
const name = project ? project.name : route.key;
|
|
161
|
+
titleParts.push(name);
|
|
162
|
+
const crumb = paintLine(el('li', { class: 'crumb-project' }), slotOf(route.key));
|
|
163
|
+
const swatch = el('span', { class: 'line-swatch', 'aria-hidden': 'true' });
|
|
164
|
+
const label = el('span', { class: 'crumb-text', text: name });
|
|
165
|
+
crumb.append(route.view === 'project'
|
|
166
|
+
? el('span', { 'aria-current': 'page' }, [swatch, label])
|
|
167
|
+
: el('a', { href: routes.project(route.key) }, [swatch, label]));
|
|
168
|
+
items.push(crumb);
|
|
169
|
+
const leaf = route.view === 'change' ? route.name : route.view === 'spec' ? route.id : route.view === 'decisions' ? 'Decisions' : null;
|
|
170
|
+
if (leaf) {
|
|
171
|
+
titleParts.push(leaf);
|
|
172
|
+
items.push(el('li', {}, [el('span', { class: 'crumb-text', 'aria-current': 'page', text: leaf })]));
|
|
435
173
|
}
|
|
436
174
|
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
if (
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
el('span', { class: 'row-name', text: c.name }),
|
|
454
|
-
el('progress', { class: 'task-bar', value: String(completed), max: String(total) }),
|
|
455
|
-
el('span', { class: 'task-count', text: completed + '/' + total }),
|
|
456
|
-
c.artifactSummary.blocked > 0
|
|
457
|
-
? el('span', { class: 'badge badge-blocked-warn', text: 'blocked' })
|
|
458
|
-
: null,
|
|
459
|
-
c.phase === 'archiving'
|
|
460
|
-
? el('span', { class: 'badge badge-archiving', text: 'archiving' })
|
|
461
|
-
: null,
|
|
462
|
-
c.phase === 'ready'
|
|
463
|
-
? el('span', { class: 'badge badge-ready', text: 'ready' })
|
|
464
|
-
: null,
|
|
465
|
-
]),
|
|
466
|
-
taskListBody(c)
|
|
467
|
-
);
|
|
468
|
-
details.addEventListener('toggle', () => {
|
|
469
|
-
if (details.open) expandedTasks.add(c.name);
|
|
470
|
-
else expandedTasks.delete(c.name);
|
|
471
|
-
});
|
|
472
|
-
return details;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
function updateTaskProgress() {
|
|
476
|
-
const s = ov.taskSection;
|
|
477
|
-
s.replaceChildren(el('h2', { class: 'section-title', text: 'Task progress' }));
|
|
478
|
-
if (!overview.changes.length) {
|
|
479
|
-
s.append(emptyNote('No active changes.'));
|
|
480
|
-
return;
|
|
481
|
-
}
|
|
482
|
-
for (const c of overview.changes) s.append(taskChangeRow(c));
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
function buildOverviewSkeleton() {
|
|
486
|
-
destroyCharts();
|
|
487
|
-
ov = {
|
|
488
|
-
health: el('div', { class: 'health' }),
|
|
489
|
-
cards: {
|
|
490
|
-
changes: card('Changes'),
|
|
491
|
-
tickets: card('Tickets'),
|
|
492
|
-
specs: card('Specs'),
|
|
493
|
-
archived: card('Archived'),
|
|
494
|
-
},
|
|
495
|
-
activity: chartPanel('Activity', 180),
|
|
496
|
-
doughnut: chartPanel('Artifacts', 180),
|
|
497
|
-
taskSection: el('section', { class: 'panel', id: 'task-progress-panel' }),
|
|
498
|
-
changesSection: el('section', { class: 'panel', id: 'changes-panel' }),
|
|
499
|
-
ticketsSection: el('section', { class: 'panel', id: 'tickets-panel' }),
|
|
500
|
-
specsSection: el('section', { class: 'panel' }),
|
|
501
|
-
archiveSlot: el('div'),
|
|
502
|
-
archiveCount: -1,
|
|
503
|
-
};
|
|
504
|
-
|
|
505
|
-
const cards = el('section', { class: 'cards' }, [
|
|
506
|
-
ov.cards.changes.node,
|
|
507
|
-
ov.cards.tickets.node,
|
|
508
|
-
ov.cards.specs.node,
|
|
509
|
-
ov.cards.archived.node,
|
|
510
|
-
]);
|
|
511
|
-
const chartsGrid = el('section', { class: 'charts-grid' }, [
|
|
512
|
-
ov.activity.panel,
|
|
513
|
-
ov.doughnut.panel,
|
|
514
|
-
]);
|
|
515
|
-
|
|
516
|
-
view.replaceChildren(
|
|
517
|
-
ov.health,
|
|
518
|
-
cards,
|
|
519
|
-
ov.changesSection,
|
|
520
|
-
ov.taskSection,
|
|
521
|
-
ov.ticketsSection,
|
|
522
|
-
chartsGrid,
|
|
523
|
-
ov.specsSection,
|
|
524
|
-
ov.archiveSlot
|
|
525
|
-
);
|
|
526
|
-
|
|
527
|
-
// Window selector lives in the panel title; changing it refetches the series.
|
|
528
|
-
const range = el('select', { class: 'range-select', title: 'Activity window' }, [
|
|
529
|
-
el('option', { value: '30', text: '30 days' }),
|
|
530
|
-
el('option', { value: '90', text: '90 days' }),
|
|
531
|
-
el('option', { value: '365', text: '1 year' }),
|
|
532
|
-
el('option', { value: 'all', text: 'All history' }),
|
|
533
|
-
]);
|
|
534
|
-
range.value = activityRange;
|
|
535
|
-
range.addEventListener('change', () => {
|
|
536
|
-
activityRange = range.value;
|
|
537
|
-
loadActivity();
|
|
538
|
-
});
|
|
539
|
-
ov.activity.panel.firstChild.append(range);
|
|
540
|
-
|
|
541
|
-
if (hasChart) {
|
|
542
|
-
const t = chartTheme();
|
|
543
|
-
ov.activityChart = buildActivityChart(ov.activity.canvas, 'tospec/');
|
|
544
|
-
ov.doughnutChart = buildStatusDoughnut(
|
|
545
|
-
ov.doughnut.canvas,
|
|
546
|
-
['done', 'ready', 'blocked'],
|
|
547
|
-
[t.done, t.ready, t.blocked]
|
|
175
|
+
crumbs.replaceChildren(...items);
|
|
176
|
+
document.title = route.view === 'home' ? '總覽 · tospec' : titleParts.slice(1).reverse().concat('tospec').join(' · ');
|
|
177
|
+
|
|
178
|
+
// Narrow bars show one step up instead of a trail cut to "n…": the page
|
|
179
|
+
// title already names where you are.
|
|
180
|
+
crumbBack.hidden = route.view === 'home';
|
|
181
|
+
if (route.view === 'project') {
|
|
182
|
+
crumbBack.href = routes.home();
|
|
183
|
+
crumbBack.replaceChildren(icon('back', 14), el('span', { class: 'crumb-text', text: '總覽' }));
|
|
184
|
+
} else if (route.key) {
|
|
185
|
+
crumbBack.href = routes.project(route.key);
|
|
186
|
+
paintLine(crumbBack, slotOf(route.key));
|
|
187
|
+
crumbBack.replaceChildren(
|
|
188
|
+
icon('back', 14),
|
|
189
|
+
el('span', { class: 'line-swatch', 'aria-hidden': 'true' }),
|
|
190
|
+
el('span', { class: 'crumb-text', text: project ? project.name : route.key })
|
|
548
191
|
);
|
|
549
|
-
} else {
|
|
550
|
-
ov.activity.wrap.style.display = 'none';
|
|
551
|
-
ov.doughnut.wrap.style.display = 'none';
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
/** True when a change's last modification is older than the stale threshold. */
|
|
556
|
-
function isStale(change, staleDays) {
|
|
557
|
-
const age = Date.now() - new Date(change.lastModified).getTime();
|
|
558
|
-
return age > staleDays * 86400000;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
function healthItem(text, targetId) {
|
|
562
|
-
const item = el('a', { class: 'health-item', href: '#', text });
|
|
563
|
-
item.addEventListener('click', (e) => {
|
|
564
|
-
e.preventDefault();
|
|
565
|
-
const target = document.getElementById(targetId);
|
|
566
|
-
if (target) target.scrollIntoView({ behavior: 'smooth' });
|
|
567
|
-
});
|
|
568
|
-
return item;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
function updateHealthStrip(staleDays) {
|
|
572
|
-
const items = [];
|
|
573
|
-
const staleChanges = overview.changes.filter((c) => isStale(c, staleDays));
|
|
574
|
-
if (staleChanges.length) {
|
|
575
|
-
items.push(
|
|
576
|
-
healthItem(
|
|
577
|
-
`${staleChanges.length} change(s) inactive > ${staleDays} days`,
|
|
578
|
-
'changes-panel'
|
|
579
|
-
)
|
|
580
|
-
);
|
|
581
|
-
}
|
|
582
|
-
const blocked = overview.changes.reduce((n, c) => n + c.artifactSummary.blocked, 0);
|
|
583
|
-
if (blocked) {
|
|
584
|
-
items.push(healthItem(`${blocked} blocked artifact(s)`, 'changes-panel'));
|
|
585
|
-
}
|
|
586
|
-
const parseErrors = overview.tickets.filter((t) => t.parseError).length;
|
|
587
|
-
if (parseErrors) {
|
|
588
|
-
items.push(healthItem(`${parseErrors} ticket parse error(s)`, 'tickets-panel'));
|
|
589
|
-
}
|
|
590
|
-
const unreadable = overview.changes.filter((c) => c.unreadable).length;
|
|
591
|
-
if (unreadable) {
|
|
592
|
-
items.push(healthItem(`${unreadable} change(s) with unreadable metadata`, 'changes-panel'));
|
|
593
|
-
}
|
|
594
|
-
if (activity && activity.available && activity.specDrift) {
|
|
595
|
-
items.push(el('span', { class: 'health-item', text: 'spec drift: code moved, tospec/ did not (7d)' }));
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
ov.health.classList.toggle('health-warn', items.length > 0);
|
|
599
|
-
ov.health.classList.toggle('health-ok', items.length === 0);
|
|
600
|
-
if (items.length === 0) {
|
|
601
|
-
ov.health.replaceChildren(el('span', { class: 'health-item', text: 'All good — no stale changes, blocked artifacts, or parse errors' }));
|
|
602
|
-
} else {
|
|
603
|
-
ov.health.replaceChildren(...items);
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
function changeRow(c, staleDays, changed) {
|
|
608
|
-
const row = el('a', { class: 'row change-row', href: '#/changes/' + encodeURIComponent(c.name) });
|
|
609
|
-
row.append(el('span', { class: 'row-name', text: c.name }));
|
|
610
|
-
row.append(typeBadge(c.type));
|
|
611
|
-
if (c.phase === 'applying') {
|
|
612
|
-
row.classList.add('row-applying');
|
|
613
|
-
row.append(el('span', { class: 'badge badge-applying', text: 'applying' }));
|
|
614
|
-
} else if (c.phase === 'archiving') {
|
|
615
|
-
row.classList.add('row-archiving');
|
|
616
|
-
row.append(el('span', { class: 'badge badge-archiving', text: 'archiving' }));
|
|
617
|
-
} else if (c.phase === 'ready') {
|
|
618
|
-
row.classList.add('row-ready');
|
|
619
|
-
row.append(el('span', { class: 'badge badge-ready', text: 'ready' }));
|
|
620
|
-
}
|
|
621
|
-
if (c.unreadable) {
|
|
622
|
-
// Ahead of every other badge: the counts beside it were all computed from
|
|
623
|
-
// metadata that would not parse, so they are zeros, not measurements.
|
|
624
|
-
const badge = el('span', { class: 'badge badge-unreadable', text: 'unreadable' });
|
|
625
|
-
badge.title = c.unreadable;
|
|
626
|
-
row.append(badge);
|
|
627
|
-
}
|
|
628
|
-
if (c.artifactSummary.blocked > 0) {
|
|
629
|
-
row.append(el('span', { class: 'badge badge-blocked-warn', text: 'blocked' }));
|
|
630
|
-
}
|
|
631
|
-
if (isStale(c, staleDays)) {
|
|
632
|
-
row.append(el('span', { class: 'badge badge-stale', text: 'stale' }));
|
|
633
|
-
}
|
|
634
|
-
row.append(artifactBlocks(c.artifactSummary.done, c.artifactSummary.total));
|
|
635
|
-
row.append(el('span', { class: 'row-meta', text: `${c.artifactSummary.done}/${c.artifactSummary.total} artifacts` }));
|
|
636
|
-
row.append(el('span', { class: 'row-meta', text: `tasks ${c.taskProgress.completed}/${c.taskProgress.total}` }));
|
|
637
|
-
row.append(el('span', { class: 'row-time', text: relTime(c.lastModified) }));
|
|
638
|
-
if (changed) flash(row);
|
|
639
|
-
return row;
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
function updateChangesSection(staleDays) {
|
|
643
|
-
const s = ov.changesSection;
|
|
644
|
-
s.replaceChildren(el('h2', { class: 'section-title', text: 'Changes' }));
|
|
645
|
-
if (!overview.changes.length) {
|
|
646
|
-
s.append(emptyNote('No active changes. Create one with: tospec new change <name>'));
|
|
647
|
-
return;
|
|
648
|
-
}
|
|
649
|
-
const changedNames = new Set();
|
|
650
|
-
if (prevChangeStamps) {
|
|
651
|
-
for (const c of overview.changes) {
|
|
652
|
-
const prev = prevChangeStamps.get(c.name);
|
|
653
|
-
if (prev !== undefined && prev !== c.lastModified) changedNames.add(c.name);
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
for (const c of overview.changes) {
|
|
657
|
-
s.append(changeRow(c, staleDays, changedNames.has(c.name)));
|
|
658
|
-
}
|
|
659
|
-
prevChangeStamps = new Map(overview.changes.map((c) => [c.name, c.lastModified]));
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
function updateTicketsSection() {
|
|
663
|
-
const s = ov.ticketsSection;
|
|
664
|
-
s.replaceChildren(el('h2', { class: 'section-title', text: 'Tickets' }));
|
|
665
|
-
if (!overview.tickets.length) {
|
|
666
|
-
s.append(emptyNote('No tickets.'));
|
|
667
|
-
return;
|
|
668
|
-
}
|
|
669
|
-
for (const t of overview.tickets) {
|
|
670
|
-
const row = el('div', { class: 'row ticket-row' + (t.parseError ? ' row-error' : '') });
|
|
671
|
-
row.append(el('span', { class: 'row-time mono', text: fmtStamp(t.date) }));
|
|
672
|
-
row.append(el('span', { class: 'row-name', text: t.title }));
|
|
673
|
-
if (t.type) row.append(el('span', { class: 'badge badge-' + t.type, text: t.type }));
|
|
674
|
-
if (t.change) {
|
|
675
|
-
row.append(el('a', {
|
|
676
|
-
class: 'ticket-change',
|
|
677
|
-
href: '#/changes/' + encodeURIComponent(t.change),
|
|
678
|
-
text: t.change,
|
|
679
|
-
}));
|
|
680
|
-
}
|
|
681
|
-
if (t.parseError) row.append(el('span', { class: 'parse-error', text: 'parse error' }));
|
|
682
|
-
else if (t.summary) row.append(el('span', { class: 'ticket-summary', text: t.summary }));
|
|
683
|
-
if (prevTicketFiles && !prevTicketFiles.has(t.file)) flash(row);
|
|
684
|
-
s.append(row);
|
|
685
|
-
}
|
|
686
|
-
prevTicketFiles = new Set(overview.tickets.map((t) => t.file));
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
function updateSpecsSection() {
|
|
690
|
-
const s = ov.specsSection;
|
|
691
|
-
s.replaceChildren(el('h2', { class: 'section-title', text: 'Specs' }));
|
|
692
|
-
if (!overview.specs.length) {
|
|
693
|
-
s.append(emptyNote('No specs.'));
|
|
694
|
-
return;
|
|
695
|
-
}
|
|
696
|
-
for (const sp of overview.specs) {
|
|
697
|
-
const row = el('a', { class: 'row spec-row', href: '#/specs/' + encodeURIComponent(sp.id) });
|
|
698
|
-
row.append(el('span', { class: 'row-name mono', text: sp.id }));
|
|
699
|
-
row.append(el('span', { class: 'row-meta', text: `requirements ${sp.requirementCount}` }));
|
|
700
|
-
s.append(row);
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
function renderArchive(body, data) {
|
|
705
|
-
const changes = data.changes || [];
|
|
706
|
-
const tickets = data.tickets || [];
|
|
707
|
-
body.replaceChildren();
|
|
708
|
-
if (!changes.length && !tickets.length) {
|
|
709
|
-
body.append(emptyNote('Nothing archived.'));
|
|
710
|
-
return;
|
|
711
|
-
}
|
|
712
|
-
if (changes.length) {
|
|
713
|
-
body.append(el('h4', { text: 'Changes' }));
|
|
714
|
-
for (const c of changes) {
|
|
715
|
-
body.append(el('div', { class: 'row archive-row' }, [
|
|
716
|
-
el('span', { class: 'row-name', text: c.name || c.dirName }),
|
|
717
|
-
el('span', { class: 'row-time mono', text: fmtStamp(c.stamp) }),
|
|
718
|
-
]));
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
if (tickets.length) {
|
|
722
|
-
body.append(el('h4', { text: 'Tickets' }));
|
|
723
|
-
for (const t of tickets) {
|
|
724
|
-
body.append(el('div', { class: 'row archive-row' }, [
|
|
725
|
-
el('span', { class: 'row-time mono', text: fmtStamp(t.date) }),
|
|
726
|
-
el('span', { class: 'row-name', text: t.title }),
|
|
727
|
-
]));
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
function archiveSection(count) {
|
|
733
|
-
const details = el('details', { class: 'archive' });
|
|
734
|
-
details.append(el('summary', {}, ['Archive (' + count + ')']));
|
|
735
|
-
const body = el('div', { class: 'archive-body' });
|
|
736
|
-
body.append(emptyNote('Loading...'));
|
|
737
|
-
details.append(body);
|
|
738
|
-
let loaded = false;
|
|
739
|
-
details.addEventListener('toggle', async () => {
|
|
740
|
-
if (!details.open || loaded) return;
|
|
741
|
-
loaded = true;
|
|
742
|
-
try {
|
|
743
|
-
renderArchive(body, await api('/api/archive'));
|
|
744
|
-
} catch {
|
|
745
|
-
loaded = false;
|
|
746
|
-
body.replaceChildren(emptyNote('Failed to load archive.'));
|
|
747
|
-
}
|
|
748
|
-
});
|
|
749
|
-
return details;
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
function updateOverview() {
|
|
753
|
-
const staleDays = (activity && activity.staleDays) || 14;
|
|
754
|
-
|
|
755
|
-
updateHealthStrip(staleDays);
|
|
756
|
-
|
|
757
|
-
countUp(ov.cards.changes.count, overview.counts.changes);
|
|
758
|
-
countUp(ov.cards.tickets.count, overview.counts.tickets);
|
|
759
|
-
countUp(ov.cards.specs.count, overview.counts.specs);
|
|
760
|
-
countUp(ov.cards.archived.count, overview.counts.archived);
|
|
761
|
-
|
|
762
|
-
// Activity chart (hidden with a note when git is unavailable — ADR D6).
|
|
763
|
-
if (hasChart) {
|
|
764
|
-
if (activity && activity.available) {
|
|
765
|
-
ov.activity.wrap.style.display = '';
|
|
766
|
-
ov.activity.note.style.display = 'none';
|
|
767
|
-
updateActivityChart(ov.activityChart, activity.days);
|
|
768
|
-
} else {
|
|
769
|
-
ov.activity.wrap.style.display = 'none';
|
|
770
|
-
ov.activity.note.textContent = activity
|
|
771
|
-
? `No git activity (${activity.reason || 'unavailable'}).`
|
|
772
|
-
: 'Loading activity...';
|
|
773
|
-
ov.activity.note.style.display = '';
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
const done = overview.changes.reduce((n, c) => n + c.artifactSummary.done, 0);
|
|
777
|
-
const blocked = overview.changes.reduce((n, c) => n + c.artifactSummary.blocked, 0);
|
|
778
|
-
const total = overview.changes.reduce((n, c) => n + c.artifactSummary.total, 0);
|
|
779
|
-
const ready = total - done - blocked;
|
|
780
|
-
if (total > 0) {
|
|
781
|
-
ov.doughnut.wrap.style.display = '';
|
|
782
|
-
ov.doughnut.note.style.display = 'none';
|
|
783
|
-
updateStatusDoughnut(
|
|
784
|
-
ov.doughnutChart,
|
|
785
|
-
[done, ready, blocked],
|
|
786
|
-
Math.round((done / total) * 100) + '%'
|
|
787
|
-
);
|
|
788
|
-
} else {
|
|
789
|
-
ov.doughnut.wrap.style.display = 'none';
|
|
790
|
-
ov.doughnut.note.textContent = 'No artifacts.';
|
|
791
|
-
ov.doughnut.note.style.display = '';
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
updateTaskProgress();
|
|
797
|
-
updateChangesSection(staleDays);
|
|
798
|
-
updateTicketsSection();
|
|
799
|
-
updateSpecsSection();
|
|
800
|
-
|
|
801
|
-
// The archive accordion lazy-loads on expand; rebuild only when the count
|
|
802
|
-
// changes so an open accordion isn't collapsed by every SSE refresh.
|
|
803
|
-
if (ov.archiveCount !== overview.counts.archived) {
|
|
804
|
-
ov.archiveCount = overview.counts.archived;
|
|
805
|
-
ov.archiveSlot.replaceChildren(archiveSection(overview.counts.archived));
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
function renderOverview() {
|
|
810
|
-
if (!overview) {
|
|
811
|
-
view.replaceChildren(emptyNote('Loading...'));
|
|
812
|
-
return;
|
|
813
|
-
}
|
|
814
|
-
if (!ov || !view.contains(ov.health)) buildOverviewSkeleton();
|
|
815
|
-
updateOverview();
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
// --- detail views ----------------------------------------------------------
|
|
819
|
-
|
|
820
|
-
function leaveOverview() {
|
|
821
|
-
ov = null;
|
|
822
|
-
destroyCharts();
|
|
823
|
-
prevChangeStamps = null;
|
|
824
|
-
prevTicketFiles = null;
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
function backLink() {
|
|
828
|
-
return el('a', { class: 'back', href: '#/', text: '← back' });
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
function metaItem(label, value) {
|
|
832
|
-
return el('span', { class: 'meta-item' }, [
|
|
833
|
-
el('span', { class: 'meta-label', text: label }),
|
|
834
|
-
el('span', { class: 'meta-value', text: value }),
|
|
835
|
-
]);
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
/** File accordion: renders `/api/render` HTML lazily on first expand. */
|
|
839
|
-
function fileAccordion(file) {
|
|
840
|
-
const details = el('details', { class: 'file' });
|
|
841
|
-
details.append(el('summary', {}, [
|
|
842
|
-
el('span', { class: 'file-path mono', text: file.path }),
|
|
843
|
-
file.artifactId ? el('span', { class: 'badge badge-artifact', text: file.artifactId }) : null,
|
|
844
|
-
]));
|
|
845
|
-
const body = el('div', { class: 'rendered' });
|
|
846
|
-
details.append(body);
|
|
847
|
-
let loaded = false;
|
|
848
|
-
details.addEventListener('toggle', async () => {
|
|
849
|
-
if (!details.open || loaded) return;
|
|
850
|
-
loaded = true;
|
|
851
|
-
body.textContent = 'Loading...';
|
|
852
|
-
try {
|
|
853
|
-
const data = await api('/api/render?file=' + encodeURIComponent(file.path));
|
|
854
|
-
body.innerHTML = data.html; // sanitized by core/markdown-render.ts - raw HTML is escaped there, not here
|
|
855
|
-
} catch (e) {
|
|
856
|
-
loaded = false;
|
|
857
|
-
body.textContent = 'Failed to render (' + (e.status || 'error') + ').';
|
|
858
|
-
}
|
|
859
|
-
});
|
|
860
|
-
return details;
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
/** Detail-page chart block: task ring + artifact stacked bar + change activity. */
|
|
864
|
-
function detailCharts(detail, name) {
|
|
865
|
-
if (!hasChart) return null;
|
|
866
|
-
const t = chartTheme();
|
|
867
|
-
const grid = el('section', { class: 'charts-grid charts-grid-3' });
|
|
868
|
-
|
|
869
|
-
// Task completion ring.
|
|
870
|
-
const task = chartPanel('Tasks', 150);
|
|
871
|
-
grid.append(task.panel);
|
|
872
|
-
const taskChart = buildStatusDoughnut(task.canvas, ['completed', 'remaining'], [t.series1, t.track]);
|
|
873
|
-
const { completed, total } = detail.taskProgress;
|
|
874
|
-
if (total > 0) {
|
|
875
|
-
updateStatusDoughnut(taskChart, [completed, total - completed], Math.round((completed / total) * 100) + '%');
|
|
876
|
-
} else {
|
|
877
|
-
task.wrap.style.display = 'none';
|
|
878
|
-
task.note.textContent = 'No tasks.';
|
|
879
|
-
task.note.style.display = '';
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
// Artifact status stacked bar (single row, status palette).
|
|
883
|
-
const art = chartPanel('Artifacts', 150);
|
|
884
|
-
grid.append(art.panel);
|
|
885
|
-
const counts = { done: 0, ready: 0, blocked: 0 };
|
|
886
|
-
for (const a of detail.artifacts) counts[a.status] = (counts[a.status] || 0) + 1;
|
|
887
|
-
if (detail.artifacts.length > 0) {
|
|
888
|
-
newChart(art.canvas, {
|
|
889
|
-
type: 'bar',
|
|
890
|
-
data: {
|
|
891
|
-
labels: [''],
|
|
892
|
-
datasets: [
|
|
893
|
-
{ label: 'done', data: [counts.done], backgroundColor: t.done, borderRadius: 4, barThickness: 18 },
|
|
894
|
-
{ label: 'ready', data: [counts.ready], backgroundColor: t.ready, borderRadius: 4, barThickness: 18 },
|
|
895
|
-
{ label: 'blocked', data: [counts.blocked], backgroundColor: t.blocked, borderRadius: 4, barThickness: 18 },
|
|
896
|
-
],
|
|
897
|
-
},
|
|
898
|
-
options: {
|
|
899
|
-
indexAxis: 'y',
|
|
900
|
-
responsive: true,
|
|
901
|
-
maintainAspectRatio: false,
|
|
902
|
-
plugins: { legend: { position: 'bottom', labels: { color: t.muted, boxWidth: 10 } } },
|
|
903
|
-
scales: {
|
|
904
|
-
x: { stacked: true, ticks: { color: t.muted, precision: 0 }, grid: { display: false } },
|
|
905
|
-
y: { stacked: true, ticks: { display: false }, grid: { display: false } },
|
|
906
|
-
},
|
|
907
|
-
},
|
|
908
|
-
});
|
|
909
|
-
} else {
|
|
910
|
-
art.wrap.style.display = 'none';
|
|
911
|
-
art.note.textContent = 'No artifacts.';
|
|
912
|
-
art.note.style.display = '';
|
|
913
192
|
}
|
|
914
193
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
grid.append(act.panel);
|
|
918
|
-
act.wrap.style.display = 'none';
|
|
919
|
-
act.note.textContent = 'Loading activity...';
|
|
920
|
-
act.note.style.display = '';
|
|
921
|
-
api('/api/activity?change=' + encodeURIComponent(name))
|
|
922
|
-
.then((data) => {
|
|
923
|
-
if (!view.contains(act.panel)) return; // user navigated away
|
|
924
|
-
if (data.available) {
|
|
925
|
-
act.note.style.display = 'none';
|
|
926
|
-
act.wrap.style.display = '';
|
|
927
|
-
updateActivityChart(buildActivityChart(act.canvas, 'this change'), data.days);
|
|
928
|
-
} else {
|
|
929
|
-
act.note.textContent = `No git activity (${data.reason || 'unavailable'}).`;
|
|
930
|
-
}
|
|
931
|
-
})
|
|
932
|
-
.catch(() => {
|
|
933
|
-
act.note.textContent = 'Failed to load activity.';
|
|
934
|
-
});
|
|
194
|
+
if (route.key) paintLine(signbar, slotOf(route.key));
|
|
195
|
+
signbar.classList.toggle('has-line', Boolean(route.key));
|
|
935
196
|
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
const msg = e.status === 404 ? `Change "${name}" not found.` : 'Failed to load change.';
|
|
947
|
-
view.replaceChildren(backLink(), emptyNote(msg));
|
|
948
|
-
return;
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
const linkedDecisions = detail.metadata.decisions || [];
|
|
952
|
-
const meta = el('div', { class: 'meta' }, [
|
|
953
|
-
metaItem('schema', detail.metadata.schema || '—'),
|
|
954
|
-
detail.metadata.type ? metaItem('type', detail.metadata.type) : null,
|
|
955
|
-
detail.metadata.created ? metaItem('created', detail.metadata.created) : null,
|
|
956
|
-
detail.metadata.goal ? metaItem('goal', detail.metadata.goal) : null,
|
|
957
|
-
// Linked ADRs (metadata `decisions`) — each links into the decisions view.
|
|
958
|
-
linkedDecisions.length
|
|
959
|
-
? el('span', { class: 'meta-item' }, [
|
|
960
|
-
el('span', { class: 'meta-label', text: 'decisions' }),
|
|
961
|
-
el('span', { class: 'meta-value' }, linkedDecisions.map((f, i) => el('span', {}, [
|
|
962
|
-
i > 0 ? ', ' : null,
|
|
963
|
-
el('a', { href: '#/decisions/' + encodeURIComponent(f), text: f }),
|
|
964
|
-
]))),
|
|
965
|
-
])
|
|
966
|
-
: null,
|
|
967
|
-
]);
|
|
968
|
-
|
|
969
|
-
const artifacts = el('section', { class: 'panel' });
|
|
970
|
-
artifacts.append(el('h3', { text: 'Artifacts' }));
|
|
971
|
-
for (const a of detail.artifacts) {
|
|
972
|
-
const row = el('div', { class: 'artifact-row' }, [
|
|
973
|
-
el('span', { class: 'dot dot-' + a.status }),
|
|
974
|
-
el('span', { class: 'artifact-id mono', text: a.id }),
|
|
975
|
-
el('span', { class: 'artifact-status', text: a.status }),
|
|
976
|
-
]);
|
|
977
|
-
if (a.status === 'blocked' && a.missingDeps && a.missingDeps.length) {
|
|
978
|
-
row.append(el('span', { class: 'blocked-by', text: 'blocked by: ' + a.missingDeps.join(', ') }));
|
|
979
|
-
}
|
|
980
|
-
artifacts.append(row);
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
const files = el('section', { class: 'panel' });
|
|
984
|
-
files.append(el('h3', { text: 'Files' }));
|
|
985
|
-
if (!detail.files.length) files.append(emptyNote('No files yet.'));
|
|
986
|
-
for (const f of detail.files) files.append(fileAccordion(f));
|
|
987
|
-
|
|
988
|
-
const nodes = [
|
|
989
|
-
backLink(),
|
|
990
|
-
el('h2', { text: detail.name }),
|
|
991
|
-
meta,
|
|
992
|
-
el('p', { class: 'task-progress', text: `Tasks ${detail.taskProgress.completed}/${detail.taskProgress.total}` }),
|
|
993
|
-
detailCharts(detail, name),
|
|
994
|
-
artifacts,
|
|
995
|
-
files,
|
|
996
|
-
].filter(Boolean);
|
|
997
|
-
view.replaceChildren(...nodes);
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
async function renderSpecDetail(id) {
|
|
1001
|
-
leaveOverview();
|
|
1002
|
-
view.replaceChildren(backLink(), el('h2', { text: id }), emptyNote('Loading...'));
|
|
1003
|
-
const nodes = [backLink(), el('h2', { text: id })];
|
|
1004
|
-
|
|
1005
|
-
try {
|
|
1006
|
-
const spec = await api('/api/specs/' + encodeURIComponent(id));
|
|
1007
|
-
nodes.push(el('p', { class: 'muted', text: `requirements ${spec.requirements.length}` }));
|
|
1008
|
-
} catch (e) {
|
|
1009
|
-
if (e.status === 404) {
|
|
1010
|
-
view.replaceChildren(backLink(), emptyNote(`Spec "${id}" not found.`));
|
|
1011
|
-
return;
|
|
1012
|
-
}
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
const body = el('div', { class: 'rendered' });
|
|
1016
|
-
body.textContent = 'Loading...';
|
|
1017
|
-
nodes.push(body);
|
|
1018
|
-
view.replaceChildren(...nodes);
|
|
1019
|
-
|
|
1020
|
-
try {
|
|
1021
|
-
const data = await api('/api/render?file=' + encodeURIComponent(`tospec/specs/${id}/spec.md`));
|
|
1022
|
-
body.innerHTML = data.html; // sanitized by core/markdown-render.ts - raw HTML is escaped there, not here
|
|
1023
|
-
} catch {
|
|
1024
|
-
body.textContent = 'Failed to render spec.';
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
// --- decisions ---------------------------------------------------------------
|
|
1029
|
-
|
|
1030
|
-
let decisions = []; // cached /api/decisions payload (refetched on every visit)
|
|
1031
|
-
let decisionsQuery = ''; // search box text, kept across re-renders of this route
|
|
1032
|
-
|
|
1033
|
-
/** Full-text match: title, topic, status, and the raw markdown body. */
|
|
1034
|
-
function decisionMatches(d, q) {
|
|
1035
|
-
if (!q) return true;
|
|
1036
|
-
return (d.title + ' ' + d.topic + ' ' + d.status + ' ' + d.content).toLowerCase().includes(q);
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
function decisionIndexRow(d, active) {
|
|
1040
|
-
const row = el('a', {
|
|
1041
|
-
class: 'decision-row' + (active ? ' decision-row-active' : ''),
|
|
1042
|
-
href: '#/decisions/' + encodeURIComponent(d.file),
|
|
1043
|
-
});
|
|
1044
|
-
row.append(el('span', { class: 'decision-title', text: d.title }));
|
|
1045
|
-
row.append(el('div', { class: 'decision-meta' }, [
|
|
1046
|
-
el('span', { class: 'mono', text: fmtStamp(d.date) }),
|
|
1047
|
-
el('span', { class: 'badge badge-' + d.status, text: d.status }),
|
|
1048
|
-
]));
|
|
1049
|
-
return row;
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
function renderDecisionIndexList(container, list, activeFile) {
|
|
1053
|
-
container.replaceChildren();
|
|
1054
|
-
if (!list.length) {
|
|
1055
|
-
container.append(emptyNote('No matching decisions.'));
|
|
1056
|
-
return;
|
|
1057
|
-
}
|
|
1058
|
-
for (const d of list) container.append(decisionIndexRow(d, d.file === activeFile));
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
/** Renders one decision's rendered markdown into the content pane. */
|
|
1062
|
-
async function renderDecisionContent(pane, file) {
|
|
1063
|
-
const d = decisions.find((x) => x.file === file);
|
|
1064
|
-
if (!d) {
|
|
1065
|
-
pane.replaceChildren(emptyNote('Decision not found.'));
|
|
1066
|
-
return;
|
|
1067
|
-
}
|
|
1068
|
-
const body = el('div', { class: 'rendered' });
|
|
1069
|
-
body.textContent = 'Loading...';
|
|
1070
|
-
pane.replaceChildren(
|
|
1071
|
-
el('h2', { text: d.title }),
|
|
1072
|
-
el('div', { class: 'meta' }, [metaItem('date', fmtStamp(d.date)), metaItem('status', d.status)]),
|
|
1073
|
-
body
|
|
1074
|
-
);
|
|
1075
|
-
try {
|
|
1076
|
-
const data = await api('/api/render?file=' + encodeURIComponent(d.path));
|
|
1077
|
-
body.innerHTML = data.html; // sanitized by core/markdown-render.ts - raw HTML is escaped there, not here
|
|
1078
|
-
enhanceDecisionQA(body, d.content); // 決策過程 → Q/A cards (client-side, graceful fallback)
|
|
1079
|
-
} catch {
|
|
1080
|
-
body.textContent = 'Failed to render decision.';
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
/** Extracts `{ q, a }` pairs from the `## 決策過程` section of raw markdown.
|
|
1085
|
-
* Canonical shape: a `**Q:**` line, then the answer in the following
|
|
1086
|
-
* paragraph(s), until the next `**Q:**` or the next `## ` heading. Old-format
|
|
1087
|
-
* or empty sections yield `[]`, so the caller leaves marked's output as-is. */
|
|
1088
|
-
function parseDecisionQA(markdown) {
|
|
1089
|
-
if (!markdown) return [];
|
|
1090
|
-
const start = markdown.search(/^##\s+決策過程\s*$/m);
|
|
1091
|
-
if (start < 0) return [];
|
|
1092
|
-
const rest = markdown.slice(start).replace(/^##\s+決策過程\s*$/m, '');
|
|
1093
|
-
const next = rest.search(/^##\s/m); // stop at the following section, if any
|
|
1094
|
-
const section = next >= 0 ? rest.slice(0, next) : rest;
|
|
1095
|
-
const pairs = [];
|
|
1096
|
-
for (const block of section.split(/^\*\*Q:\*\*/m).slice(1)) {
|
|
1097
|
-
const nl = block.indexOf('\n');
|
|
1098
|
-
const q = (nl < 0 ? block : block.slice(0, nl)).trim();
|
|
1099
|
-
const a = (nl < 0 ? '' : block.slice(nl + 1)).trim();
|
|
1100
|
-
if (q) pairs.push({ q, a });
|
|
1101
|
-
}
|
|
1102
|
-
return pairs;
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
/** Swaps the rendered 決策過程 body (always the last section) for Q/A cards, keeping its heading. */
|
|
1106
|
-
function enhanceDecisionQA(body, markdown) {
|
|
1107
|
-
const pairs = parseDecisionQA(markdown);
|
|
1108
|
-
if (!pairs.length) return;
|
|
1109
|
-
const heading = [...body.querySelectorAll('h2')].find((h) => h.textContent.trim() === '決策過程');
|
|
1110
|
-
if (!heading) return;
|
|
1111
|
-
while (heading.nextSibling) heading.nextSibling.remove(); // drop marked's raw Q&A, keep the heading
|
|
1112
|
-
heading.after(
|
|
1113
|
-
el(
|
|
1114
|
-
'section',
|
|
1115
|
-
{ class: 'qa-cards' },
|
|
1116
|
-
pairs.map((p) =>
|
|
1117
|
-
el('div', { class: 'qa-card' }, [
|
|
1118
|
-
el('div', { class: 'qa-q', text: p.q }),
|
|
1119
|
-
el('div', { class: 'qa-a', text: p.a }),
|
|
1120
|
-
])
|
|
1121
|
-
)
|
|
1122
|
-
)
|
|
197
|
+
// A live region: rewrite it only when the link state flips, or every push
|
|
198
|
+
// would announce "connected" again.
|
|
199
|
+
const state = store.hubConnected ? 'up' : 'down';
|
|
200
|
+
if (hubStatus.dataset.state === state) return;
|
|
201
|
+
hubStatus.dataset.state = state;
|
|
202
|
+
hubStatus.replaceChildren(
|
|
203
|
+
el('span', { class: 'hub-dot', 'aria-hidden': 'true' }),
|
|
204
|
+
// Always names the hub: a bare "connected" read as the project's session
|
|
205
|
+
// on an offline project's page, which says the opposite.
|
|
206
|
+
el('span', { class: 'hub-text', text: store.hubConnected ? 'hub 已連線' : 'hub 中斷' })
|
|
1123
207
|
);
|
|
208
|
+
hubStatus.classList.toggle('is-down', !store.hubConnected);
|
|
209
|
+
hubStatus.title = store.hubConnected
|
|
210
|
+
? '即時更新中: session 推送快照時畫面會跟著更新.'
|
|
211
|
+
: '與 dashboard hub 的連線中斷. 畫面停在最後收到的資料, 連線恢復後會自動更新.';
|
|
1124
212
|
}
|
|
1125
213
|
|
|
1126
|
-
|
|
1127
|
-
* Decisions tab: a floating, always-visible index (search + list) on the left
|
|
1128
|
-
* and the selected decision's rendered content on the right. Selecting an
|
|
1129
|
-
* entry navigates to `#/decisions/<file>` so it's linkable and survives
|
|
1130
|
-
* refresh; the index itself never leaves the viewport.
|
|
1131
|
-
*/
|
|
1132
|
-
async function renderDecisionsList(activeFile) {
|
|
1133
|
-
leaveOverview();
|
|
1134
|
-
view.replaceChildren(emptyNote('Loading...'));
|
|
1135
|
-
try {
|
|
1136
|
-
decisions = await api('/api/decisions');
|
|
1137
|
-
} catch {
|
|
1138
|
-
view.replaceChildren(el('p', { class: 'error', text: 'Failed to load decisions.' }));
|
|
1139
|
-
return;
|
|
1140
|
-
}
|
|
214
|
+
// --- the sidebar as a drawer (below 960px) ------------------------------------------
|
|
1141
215
|
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
const index = el('div', { class: 'decisions-index' }, [search, list]);
|
|
1150
|
-
const content = el('div', { class: 'decisions-content panel' });
|
|
1151
|
-
|
|
1152
|
-
function refreshList() {
|
|
1153
|
-
decisionsQuery = search.value;
|
|
1154
|
-
const q = decisionsQuery.trim().toLowerCase();
|
|
1155
|
-
renderDecisionIndexList(list, decisions.filter((d) => decisionMatches(d, q)), activeFile);
|
|
216
|
+
function setNavOpen(open) {
|
|
217
|
+
sidebar.classList.toggle('is-open', open);
|
|
218
|
+
navScrim.hidden = !open;
|
|
219
|
+
navToggle.setAttribute('aria-expanded', String(open));
|
|
220
|
+
if (open) {
|
|
221
|
+
const current = sidebar.querySelector('[aria-current="page"]') || sidebar.querySelector('a');
|
|
222
|
+
if (current) current.focus({ preventScroll: true });
|
|
1156
223
|
}
|
|
1157
|
-
|
|
1158
|
-
refreshList();
|
|
1159
|
-
|
|
1160
|
-
view.replaceChildren(index, content);
|
|
224
|
+
}
|
|
1161
225
|
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
226
|
+
navToggle.replaceChildren(icon('menu', 16), el('span', { class: 'sr-only', text: 'Projects' }));
|
|
227
|
+
navToggle.addEventListener('click', () => setNavOpen(!sidebar.classList.contains('is-open')));
|
|
228
|
+
navScrim.addEventListener('click', () => setNavOpen(false));
|
|
229
|
+
window.addEventListener('keydown', (event) => {
|
|
230
|
+
if (event.key === 'Escape' && sidebar.classList.contains('is-open')) {
|
|
231
|
+
setNavOpen(false);
|
|
232
|
+
navToggle.focus();
|
|
1168
233
|
}
|
|
1169
|
-
}
|
|
234
|
+
});
|
|
1170
235
|
|
|
1171
|
-
// ---
|
|
236
|
+
// --- wiring ----------------------------------------------------------------------
|
|
1172
237
|
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
238
|
+
onStoreChange(() => {
|
|
239
|
+
render({ live: true });
|
|
240
|
+
checkSessionAlerts();
|
|
241
|
+
});
|
|
242
|
+
setupNotifyButton(document.getElementById('notify-btn'));
|
|
243
|
+
window.addEventListener('hashchange', () => {
|
|
244
|
+
// A link in the drawer was followed: the page it opened is what to show.
|
|
245
|
+
setNavOpen(false);
|
|
246
|
+
render();
|
|
247
|
+
});
|
|
1180
248
|
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
249
|
+
refreshBtn.replaceChildren(icon('refresh', 16), el('span', { class: 'btn-text', text: '重新整理' }));
|
|
250
|
+
refreshBtn.addEventListener('click', async () => {
|
|
251
|
+
refreshBtn.disabled = true;
|
|
252
|
+
const route = parseRoute(location.hash);
|
|
253
|
+
// Live sessions push fresh snapshots (arriving as `change` events); the
|
|
254
|
+
// reload below re-reads what the hub already holds, offline projects included.
|
|
255
|
+
requestRefresh(route.key ? [route.key] : store.projects.map((p) => p.key));
|
|
256
|
+
await loadProjects();
|
|
257
|
+
await loadAllProjects();
|
|
258
|
+
refreshBtn.disabled = false;
|
|
259
|
+
});
|
|
1185
260
|
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
if (h.startsWith('#/changes/')) {
|
|
1191
|
-
renderChangeDetail(decodeURIComponent(h.slice('#/changes/'.length)));
|
|
1192
|
-
} else if (h.startsWith('#/specs/')) {
|
|
1193
|
-
renderSpecDetail(decodeURIComponent(h.slice('#/specs/'.length)));
|
|
1194
|
-
} else if (h.startsWith('#/decisions/')) {
|
|
1195
|
-
renderDecisionsList(decodeURIComponent(h.slice('#/decisions/'.length)));
|
|
1196
|
-
} else if (h === '#/decisions') {
|
|
1197
|
-
renderDecisionsList(null);
|
|
1198
|
-
} else {
|
|
1199
|
-
renderOverview();
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
261
|
+
// Relative times and the idle threshold move with the clock, not with pushes.
|
|
262
|
+
setInterval(() => {
|
|
263
|
+
if (LIVE_VIEWS.has(parseRoute(location.hash).view)) render({ live: true });
|
|
264
|
+
}, 60000);
|
|
1202
265
|
|
|
1203
|
-
/**
|
|
1204
|
-
function
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
destroyCharts();
|
|
1209
|
-
if (overview) renderOverview();
|
|
1210
|
-
} else {
|
|
1211
|
-
router();
|
|
1212
|
-
}
|
|
266
|
+
/** A push from `key`: rendered files go stale, and an open change page re-reads its detail. */
|
|
267
|
+
function onProjectPush(key) {
|
|
268
|
+
clearRenderCache(key);
|
|
269
|
+
const route = parseRoute(location.hash);
|
|
270
|
+
if (route.view === 'change' && route.key === key) loadDetail(key, route.name, true);
|
|
1213
271
|
}
|
|
1214
272
|
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
} else {
|
|
1221
|
-
router();
|
|
1222
|
-
}
|
|
1223
|
-
});
|
|
1224
|
-
|
|
1225
|
-
// Live updates: the server pushes a `change` event on any tospec file edit, so
|
|
1226
|
-
// the UI refreshes itself without polling or a manual reload. EventSource
|
|
1227
|
-
// reconnects on its own; we only reflect the link state.
|
|
1228
|
-
function subscribe() {
|
|
1229
|
-
const es = new EventSource('/api/events');
|
|
1230
|
-
es.addEventListener('change', () => {
|
|
1231
|
-
loadOverview(); // refreshes counts/lists/charts (morph + flash on overview)
|
|
1232
|
-
loadActivity();
|
|
1233
|
-
if (!isOverviewRoute()) router(); // refresh the open detail view too
|
|
1234
|
-
});
|
|
1235
|
-
es.onopen = () => setConnected(true);
|
|
1236
|
-
es.onerror = () => setConnected(false);
|
|
273
|
+
async function start() {
|
|
274
|
+
subscribe(onProjectPush);
|
|
275
|
+
render();
|
|
276
|
+
await loadProjects();
|
|
277
|
+
await loadAllProjects();
|
|
1237
278
|
}
|
|
1238
279
|
|
|
1239
|
-
|
|
1240
|
-
loadOverview();
|
|
1241
|
-
loadActivity();
|
|
1242
|
-
subscribe();
|
|
280
|
+
start();
|