changeledger 0.3.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.
Files changed (44) hide show
  1. package/AGENTS.md +25 -0
  2. package/LICENSE +21 -0
  3. package/README.md +160 -0
  4. package/bin/changeledger.mjs +375 -0
  5. package/package.json +72 -0
  6. package/src/atomic-write.mjs +134 -0
  7. package/src/change.mjs +102 -0
  8. package/src/check.mjs +536 -0
  9. package/src/commands/agent.mjs +256 -0
  10. package/src/commands/check.mjs +48 -0
  11. package/src/commands/graduate.mjs +105 -0
  12. package/src/commands/init.mjs +43 -0
  13. package/src/commands/new.mjs +164 -0
  14. package/src/commands/register.mjs +28 -0
  15. package/src/commands/release.mjs +138 -0
  16. package/src/commands/view.mjs +52 -0
  17. package/src/config.mjs +76 -0
  18. package/src/contract.mjs +100 -0
  19. package/src/git.mjs +73 -0
  20. package/src/lifecycle.mjs +57 -0
  21. package/src/metrics.mjs +143 -0
  22. package/src/paths.mjs +12 -0
  23. package/src/registry.mjs +55 -0
  24. package/src/release.mjs +71 -0
  25. package/src/repo.mjs +122 -0
  26. package/src/slug.mjs +12 -0
  27. package/src/spec.mjs +12 -0
  28. package/src/viewer/domain.mjs +133 -0
  29. package/src/viewer/public/api.js +25 -0
  30. package/src/viewer/public/app-state.js +87 -0
  31. package/src/viewer/public/app.js +717 -0
  32. package/src/viewer/public/index.html +64 -0
  33. package/src/viewer/public/security.js +65 -0
  34. package/src/viewer/public/state.js +31 -0
  35. package/src/viewer/public/styles.css +1062 -0
  36. package/src/viewer/public/templates.js +9 -0
  37. package/src/viewer/public/view-parts.js +162 -0
  38. package/src/viewer/public/view-renderers.js +191 -0
  39. package/src/viewer/server/router.mjs +200 -0
  40. package/src/viewer/server/security.mjs +27 -0
  41. package/src/writer.mjs +151 -0
  42. package/src/yaml.mjs +75 -0
  43. package/templates/AGENTS.md +540 -0
  44. package/templates/config.yml +55 -0
@@ -0,0 +1,717 @@
1
+ import { getGitRefs, getProjects, getRepo, postStatus, searchAllProjects } from './api.js';
2
+ import {
3
+ clearStatusFilters,
4
+ invalidateCache,
5
+ selectProject,
6
+ setOwnerFilter,
7
+ setRepo,
8
+ setSortKey,
9
+ setTextFilter,
10
+ setTypeFilter,
11
+ setView,
12
+ state,
13
+ toggleGlobalMode,
14
+ toggleShowArchived,
15
+ toggleShowDiscarded,
16
+ toggleStatusFilter,
17
+ } from './app-state.js';
18
+ import { cssIdent, initMermaid, makeMermaidExpandable, renderMermaid } from './security.js';
19
+ import { boardStatuses, isVisible, passesTombstones } from './state.js';
20
+ import { html, render as litRender, nothing } from './templates.js';
21
+ import {
22
+ card,
23
+ closeButton,
24
+ sortIndicator,
25
+ specBody,
26
+ stageBlock,
27
+ statusSummary,
28
+ tableRow,
29
+ validationPanel,
30
+ } from './view-parts.js';
31
+ import { graphSvg, metricsHtml, specsListHtml } from './view-renderers.js';
32
+
33
+ export { cssIdent, esc, makeMermaidExpandable, safeHtml } from './security.js';
34
+ export { boardStatuses, isVisible, passesTombstones } from './state.js';
35
+ export {
36
+ card,
37
+ sortIndicator,
38
+ stageBlock,
39
+ statusSummary,
40
+ statusTag,
41
+ tableRow,
42
+ taskList,
43
+ } from './view-parts.js';
44
+
45
+ const $ = (sel) => document.querySelector(sel);
46
+
47
+ initMermaid();
48
+
49
+ async function loadProjects() {
50
+ const { projects, current } = await getProjects();
51
+ state.projectsList = projects;
52
+ const sel = $('#project');
53
+ litRender(
54
+ projects.map(
55
+ (p) =>
56
+ html`<option value=${p.id} ?disabled=${!p.alive}>${p.name}${p.alive ? '' : ' (missing)'}</option>`,
57
+ ),
58
+ sel,
59
+ );
60
+ state.currentProject = current ?? projects.find((p) => p.alive)?.id ?? null;
61
+ if (state.currentProject) sel.value = state.currentProject;
62
+ sel.style.display = projects.length > 1 ? '' : 'none';
63
+ await load();
64
+ }
65
+
66
+ async function load() {
67
+ if (!state.currentProject) {
68
+ litRender(
69
+ html`<p class="empty" style="padding:20px">No projects registered. Run <code>changeledger init</code> in a repo.</p>`,
70
+ $('#board'),
71
+ );
72
+ return;
73
+ }
74
+ try {
75
+ const text = await getRepo(state.currentProject);
76
+ if (text === state.lastJson) return;
77
+ setRepo(text);
78
+ hydrateFilters();
79
+ render();
80
+ } catch (e) {
81
+ litRender(html`<p style="color:var(--bug);padding:20px">${e.message}</p>`, $('#board'));
82
+ }
83
+ }
84
+
85
+ // Rebuilt on each project load (types/statuses can differ per project).
86
+ function hydrateFilters() {
87
+ litRender(
88
+ html`<option value="all">All types</option>
89
+ ${state.repo.types.map((t) => html`<option value=${t}>${t}</option>`)}`,
90
+ $('#type-filter'),
91
+ );
92
+ $('#type-filter').value = state.filters.type;
93
+ $('#lang').textContent = state.repo.language;
94
+
95
+ const owners = [...new Set(state.repo.changes.map((c) => c.owner).filter(Boolean))].sort();
96
+ litRender(
97
+ html`<option value="all">All owners</option>
98
+ ${owners.map((o) => html`<option value=${o}>${o}</option>`)}`,
99
+ $('#owner-filter'),
100
+ );
101
+ if (state.filters.owner !== 'all' && !owners.includes(state.filters.owner)) setOwnerFilter('all');
102
+ $('#owner-filter').value = state.filters.owner;
103
+ $('#owner-filter').style.display = owners.length ? '' : 'none';
104
+
105
+ renderStatusFilter();
106
+ }
107
+
108
+ function renderStatusFilter() {
109
+ const sf = $('#status-filter');
110
+ litRender(
111
+ html`<details class="filter-menu">
112
+ <summary class="filter-trigger">
113
+ <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2 3.25h12M4.25 8h7.5M6.5 12.75h3"></path></svg>
114
+ <span data-status-summary>${statusSummary(state.filters.statuses)}</span>
115
+ <svg class="filter-chevron" viewBox="0 0 16 16" aria-hidden="true">
116
+ <path d="m4.5 6.25 3.5 3.5 3.5-3.5"></path>
117
+ </svg>
118
+ </summary>
119
+ <div class="filter-popover">
120
+ <div class="filter-heading"><span>Status</span><button type="button" data-clear-status>Clear</button></div>
121
+ <div class="filter-options">
122
+ ${boardStatuses(state.repo.statuses).map(
123
+ (status) => html`<label class="check-option">
124
+ <input type="checkbox" data-status=${status} .checked=${state.filters.statuses.has(status)} />
125
+ <span class="check-box" aria-hidden="true"></span>
126
+ <span>${status.replaceAll('-', ' ')}</span>
127
+ </label>`,
128
+ )}
129
+ </div>
130
+ <div class="filter-heading visibility-heading"><span>Visibility</span></div>
131
+ <div class="filter-options">
132
+ <label class="check-option">
133
+ <input type="checkbox" data-visibility="archived" .checked=${state.filters.showArchived} />
134
+ <span class="check-box" aria-hidden="true"></span><span>Archived</span>
135
+ </label>
136
+ <label class="check-option">
137
+ <input type="checkbox" data-visibility="discarded" .checked=${state.filters.showDiscarded} />
138
+ <span class="check-box" aria-hidden="true"></span><span>Discarded</span>
139
+ </label>
140
+ </div>
141
+ </div>
142
+ </details>`,
143
+ sf,
144
+ );
145
+ sf.querySelectorAll('[data-status]').forEach((input) => {
146
+ input.onchange = () => {
147
+ toggleStatusFilter(input.dataset.status);
148
+ sf.querySelector('[data-status-summary]').textContent = statusSummary(state.filters.statuses);
149
+ render();
150
+ };
151
+ });
152
+ sf.querySelector('[data-clear-status]').onclick = () => {
153
+ clearStatusFilters();
154
+ sf.querySelectorAll('[data-status], [data-visibility]').forEach((input) => {
155
+ input.checked = false;
156
+ });
157
+ sf.querySelector('[data-status-summary]').textContent = statusSummary(state.filters.statuses);
158
+ render();
159
+ };
160
+ sf.querySelector('[data-visibility="archived"]').onchange = () => {
161
+ toggleShowArchived();
162
+ render();
163
+ };
164
+ sf.querySelector('[data-visibility="discarded"]').onchange = () => {
165
+ toggleShowDiscarded();
166
+ render();
167
+ };
168
+ }
169
+
170
+ function visibleChanges() {
171
+ return state.repo.changes.filter((c) => isVisible(c, state.filters));
172
+ }
173
+
174
+ function render() {
175
+ if (state.currentView === 'graph') renderGraph();
176
+ else if (state.currentView === 'table') renderTable();
177
+ else if (state.currentView === 'specs') renderSpecs();
178
+ else if (state.currentView === 'metrics') renderMetrics();
179
+ else renderBoard();
180
+ }
181
+
182
+ function renderBoard() {
183
+ const changes = visibleChanges();
184
+ const board = $('#board');
185
+ litRender(
186
+ boardStatuses(state.repo.statuses, state.filters.showDiscarded).map((status) => {
187
+ const items = changes.filter((c) => c.status === status);
188
+ return html`
189
+ <div class="column" data-status=${status}>
190
+ <div class="column-head"><span>${status}</span><span class="count">${items.length}</span></div>
191
+ <div class="column-body">${items.map(card)}</div>
192
+ </div>`;
193
+ }),
194
+ board,
195
+ );
196
+ // Dragging is reserved for initial approval. Final validation uses explicit
197
+ // detail actions because rejection requires a reason.
198
+ board.querySelectorAll('.card').forEach((el) => {
199
+ el.onclick = () => openDetail(el.dataset.id);
200
+ const c = state.repo.changes.find((x) => String(x.id) === String(el.dataset.id));
201
+ if (c && c.status === 'draft') {
202
+ el.setAttribute('draggable', 'true');
203
+ el.ondragstart = (e) => {
204
+ e.dataTransfer.setData('text/plain', el.dataset.id);
205
+ e.dataTransfer.effectAllowed = 'move';
206
+ };
207
+ }
208
+ });
209
+ const approvedCol = board.querySelector('.column[data-status="approved"]');
210
+ if (approvedCol) {
211
+ approvedCol.ondragover = (e) => {
212
+ e.preventDefault();
213
+ approvedCol.classList.add('drop-target');
214
+ };
215
+ approvedCol.ondragleave = () => approvedCol.classList.remove('drop-target');
216
+ approvedCol.ondrop = (e) => {
217
+ e.preventDefault();
218
+ approvedCol.classList.remove('drop-target');
219
+ const id = e.dataTransfer.getData('text/plain');
220
+ const c = state.repo.changes.find((x) => String(x.id) === String(id));
221
+ if (c && c.status === 'draft') moveStatus(id, 'approved');
222
+ };
223
+ }
224
+ }
225
+
226
+ // Persist a human-owned lifecycle move, then refresh the board.
227
+ async function moveStatus(id, status, reason) {
228
+ try {
229
+ const res = await postStatus(state.currentProject, id, status, reason);
230
+ const out = await res.json();
231
+ if (!res.ok) {
232
+ alert(out.error || 'status change failed');
233
+ return;
234
+ }
235
+ } catch (e) {
236
+ alert(e.message);
237
+ return;
238
+ }
239
+ invalidateCache();
240
+ await load();
241
+ return true;
242
+ }
243
+
244
+ export function setValidationPending(root, pending) {
245
+ const panel = root.querySelector('.validation-actions');
246
+ if (!panel) return;
247
+ panel.classList.toggle('is-pending', pending);
248
+ panel.querySelectorAll('button, input').forEach((control) => {
249
+ control.disabled = pending;
250
+ });
251
+ }
252
+
253
+ export function showValidationError(root, message) {
254
+ const error = root.querySelector('.validation-error');
255
+ if (!error) return;
256
+ error.textContent = message;
257
+ error.hidden = !message;
258
+ }
259
+
260
+ export function resetValidationState(root) {
261
+ setValidationPending(root, false);
262
+ showValidationError(root, '');
263
+ }
264
+
265
+ export async function runValidationSubmission({ root, request, onSuccess }) {
266
+ setValidationPending(root, true);
267
+ showValidationError(root, '');
268
+ try {
269
+ const res = await request();
270
+ const out = await res.json();
271
+ if (!res.ok) {
272
+ showValidationError(root, out.error || 'Status change failed.');
273
+ setValidationPending(root, false);
274
+ return false;
275
+ }
276
+ } catch (error) {
277
+ showValidationError(root, error.message);
278
+ setValidationPending(root, false);
279
+ return false;
280
+ }
281
+ resetValidationState(root);
282
+ await onSuccess();
283
+ return true;
284
+ }
285
+
286
+ async function submitValidation(id, status, reason) {
287
+ const root = $('#detail');
288
+ await runValidationSubmission({
289
+ root,
290
+ request: () => postStatus(state.currentProject, id, status, reason),
291
+ onSuccess: async () => {
292
+ closeDetail();
293
+ invalidateCache();
294
+ await load();
295
+ },
296
+ });
297
+ }
298
+
299
+ function openDetail(id) {
300
+ const c = state.repo.changes.find((x) => String(x.id) === String(id));
301
+ if (!c) return;
302
+ const deps = (c.depends_on || []).map((d) => {
303
+ const ext = String(d).includes(':');
304
+ return ext
305
+ ? html`<span class="pill ext" data-extdep=${d} style="cursor:pointer">depends on ${d}</span>`
306
+ : html`<span class="pill" data-dep=${d} style="cursor:pointer">depends on #${d}</span>`;
307
+ });
308
+ const pipeline = c.stages.map(
309
+ (s) => html`<span class="stage-chip" data-go=${`stage-${s.key}`}>${s.heading}</span>`,
310
+ );
311
+ const stages = c.stages.map((s) => stageBlock(c, s));
312
+
313
+ litRender(
314
+ html`
315
+ ${closeButton()}
316
+ <h1>${c.title}</h1>
317
+ <div class="detail-meta">
318
+ <span class="pill">#${c.id}</span>
319
+ <span class="pill" style=${`color:var(--${cssIdent(c.type)})`}>${c.type}</span>
320
+ <span class="pill">${c.status}</span>
321
+ ${c.owner ? html`<span class="pill owner">@${c.owner}</span>` : nothing}
322
+ <span class="pill" title=${c.created || ''}>${fmtDateTime(c.created)}</span>
323
+ ${deps}
324
+ </div>
325
+ ${c.status === 'in-validation' ? validationPanel() : nothing}
326
+ <div class="pipeline">${pipeline}</div>
327
+ ${stages}
328
+ <div id="git-section"></div>`,
329
+ $('#detail'),
330
+ );
331
+
332
+ resetValidationState($('#detail'));
333
+
334
+ const overlay = $('#overlay');
335
+ overlay.classList.remove('hidden');
336
+ $('#detail').querySelector('.close').onclick = closeDetail;
337
+ const accept = $('#detail').querySelector('[data-validation="pass"]');
338
+ if (accept) accept.onclick = () => submitValidation(c.id, 'done');
339
+ const reject = $('#detail').querySelector('[data-validation="fail"]');
340
+ if (reject) {
341
+ reject.onclick = () => {
342
+ const input = $('#detail').querySelector('[data-validation-reason]');
343
+ const reason = input?.value.trim();
344
+ if (!reason) {
345
+ showValidationError($('#detail'), 'A rejection reason is required.');
346
+ input?.focus();
347
+ return;
348
+ }
349
+ submitValidation(c.id, 'in-progress', reason);
350
+ };
351
+ }
352
+ overlay.onclick = (e) => {
353
+ if (e.target === overlay) closeDetail();
354
+ };
355
+ $('#detail')
356
+ .querySelectorAll('[data-go]')
357
+ .forEach((el) => {
358
+ el.onclick = () =>
359
+ $(`#${el.dataset.go}`).scrollIntoView({ behavior: 'smooth', block: 'start' });
360
+ });
361
+ $('#detail')
362
+ .querySelectorAll('[data-dep]')
363
+ .forEach((el) => {
364
+ el.onclick = () => openDetail(el.dataset.dep);
365
+ });
366
+ $('#detail')
367
+ .querySelectorAll('[data-extdep]')
368
+ .forEach((el) => {
369
+ el.onclick = () => {
370
+ const [proj, changeId] = el.dataset.extdep.split(':');
371
+ gotoChange(proj, changeId);
372
+ };
373
+ });
374
+ renderExpandableMermaid($('#detail'));
375
+ loadGitRefs(c.id);
376
+ }
377
+
378
+ // Fetch and render the git refs (commits/branches) that reference this change.
379
+ async function loadGitRefs(id) {
380
+ let refs;
381
+ try {
382
+ refs = await getGitRefs(state.currentProject, id);
383
+ } catch {
384
+ return;
385
+ }
386
+ const sec = $('#git-section');
387
+ if (!sec) return;
388
+ if (!refs.commits.length && !refs.branches.length) {
389
+ litRender(nothing, sec);
390
+ return;
391
+ }
392
+ const commits = refs.commits.map(
393
+ (c) =>
394
+ html`<li>
395
+ <span class="mono">${c.sha.slice(0, 8)}</span> ${c.subject}
396
+ <span class="when" title=${c.date || ''}>${fmtDate(c.date)}</span>
397
+ </li>`,
398
+ );
399
+ const branches = refs.branches.map((b) => html`<span class="pill">${b}</span>`);
400
+ litRender(
401
+ html`
402
+ <div class="stage">
403
+ <h2>Git</h2>
404
+ <div class="stage-content">
405
+ ${branches.length ? html`<div class="detail-meta">${branches}</div>` : nothing}
406
+ ${refs.commits.length ? html`<ul class="git-commits">${commits}</ul>` : nothing}
407
+ </div>
408
+ </div>`,
409
+ sec,
410
+ );
411
+ }
412
+
413
+ function closeDetail() {
414
+ $('#overlay').classList.add('hidden');
415
+ }
416
+
417
+ let diagramLightbox = null;
418
+
419
+ async function renderExpandableMermaid(root) {
420
+ await renderMermaid(root);
421
+ makeMermaidExpandable(root, (node) => diagramLightbox?.open(node));
422
+ }
423
+
424
+ export function createDiagramLightbox({ overlay, canvas, closeButton }) {
425
+ let origin = null;
426
+ const close = () => {
427
+ if (overlay.classList.contains('hidden')) return false;
428
+ overlay.classList.add('hidden');
429
+ canvas.replaceChildren();
430
+ origin?.focus();
431
+ origin = null;
432
+ return true;
433
+ };
434
+ const controller = {
435
+ open(node) {
436
+ const source = node.querySelector('svg');
437
+ if (!source) return false;
438
+ origin = node;
439
+ canvas.replaceChildren(source.cloneNode(true));
440
+ overlay.classList.remove('hidden');
441
+ closeButton.focus();
442
+ return true;
443
+ },
444
+ close,
445
+ handleBackdrop(event) {
446
+ return event.target === overlay ? close() : false;
447
+ },
448
+ handleKeydown(event) {
449
+ return event.key === 'Escape' ? close() : false;
450
+ },
451
+ };
452
+ closeButton.onclick = close;
453
+ overlay.onclick = controller.handleBackdrop;
454
+ return controller;
455
+ }
456
+
457
+ // Cross-project navigation: resolve `proj` (by id or name) in the loaded project
458
+ // list, switch to it, then open the target change once its repo has loaded.
459
+ async function gotoChange(proj, changeId) {
460
+ const match = state.projectsList.find((p) => p.id === proj || p.name === proj);
461
+ if (!match?.alive) {
462
+ alert(`Project "${proj}" is not registered or its path is gone.`);
463
+ return;
464
+ }
465
+ if (match.id !== state.currentProject) {
466
+ selectProject(match.id);
467
+ $('#project').value = match.id;
468
+ await load();
469
+ }
470
+ openDetail(changeId);
471
+ }
472
+
473
+ /* Dependency graph */
474
+ function renderGraph() {
475
+ const changes = state.repo.changes.filter((c) => passesTombstones(c, state.filters));
476
+ litRender(graphSvg(changes), $('#graph'));
477
+ $('#graph')
478
+ .querySelectorAll('.node')
479
+ .forEach((el) => {
480
+ el.onclick = () => openDetail(el.dataset.id);
481
+ });
482
+ }
483
+
484
+ /* Table view */
485
+ function renderTable() {
486
+ const cols = [
487
+ { key: 'id', label: 'ID' },
488
+ { key: 'title', label: 'Title' },
489
+ { key: 'type', label: 'Type' },
490
+ { key: 'status', label: 'Status' },
491
+ { key: 'progress', label: 'Progress' },
492
+ { key: 'deps', label: 'Deps' },
493
+ ];
494
+ const rows = visibleChanges()
495
+ .slice()
496
+ .sort((a, b) => {
497
+ const va = sortVal(a, state.sortKey),
498
+ vb = sortVal(b, state.sortKey);
499
+ return va < vb ? -state.sortDir : va > vb ? state.sortDir : 0;
500
+ });
501
+
502
+ litRender(
503
+ html`
504
+ <table class="grid">
505
+ <thead>
506
+ <tr>
507
+ ${cols.map(
508
+ (c) =>
509
+ html`<th data-sort=${c.key}>
510
+ <span class="column-label">${c.label}</span>
511
+ ${state.sortKey === c.key ? sortIndicator(state.sortDir) : nothing}
512
+ </th>`,
513
+ )}
514
+ </tr>
515
+ </thead>
516
+ <tbody>${rows.map(tableRow)}</tbody>
517
+ </table>`,
518
+ $('#table'),
519
+ );
520
+
521
+ $('#table')
522
+ .querySelectorAll('th[data-sort]')
523
+ .forEach((el) => {
524
+ el.onclick = () => {
525
+ setSortKey(el.dataset.sort);
526
+ renderTable();
527
+ };
528
+ });
529
+ $('#table')
530
+ .querySelectorAll('tr[data-id]')
531
+ .forEach((el) => {
532
+ el.onclick = () => openDetail(el.dataset.id);
533
+ });
534
+ }
535
+
536
+ function sortVal(c, key) {
537
+ if (key === 'progress') return c.progress.total ? c.progress.done / c.progress.total : -1;
538
+ if (key === 'deps') return (c.depends_on || []).length;
539
+ if (key === 'id') return String(c.id);
540
+ return String(c[key] ?? '');
541
+ }
542
+
543
+ /* Specs view */
544
+ function renderSpecs() {
545
+ const q = state.filters.text.toLowerCase();
546
+ const specs = (state.repo.specs || []).filter(
547
+ (s) => !q || `${s.title} ${(s.tags || []).join(' ')} ${s.body}`.toLowerCase().includes(q),
548
+ );
549
+ litRender(specsListHtml(specs, fmtDateTime), $('#specs'));
550
+ $('#specs')
551
+ .querySelectorAll('.spec-card')
552
+ .forEach((el) => {
553
+ el.onclick = () => openSpec(specs[Number(el.dataset.i)]);
554
+ });
555
+ }
556
+
557
+ function openSpec(s) {
558
+ litRender(
559
+ html`
560
+ ${closeButton()}
561
+ <h1>${s.title}</h1>
562
+ <div class="detail-meta">
563
+ <span class="pill">spec</span>
564
+ <span class="pill" title=${s.updated || ''}>${fmtDateTime(s.updated)}</span>
565
+ ${(s.tags || []).map((t) => html`<span class="pill">${t}</span>`)}
566
+ </div>
567
+ ${specBody(s.body)}`,
568
+ $('#detail'),
569
+ );
570
+ const overlay = $('#overlay');
571
+ overlay.classList.remove('hidden');
572
+ $('#detail').querySelector('.close').onclick = closeDetail;
573
+ overlay.onclick = (e) => {
574
+ if (e.target === overlay) closeDetail();
575
+ };
576
+ renderExpandableMermaid($('#detail'));
577
+ }
578
+
579
+ const VIEWS = ['board', 'table', 'graph', 'specs', 'metrics'];
580
+
581
+ function renderMetrics() {
582
+ litRender(metricsHtml(state.repo.metrics || {}), $('#metrics'));
583
+ }
584
+
585
+ function activateView(v) {
586
+ setView(v);
587
+ $('#toggle-global').classList.remove('active');
588
+ $('#global').classList.add('hidden');
589
+ for (const name of VIEWS) {
590
+ $(`#view-${name}`).classList.toggle('active', v === name);
591
+ $(`#${name}`).classList.toggle('hidden', v !== name);
592
+ }
593
+ render();
594
+ }
595
+
596
+ // Global search: query every project server-side, render grouped results.
597
+ async function renderGlobal() {
598
+ const q = state.filters.text.trim();
599
+ const el = $('#global');
600
+ if (!q) {
601
+ litRender(
602
+ html`<p class="empty" style="padding:20px">Type to search across all projects.</p>`,
603
+ el,
604
+ );
605
+ return;
606
+ }
607
+ let groups;
608
+ try {
609
+ groups = await searchAllProjects(q);
610
+ } catch (e) {
611
+ litRender(html`<p style="color:var(--bug);padding:20px">${e.message}</p>`, el);
612
+ return;
613
+ }
614
+ if (!groups.length) {
615
+ litRender(html`<p class="empty" style="padding:20px">No matches for “${q}”.</p>`, el);
616
+ return;
617
+ }
618
+ litRender(
619
+ groups.map(
620
+ (g) => html`
621
+ <div class="search-group">
622
+ <h3>${g.project.name} <span class="count">${g.matches.length}</span></h3>
623
+ ${g.matches.map(
624
+ (m) => html`<div class="search-hit" data-proj=${g.project.id} data-id=${m.id}>
625
+ <span class="card-id">#${m.id}</span>
626
+ <span class="type-tag" style=${`--type-color: var(--${cssIdent(m.type)})`}>${m.type}</span>
627
+ <span>${m.title}</span>
628
+ <span class="pill">${m.status}</span>
629
+ </div>`,
630
+ )}
631
+ </div>`,
632
+ ),
633
+ el,
634
+ );
635
+ el.querySelectorAll('.search-hit').forEach((hit) => {
636
+ hit.onclick = () => gotoChange(hit.dataset.proj, hit.dataset.id);
637
+ });
638
+ }
639
+
640
+ function enterGlobal() {
641
+ for (const name of VIEWS) $(`#${name}`).classList.add('hidden');
642
+ $('#global').classList.remove('hidden');
643
+ renderGlobal();
644
+ }
645
+
646
+ // Render an ISO UTC timestamp in the viewer's local format. The stored value
647
+ // stays ISO UTC (source of truth); only the display is localized. Empty/invalid
648
+ // input renders as ''.
649
+ const fmtDateTime = (iso) => {
650
+ if (!iso) return '';
651
+ const d = new Date(iso);
652
+ return Number.isNaN(d.getTime()) ? '' : d.toLocaleString();
653
+ };
654
+ const fmtDate = (iso) => {
655
+ if (!iso) return '';
656
+ const d = new Date(iso);
657
+ return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString();
658
+ };
659
+
660
+ // Wire the DOM and start polling. Guarded below so importing this module (tests)
661
+ // has no side effects; only a real browser page bootstraps.
662
+ function bootstrap() {
663
+ diagramLightbox = createDiagramLightbox({
664
+ overlay: $('#diagram-overlay'),
665
+ canvas: $('#diagram-canvas'),
666
+ closeButton: $('#close-diagram'),
667
+ });
668
+ $('#search').oninput = (e) => {
669
+ setTextFilter(e.target.value);
670
+ if (state.globalMode) renderGlobal();
671
+ else render();
672
+ };
673
+ $('#toggle-global').onclick = (e) => {
674
+ const active = toggleGlobalMode();
675
+ e.target.classList.toggle('active', active);
676
+ if (active) enterGlobal();
677
+ else activateView(state.currentView);
678
+ };
679
+ $('#type-filter').onchange = (e) => {
680
+ setTypeFilter(e.target.value);
681
+ render();
682
+ };
683
+ $('#owner-filter').onchange = (e) => {
684
+ setOwnerFilter(e.target.value);
685
+ render();
686
+ };
687
+ document.addEventListener('pointerdown', (event) => {
688
+ closeStatusMenuOnOutsideClick($('#status-filter .filter-menu'), event.target);
689
+ });
690
+ $('#view-board').onclick = () => activateView('board');
691
+ $('#view-table').onclick = () => activateView('table');
692
+ $('#view-graph').onclick = () => activateView('graph');
693
+ $('#view-specs').onclick = () => activateView('specs');
694
+ $('#view-metrics').onclick = () => activateView('metrics');
695
+ $('#project').onchange = (e) => {
696
+ selectProject(e.target.value);
697
+ load();
698
+ };
699
+ document.onkeydown = (e) => {
700
+ if (e.key === 'Escape' && !diagramLightbox.handleKeydown(e)) closeDetail();
701
+ };
702
+
703
+ loadProjects();
704
+ setInterval(load, 5000);
705
+ }
706
+
707
+ export function closeStatusMenuOnOutsideClick(menu, target) {
708
+ if (!menu?.open || menu.contains(target)) return false;
709
+ menu.open = false;
710
+ return true;
711
+ }
712
+
713
+ // Only a real browser page with the app shell bootstraps; importing the module
714
+ // (for tests) must not touch the DOM or start polling.
715
+ if (typeof document !== 'undefined' && document.getElementById('search')) {
716
+ bootstrap();
717
+ }