hyper-pm-web 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js ADDED
@@ -0,0 +1,2326 @@
1
+ /* global window, document, fetch, localStorage, marked, DOMPurify, HyperPmAuditSummary */
2
+
3
+ /** @type {boolean} */
4
+ let markdownLibConfigured = false;
5
+
6
+ /**
7
+ * Enables GFM-style line breaks for issue-style markdown (once per page load).
8
+ */
9
+ function configureMarkdownLibOnce() {
10
+ if (markdownLibConfigured) return;
11
+ if (typeof marked !== "undefined" && typeof marked.use === "function") {
12
+ marked.use({
13
+ gfm: true,
14
+ breaks: true,
15
+ });
16
+ }
17
+ markdownLibConfigured = true;
18
+ }
19
+
20
+ /**
21
+ * @returns {boolean} True when CDN markdown + sanitizer globals are available.
22
+ */
23
+ function markdownLibsLoaded() {
24
+ return (
25
+ typeof marked !== "undefined" &&
26
+ typeof marked.parse === "function" &&
27
+ typeof DOMPurify !== "undefined" &&
28
+ typeof DOMPurify.sanitize === "function"
29
+ );
30
+ }
31
+
32
+ /**
33
+ * Sanitizes HTML produced from user-authored markdown before `innerHTML` use.
34
+ * @param {string} dirtyHtml
35
+ * @returns {string}
36
+ */
37
+ function sanitizeUserMarkdownHtml(dirtyHtml) {
38
+ if (
39
+ typeof DOMPurify === "undefined" ||
40
+ typeof DOMPurify.sanitize !== "function"
41
+ ) {
42
+ return "";
43
+ }
44
+ return DOMPurify.sanitize(dirtyHtml, { USE_PROFILES: { html: true } });
45
+ }
46
+
47
+ /**
48
+ * Renders inline markdown (titles, short labels). Falls back to escaped plain text.
49
+ * @param {unknown} text
50
+ * @returns {string}
51
+ */
52
+ function markdownInlineHtml(text) {
53
+ const s = String(text ?? "");
54
+ configureMarkdownLibOnce();
55
+ if (!markdownLibsLoaded()) {
56
+ return escapeHtml(s);
57
+ }
58
+ const raw =
59
+ typeof marked.parseInline === "function"
60
+ ? marked.parseInline(s, { async: false })
61
+ : marked.parse(s, { async: false });
62
+ return sanitizeUserMarkdownHtml(/** @type {string} */ (raw));
63
+ }
64
+
65
+ /**
66
+ * Flatten markdown to plain text for the window/toolbar title (no HTML).
67
+ * @param {unknown} text
68
+ * @returns {string}
69
+ */
70
+ function markdownToPlainTextForUiChrome(text) {
71
+ const s = String(text ?? "");
72
+ if (!trimU(s)) return "";
73
+ if (!markdownLibsLoaded()) return s.replace(/\s+/g, " ").trim();
74
+ const wrap = document.createElement("div");
75
+ wrap.innerHTML = markdownInlineHtml(s);
76
+ const out = wrap.textContent?.replace(/\s+/g, " ").trim();
77
+ return out && out.length > 0 ? out : s;
78
+ }
79
+
80
+ const TOKEN_KEY = "hyperPmWebBearer";
81
+
82
+ const STATUSES = ["backlog", "todo", "in_progress", "done", "cancelled"];
83
+
84
+ /** @type {{ view: string; epicId?: string; storyId?: string; ticketId?: string; storyFilterEpic?: string; ticketFilterStory?: string; epicDetailForm?: boolean; storyDetailForm?: boolean; ticketDetailForm?: boolean }} */
85
+ const state = {
86
+ view: "dashboard",
87
+ storyFilterEpic: "",
88
+ ticketFilterStory: "",
89
+ };
90
+
91
+ /**
92
+ * @param {string} s
93
+ */
94
+ function escapeHtml(s) {
95
+ return String(s)
96
+ .replace(/&/g, "&")
97
+ .replace(/</g, "&lt;")
98
+ .replace(/>/g, "&gt;")
99
+ .replace(/"/g, "&quot;");
100
+ }
101
+
102
+ /**
103
+ * @param {string} status
104
+ */
105
+ function badgeHtml(status) {
106
+ const s = String(status);
107
+ const label = s.replace(/_/g, " ");
108
+ return `<span class="status-pill" data-status="${escapeHtml(s)}">${escapeHtml(label)}</span>`;
109
+ }
110
+
111
+ /**
112
+ * @param {string} id
113
+ */
114
+ function idChip(id) {
115
+ return `<span class="id-chip">${escapeHtml(id)}</span>`;
116
+ }
117
+
118
+ /**
119
+ * Renders markdown as safe HTML for read-only multiline display (epic/story/ticket bodies).
120
+ * @param {unknown} text
121
+ * @returns {string}
122
+ */
123
+ function readBodyHtml(text) {
124
+ const s = String(text ?? "");
125
+ if (!trimU(s)) {
126
+ return '<p class="muted read-empty">No description.</p>';
127
+ }
128
+ configureMarkdownLibOnce();
129
+ if (!markdownLibsLoaded()) {
130
+ return `<div class="read-body">${escapeHtml(s).replace(/\n/g, "<br />")}</div>`;
131
+ }
132
+ const raw = marked.parse(s, { async: false });
133
+ const clean = sanitizeUserMarkdownHtml(/** @type {string} */ (raw));
134
+ return `<div class="read-body md-body">${clean}</div>`;
135
+ }
136
+
137
+ /**
138
+ * One labeled block in a read-only detail stack (`innerHtml` is trusted HTML from this file only).
139
+ * @param {string} label
140
+ * @param {string} innerHtml
141
+ * @returns {string}
142
+ */
143
+ function readRowHtml(label, innerHtml) {
144
+ return `<div class="read-row"><div class="read-label">${escapeHtml(label)}</div><div class="read-value">${innerHtml}</div></div>`;
145
+ }
146
+
147
+ /**
148
+ * @param {string} v
149
+ * @returns {string | undefined}
150
+ */
151
+ function trimU(v) {
152
+ const t = String(v).trim();
153
+ return t.length > 0 ? t : undefined;
154
+ }
155
+
156
+ /**
157
+ * @typedef {{
158
+ * kind: 'dashboard'|'epics'|'epicNew'|'epicEdit'|'stories'|'storyNew'|'storyEdit'|'tickets'|'ticketNew'|'ticketEdit'|'tools'|'advanced';
159
+ * epicId?: string; storyId?: string; ticketId?: string;
160
+ * epicForm?: boolean; storyForm?: boolean; ticketForm?: boolean;
161
+ * storyFilterEpic?: string; ticketFilterStory?: string;
162
+ * }} AppRoute
163
+ */
164
+
165
+ /**
166
+ * Parses `location.hash` into a structured route.
167
+ * @returns {AppRoute}
168
+ */
169
+ function parseHash() {
170
+ try {
171
+ let raw = (window.location.hash || "").replace(/^#/, "").trim();
172
+ if (!raw || raw === "/") return { kind: "dashboard" };
173
+ const qIndex = raw.indexOf("?");
174
+ const pathPart = qIndex === -1 ? raw : raw.slice(0, qIndex);
175
+ const qs = qIndex === -1 ? "" : raw.slice(qIndex + 1);
176
+ const params = new URLSearchParams(qs);
177
+ const path = pathPart.startsWith("/") ? pathPart : `/${pathPart}`;
178
+ const parts = path.split("/").filter(Boolean);
179
+
180
+ if (parts[0] === "dashboard" && parts.length === 1) {
181
+ return { kind: "dashboard" };
182
+ }
183
+ if (parts[0] === "epics" && parts.length === 1) return { kind: "epics" };
184
+ if (parts[0] === "epic" && parts[1] === "new") return { kind: "epicNew" };
185
+ if (parts[0] === "epic" && parts.length >= 2 && parts[1] !== "new") {
186
+ const id = decodeURIComponent(parts[1]);
187
+ if (!trimU(id)) return { kind: "dashboard" };
188
+ const epicForm = parts[2] === "edit";
189
+ return { kind: "epicEdit", epicId: id, epicForm };
190
+ }
191
+ if (parts[0] === "stories" && parts.length === 1) {
192
+ const epic = params.get("epic") || "";
193
+ return { kind: "stories", storyFilterEpic: epic };
194
+ }
195
+ if (parts[0] === "story" && parts[1] === "new") return { kind: "storyNew" };
196
+ if (parts[0] === "story" && parts.length >= 2 && parts[1] !== "new") {
197
+ const id = decodeURIComponent(parts[1]);
198
+ if (!trimU(id)) return { kind: "dashboard" };
199
+ const storyForm = parts[2] === "edit";
200
+ return { kind: "storyEdit", storyId: id, storyForm };
201
+ }
202
+ if (parts[0] === "tickets" && parts.length === 1) {
203
+ const st = params.get("story") || "";
204
+ return { kind: "tickets", ticketFilterStory: st };
205
+ }
206
+ if (parts[0] === "ticket" && parts[1] === "new")
207
+ return { kind: "ticketNew" };
208
+ if (parts[0] === "ticket" && parts.length >= 2 && parts[1] !== "new") {
209
+ const id = decodeURIComponent(parts[1]);
210
+ if (!trimU(id)) return { kind: "dashboard" };
211
+ const ticketForm = parts[2] === "edit";
212
+ return { kind: "ticketEdit", ticketId: id, ticketForm };
213
+ }
214
+ if (parts[0] === "tools" && parts.length === 1) return { kind: "tools" };
215
+ if (parts[0] === "advanced" && parts.length === 1) {
216
+ return { kind: "advanced" };
217
+ }
218
+ } catch {
219
+ /* fall through */
220
+ }
221
+ return { kind: "dashboard" };
222
+ }
223
+
224
+ /**
225
+ * @param {AppRoute} r
226
+ * @returns {string} path and optional query, without leading #
227
+ */
228
+ function routeToHashPath(r) {
229
+ switch (r.kind) {
230
+ case "dashboard":
231
+ return "/";
232
+ case "epics":
233
+ return "/epics";
234
+ case "epicNew":
235
+ return "/epic/new";
236
+ case "epicEdit": {
237
+ const id = encodeURIComponent(r.epicId || "");
238
+ return r.epicForm ? `/epic/${id}/edit` : `/epic/${id}`;
239
+ }
240
+ case "stories": {
241
+ const fe = trimU(r.storyFilterEpic);
242
+ return fe ? `/stories?epic=${encodeURIComponent(fe)}` : "/stories";
243
+ }
244
+ case "storyNew":
245
+ return "/story/new";
246
+ case "storyEdit": {
247
+ const id = encodeURIComponent(r.storyId || "");
248
+ return r.storyForm ? `/story/${id}/edit` : `/story/${id}`;
249
+ }
250
+ case "tickets": {
251
+ const fs = trimU(r.ticketFilterStory);
252
+ return fs ? `/tickets?story=${encodeURIComponent(fs)}` : "/tickets";
253
+ }
254
+ case "ticketNew":
255
+ return "/ticket/new";
256
+ case "ticketEdit": {
257
+ const id = encodeURIComponent(r.ticketId || "");
258
+ return r.ticketForm ? `/ticket/${id}/edit` : `/ticket/${id}`;
259
+ }
260
+ case "tools":
261
+ return "/tools";
262
+ case "advanced":
263
+ return "/advanced";
264
+ default:
265
+ return "/";
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Applies route fields to in-memory `state` (single source with URL after navigation).
271
+ * @param {AppRoute} r
272
+ */
273
+ function syncStateFromRoute(r) {
274
+ switch (r.kind) {
275
+ case "dashboard":
276
+ state.view = "dashboard";
277
+ delete state.epicId;
278
+ delete state.storyId;
279
+ delete state.ticketId;
280
+ state.storyFilterEpic = "";
281
+ state.ticketFilterStory = "";
282
+ delete state.epicDetailForm;
283
+ delete state.storyDetailForm;
284
+ delete state.ticketDetailForm;
285
+ break;
286
+ case "epics":
287
+ state.view = "epics";
288
+ delete state.epicId;
289
+ delete state.epicDetailForm;
290
+ state.storyFilterEpic = "";
291
+ state.ticketFilterStory = "";
292
+ delete state.storyId;
293
+ delete state.ticketId;
294
+ delete state.storyDetailForm;
295
+ delete state.ticketDetailForm;
296
+ break;
297
+ case "epicNew":
298
+ state.view = "epicNew";
299
+ delete state.epicId;
300
+ delete state.epicDetailForm;
301
+ state.storyFilterEpic = "";
302
+ state.ticketFilterStory = "";
303
+ delete state.storyId;
304
+ delete state.ticketId;
305
+ delete state.storyDetailForm;
306
+ delete state.ticketDetailForm;
307
+ break;
308
+ case "epicEdit":
309
+ state.view = "epicEdit";
310
+ state.epicId = r.epicId;
311
+ state.epicDetailForm = Boolean(r.epicForm);
312
+ state.storyFilterEpic = "";
313
+ state.ticketFilterStory = "";
314
+ break;
315
+ case "stories":
316
+ state.view = "stories";
317
+ state.storyFilterEpic = r.storyFilterEpic || "";
318
+ state.ticketFilterStory = "";
319
+ delete state.storyId;
320
+ delete state.storyDetailForm;
321
+ break;
322
+ case "storyNew":
323
+ state.view = "storyNew";
324
+ delete state.storyId;
325
+ delete state.storyDetailForm;
326
+ state.ticketFilterStory = "";
327
+ break;
328
+ case "storyEdit":
329
+ state.view = "storyEdit";
330
+ state.storyId = r.storyId;
331
+ state.storyDetailForm = Boolean(r.storyForm);
332
+ state.storyFilterEpic = "";
333
+ state.ticketFilterStory = "";
334
+ break;
335
+ case "tickets":
336
+ state.view = "tickets";
337
+ state.ticketFilterStory = r.ticketFilterStory || "";
338
+ state.storyFilterEpic = "";
339
+ delete state.ticketId;
340
+ delete state.ticketDetailForm;
341
+ break;
342
+ case "ticketNew":
343
+ state.view = "ticketNew";
344
+ delete state.ticketId;
345
+ delete state.ticketDetailForm;
346
+ state.storyFilterEpic = "";
347
+ break;
348
+ case "ticketEdit":
349
+ state.view = "ticketEdit";
350
+ state.ticketId = r.ticketId;
351
+ state.ticketDetailForm = Boolean(r.ticketForm);
352
+ state.storyFilterEpic = "";
353
+ state.ticketFilterStory = "";
354
+ break;
355
+ case "tools":
356
+ state.view = "tools";
357
+ delete state.epicId;
358
+ delete state.storyId;
359
+ delete state.ticketId;
360
+ delete state.epicDetailForm;
361
+ delete state.storyDetailForm;
362
+ delete state.ticketDetailForm;
363
+ break;
364
+ case "advanced":
365
+ state.view = "advanced";
366
+ delete state.epicId;
367
+ delete state.storyId;
368
+ delete state.ticketId;
369
+ delete state.epicDetailForm;
370
+ delete state.storyDetailForm;
371
+ delete state.ticketDetailForm;
372
+ break;
373
+ default:
374
+ break;
375
+ }
376
+ }
377
+
378
+ /**
379
+ * @param {AppRoute} r
380
+ * @returns {void|Promise<void>}
381
+ */
382
+ function renderForRoute(r) {
383
+ switch (r.kind) {
384
+ case "dashboard":
385
+ return renderDashboard();
386
+ case "epics":
387
+ return renderEpicsList();
388
+ case "epicNew":
389
+ return renderEpicNew();
390
+ case "epicEdit":
391
+ return renderEpicEdit();
392
+ case "stories":
393
+ return renderStoriesList();
394
+ case "storyNew":
395
+ return renderStoryNew();
396
+ case "storyEdit":
397
+ return renderStoryEdit();
398
+ case "tickets":
399
+ return renderTicketsList();
400
+ case "ticketNew":
401
+ return renderTicketNew();
402
+ case "ticketEdit":
403
+ return renderTicketEdit();
404
+ case "tools":
405
+ renderTools();
406
+ return undefined;
407
+ case "advanced":
408
+ renderAdvanced();
409
+ return undefined;
410
+ default:
411
+ return renderDashboard();
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Updates the URL and renders. Use `replace: true` for filter-only or post-save URL fixes.
417
+ * @param {AppRoute} r
418
+ * @param {{ replace?: boolean }} [opts]
419
+ */
420
+ function pushAppRoute(r, opts) {
421
+ const next = `#${routeToHashPath(r)}`;
422
+ const useReplace = Boolean(opts?.replace);
423
+ if (window.location.hash !== next) {
424
+ history[useReplace ? "replaceState" : "pushState"](null, "", next);
425
+ }
426
+ syncStateFromRoute(r);
427
+ const out = renderForRoute(r);
428
+ setNavCurrent();
429
+ return out;
430
+ }
431
+
432
+ /**
433
+ * @param {AppRoute} r
434
+ */
435
+ function replaceAppRoute(r) {
436
+ pushAppRoute(r, { replace: true });
437
+ }
438
+
439
+ function wireHistoryNavigation() {
440
+ window.addEventListener("popstate", () => {
441
+ const r = parseHash();
442
+ syncStateFromRoute(r);
443
+ void renderForRoute(r);
444
+ setNavCurrent();
445
+ });
446
+ }
447
+
448
+ /**
449
+ * Opens an epic in read mode (detail view).
450
+ * @param {string} epicId
451
+ */
452
+ function navigateToEpic(epicId) {
453
+ void pushAppRoute({ kind: "epicEdit", epicId, epicForm: false });
454
+ }
455
+
456
+ /**
457
+ * Opens a story in read mode (detail view).
458
+ * @param {string} storyId
459
+ */
460
+ function navigateToStory(storyId) {
461
+ void pushAppRoute({ kind: "storyEdit", storyId, storyForm: false });
462
+ }
463
+
464
+ /**
465
+ * Opens a ticket in read mode (detail view).
466
+ * @param {string} ticketId
467
+ */
468
+ function navigateToTicket(ticketId) {
469
+ void pushAppRoute({ kind: "ticketEdit", ticketId, ticketForm: false });
470
+ }
471
+
472
+ /**
473
+ * Goes to the stories list filtered to one epic.
474
+ * @param {string} epicId
475
+ */
476
+ function navigateToStoriesForEpic(epicId) {
477
+ void pushAppRoute({ kind: "stories", storyFilterEpic: epicId });
478
+ }
479
+
480
+ /**
481
+ * Goes to the tickets list filtered to one story.
482
+ * @param {string} storyId
483
+ */
484
+ function navigateToTicketsForStory(storyId) {
485
+ void pushAppRoute({ kind: "tickets", ticketFilterStory: storyId });
486
+ }
487
+
488
+ /**
489
+ * @param {Record<string, unknown>} data
490
+ */
491
+ async function runApi(data) {
492
+ const headers = { "Content-Type": "application/json" };
493
+ const token = localStorage.getItem(TOKEN_KEY);
494
+ if (token) {
495
+ headers.Authorization = `Bearer ${token}`;
496
+ }
497
+ const res = await fetch("/api/run", {
498
+ method: "POST",
499
+ headers,
500
+ body: JSON.stringify(data),
501
+ });
502
+ const text = await res.text();
503
+ let parsed;
504
+ try {
505
+ parsed = JSON.parse(text);
506
+ } catch {
507
+ parsed = { raw: text };
508
+ }
509
+ return { ok: res.ok, status: res.status, body: parsed };
510
+ }
511
+
512
+ /**
513
+ * @param {string[]} argv
514
+ * @param {Record<string, unknown>} [extra]
515
+ * @returns {Promise<unknown>}
516
+ */
517
+ async function runCli(argv, extra = {}) {
518
+ const r = await runApi({ argv, ...extra });
519
+ const b = r.body;
520
+ if (!r.ok) {
521
+ const msg =
522
+ typeof b.error === "string" ? b.error : `Request failed (${r.status})`;
523
+ throw new Error(msg);
524
+ }
525
+ if (b.exitCode !== 0) {
526
+ const errText = (b.stderr && String(b.stderr).trim()) || "";
527
+ throw new Error(errText || `Command failed (exit ${b.exitCode})`);
528
+ }
529
+ return b.json;
530
+ }
531
+
532
+ /**
533
+ * Formats an ISO audit timestamp for the timeline UI.
534
+ * @param {unknown} iso
535
+ * @returns {string}
536
+ */
537
+ function formatAuditInstant(iso) {
538
+ const s = String(iso ?? "");
539
+ const d = new Date(s);
540
+ if (Number.isNaN(d.getTime())) return s;
541
+ return d.toLocaleString(undefined, {
542
+ dateStyle: "medium",
543
+ timeStyle: "short",
544
+ });
545
+ }
546
+
547
+ /**
548
+ * Removes the audit history modal if present.
549
+ */
550
+ function closeHistoryModal() {
551
+ document.getElementById("hpw-history-modal")?.remove();
552
+ document.documentElement.classList.remove("history-modal-open");
553
+ }
554
+
555
+ /**
556
+ * Opens a modal and loads the audit trail for a work item id via `hyper-pm audit`.
557
+ * @param {string} entityId
558
+ * @param {string} entityKindLabel
559
+ * @returns {Promise<void>}
560
+ */
561
+ async function openHistoryModal(entityId, entityKindLabel) {
562
+ closeHistoryModal();
563
+ document.documentElement.classList.add("history-modal-open");
564
+ const backdrop = document.createElement("div");
565
+ backdrop.className = "history-modal-backdrop";
566
+ backdrop.id = "hpw-history-modal";
567
+ backdrop.setAttribute("role", "dialog");
568
+ backdrop.setAttribute("aria-modal", "true");
569
+ backdrop.setAttribute("aria-labelledby", "hpw-history-modal-title");
570
+ backdrop.innerHTML = `
571
+ <div class="history-modal" id="hpw-history-modal-panel">
572
+ <header>
573
+ <h2 id="hpw-history-modal-title">History · ${escapeHtml(entityKindLabel)} · ${escapeHtml(entityId)}</h2>
574
+ <button type="button" class="ghost" id="hpw-history-modal-close">Close</button>
575
+ </header>
576
+ <div class="history-modal-body" id="hpw-history-modal-body">
577
+ <p class="muted">Loading audit trail…</p>
578
+ </div>
579
+ </div>`;
580
+ document.body.appendChild(backdrop);
581
+ /** @param {KeyboardEvent} e */
582
+ const onKey = (e) => {
583
+ if (e.key === "Escape") {
584
+ window.removeEventListener("keydown", onKey);
585
+ closeHistoryModal();
586
+ }
587
+ };
588
+ window.addEventListener("keydown", onKey);
589
+ backdrop.addEventListener("click", () => {
590
+ window.removeEventListener("keydown", onKey);
591
+ closeHistoryModal();
592
+ });
593
+ document
594
+ .getElementById("hpw-history-modal-close")
595
+ ?.addEventListener("click", () => {
596
+ window.removeEventListener("keydown", onKey);
597
+ closeHistoryModal();
598
+ });
599
+ document
600
+ .getElementById("hpw-history-modal-panel")
601
+ ?.addEventListener("click", (e) => {
602
+ e.stopPropagation();
603
+ });
604
+ const bodyEl = document.getElementById("hpw-history-modal-body");
605
+ try {
606
+ const json = await runCli([
607
+ "audit",
608
+ "--entity-id",
609
+ entityId,
610
+ "--limit",
611
+ "500",
612
+ ]);
613
+ const raw = /** @type {{ events?: unknown[] }} */ (json);
614
+ const events = Array.isArray(raw.events) ? raw.events : [];
615
+ const summarize =
616
+ typeof HyperPmAuditSummary !== "undefined" &&
617
+ typeof HyperPmAuditSummary.summarizeAuditEventForWeb === "function"
618
+ ? HyperPmAuditSummary.summarizeAuditEventForWeb
619
+ : (evt) => {
620
+ const t =
621
+ evt &&
622
+ typeof evt === "object" &&
623
+ typeof (/** @type {{ type?: string }} */ (evt).type) === "string"
624
+ ? String(/** @type {{ type?: string }} */ (evt).type)
625
+ : "Event";
626
+ return { title: t, detailLines: [] };
627
+ };
628
+ const ordered = [...events].reverse();
629
+ if (ordered.length === 0) {
630
+ if (bodyEl) {
631
+ bodyEl.innerHTML =
632
+ '<p class="muted">No audit events recorded for this item yet.</p>';
633
+ }
634
+ return;
635
+ }
636
+ const items = ordered
637
+ .map((evt) => {
638
+ const row = /** @type {Record<string, unknown>} */ (evt);
639
+ const ts = formatAuditInstant(row.ts);
640
+ const actor = String(row.actor ?? "");
641
+ const { title, detailLines } = summarize(evt);
642
+ const details =
643
+ detailLines.length === 0
644
+ ? ""
645
+ : detailLines
646
+ .map(
647
+ (line) =>
648
+ `<p class="audit-detail">${escapeHtml(String(line))}</p>`,
649
+ )
650
+ .join("");
651
+ return `<li>
652
+ <div class="audit-ts">${escapeHtml(ts)}</div>
653
+ <div class="audit-actor">${escapeHtml(actor)}</div>
654
+ <div class="audit-title">${escapeHtml(title)}</div>
655
+ ${details}
656
+ </li>`;
657
+ })
658
+ .join("");
659
+ if (bodyEl) {
660
+ bodyEl.innerHTML = `<ol class="audit-timeline" aria-label="Audit events">${items}</ol>`;
661
+ }
662
+ } catch (e) {
663
+ if (bodyEl) {
664
+ bodyEl.innerHTML = `<p class="muted">${escapeHtml(String(e))}</p>`;
665
+ }
666
+ }
667
+ }
668
+
669
+ /**
670
+ * @param {unknown} json
671
+ * @returns {unknown[]}
672
+ */
673
+ function listFromJson(json) {
674
+ if (
675
+ json &&
676
+ typeof json === "object" &&
677
+ Array.isArray(/** @type {{items?: unknown[]}} */ (json).items)
678
+ ) {
679
+ return /** @type {{items: unknown[]}} */ (json).items;
680
+ }
681
+ return [];
682
+ }
683
+
684
+ /**
685
+ * Parses `repo commit-authors` JSON rows for the assignee picker.
686
+ * @param {unknown} json
687
+ * @returns {{ name: string; email: string; loginGuess?: string }[]}
688
+ */
689
+ function commitAuthorsFromJson(json) {
690
+ const items = listFromJson(json);
691
+ /** @type {{ name: string; email: string; loginGuess?: string }[]} */
692
+ const out = [];
693
+ for (const it of items) {
694
+ const r = /** @type {Record<string, unknown>} */ (it);
695
+ const email = trimU(String(r.email ?? ""));
696
+ if (!email) continue;
697
+ const name = String(r.name ?? "");
698
+ /** @type {{ name: string; email: string; loginGuess?: string }} */
699
+ const row = { name, email };
700
+ if (typeof r.loginGuess === "string") {
701
+ const g = trimU(r.loginGuess);
702
+ if (g) row.loginGuess = g.toLowerCase();
703
+ }
704
+ out.push(row);
705
+ }
706
+ return out;
707
+ }
708
+
709
+ /**
710
+ * Wires commit-author select and “suggest login” for ticket assignee fields.
711
+ * @param {string} prefix `edit` or `new` (element id prefix).
712
+ * @param {{ name: string; email: string; loginGuess?: string }[]} authors
713
+ */
714
+ function wireTicketAssigneeControls(prefix, authors) {
715
+ const sel = document.getElementById(`${prefix}TicketCommitAuthor`);
716
+ const loginEl = document.getElementById(`${prefix}TicketAssigneeLogin`);
717
+ const nameEl = document.getElementById(`${prefix}TicketAssigneeName`);
718
+ const emailEl = document.getElementById(`${prefix}TicketAssigneeEmail`);
719
+ const btn = document.getElementById(
720
+ `btn${prefix === "edit" ? "Edit" : "New"}TicketSuggestAssignee`,
721
+ );
722
+ sel?.addEventListener("change", () => {
723
+ if (!sel || !loginEl) return;
724
+ const v = /** @type {HTMLSelectElement} */ (sel).value;
725
+ if (!v) return;
726
+ const idx = Number(v);
727
+ if (!Number.isFinite(idx) || !authors[idx]) return;
728
+ const a = authors[idx];
729
+ if (nameEl) nameEl.value = a.name;
730
+ if (emailEl) emailEl.value = a.email;
731
+ if (a.loginGuess) loginEl.value = a.loginGuess;
732
+ /** @type {HTMLSelectElement} */ (sel).value = "";
733
+ });
734
+ btn?.addEventListener("click", async () => {
735
+ const email = trimU(emailEl?.value ?? "");
736
+ if (!email) {
737
+ toast("Enter an email to suggest a GitHub login.", true);
738
+ return;
739
+ }
740
+ const name = nameEl?.value ?? "";
741
+ try {
742
+ /** @type {string[]} */
743
+ const argv = ["repo", "suggest-assignee-login", "--email", email];
744
+ if (trimU(name)) argv.push("--name", name.trim());
745
+ const j = /** @type {{ loginGuess?: string | null }} */ (
746
+ await runCli(argv)
747
+ );
748
+ const g =
749
+ j && typeof j.loginGuess === "string" ? trimU(j.loginGuess) : undefined;
750
+ if (!g) {
751
+ toast("No login guess; enter the GitHub username manually.", true);
752
+ return;
753
+ }
754
+ if (loginEl) loginEl.value = g.toLowerCase();
755
+ } catch (e) {
756
+ toast(String(e), true);
757
+ }
758
+ });
759
+ }
760
+
761
+ /**
762
+ * @param {string} message
763
+ * @param {boolean} isError
764
+ */
765
+ function toast(message, isError) {
766
+ const el = document.getElementById("toast");
767
+ if (!el) return;
768
+ el.textContent = message;
769
+ el.className = `show ${isError ? "error" : "ok"}`;
770
+ window.clearTimeout(toast._t);
771
+ toast._t = window.setTimeout(() => {
772
+ el.className = "";
773
+ el.textContent = "";
774
+ }, 4500);
775
+ }
776
+
777
+ /**
778
+ * @param {unknown[]} items
779
+ * @param {(row: Record<string, unknown>) => string} rowHtml
780
+ */
781
+ function tableHtml(items, rowHtml) {
782
+ if (items.length === 0) {
783
+ return '<div class="empty-state">Nothing here yet. Create your first item to get started.</div>';
784
+ }
785
+ const rows = items
786
+ .map((row) => rowHtml(/** @type {Record<string, unknown>} */ (row)))
787
+ .join("");
788
+ return `<div class="table-wrap"><table class="data-table"><thead><tr><th>Title</th><th>Status</th><th>Id</th><th></th></tr></thead><tbody>${rows}</tbody></table></div>`;
789
+ }
790
+
791
+ /**
792
+ * @param {string} name
793
+ * @param {string} [value]
794
+ */
795
+ function statusOptionsHtml(name, value) {
796
+ const v = value || "backlog";
797
+ return `<select name="${escapeHtml(name)}" id="${escapeHtml(name)}">${STATUSES.map(
798
+ (s) =>
799
+ `<option value="${s}"${s === v ? " selected" : ""}>${escapeHtml(
800
+ s.replace(/_/g, " "),
801
+ )}</option>`,
802
+ ).join("")}</select>`;
803
+ }
804
+
805
+ function navSectionKey() {
806
+ if (state.view.startsWith("epic")) return "epics";
807
+ if (state.view.startsWith("story")) return "stories";
808
+ if (state.view.startsWith("ticket")) return "tickets";
809
+ return state.view;
810
+ }
811
+
812
+ function setNavCurrent() {
813
+ const key = navSectionKey();
814
+ document.querySelectorAll(".nav-btn[data-nav]").forEach((btn) => {
815
+ const v = /** @type {HTMLButtonElement} */ (btn).dataset.nav;
816
+ btn.setAttribute("aria-current", v === key ? "true" : "false");
817
+ });
818
+ }
819
+
820
+ function setPageTitle(title) {
821
+ const el = document.getElementById("pageTitle");
822
+ if (el) el.textContent = title;
823
+ }
824
+
825
+ /**
826
+ * @param {Record<string, unknown>} row
827
+ */
828
+ function epicRowHtml(row) {
829
+ const id = String(row.id);
830
+ return `<tr>
831
+ <td class="cell-title"><button type="button" class="link-title btn-open-epic" data-epic-id="${escapeHtml(id)}"><span class="md-inline">${markdownInlineHtml(row.title)}</span></button></td>
832
+ <td>${badgeHtml(String(row.status))}</td>
833
+ <td>${idChip(id)}</td>
834
+ <td><button type="button" class="ghost btn-open-epic" data-epic-id="${escapeHtml(id)}">Open</button></td>
835
+ </tr>`;
836
+ }
837
+
838
+ /**
839
+ * @param {Record<string, unknown>} row
840
+ * @param {Record<string, string>} epicTitles epic id → title
841
+ */
842
+ function storyRowHtml(row, epicTitles) {
843
+ const id = String(row.id);
844
+ const eid = String(row.epicId);
845
+ const epicTitle = epicTitles[eid];
846
+ const epicCell = epicTitle
847
+ ? `<button type="button" class="link-title btn-nav-epic" data-epic-id="${escapeHtml(eid)}"><span class="md-inline">${markdownInlineHtml(epicTitle)}</span></button>`
848
+ : idChip(eid);
849
+ return `<tr>
850
+ <td class="cell-title"><button type="button" class="link-title btn-open-story" data-story-id="${escapeHtml(id)}"><span class="md-inline">${markdownInlineHtml(row.title)}</span></button></td>
851
+ <td>${badgeHtml(String(row.status))}</td>
852
+ <td>${epicCell}</td>
853
+ <td>${idChip(id)}</td>
854
+ <td><button type="button" class="ghost btn-open-story" data-story-id="${escapeHtml(id)}">Open</button></td>
855
+ </tr>`;
856
+ }
857
+
858
+ /**
859
+ * @param {Record<string, unknown>} row
860
+ * @param {Record<string, string>} storyTitles story id → title
861
+ */
862
+ function ticketRowHtml(row, storyTitles) {
863
+ const id = String(row.id);
864
+ const sid =
865
+ row.storyId === null || row.storyId === undefined
866
+ ? ""
867
+ : String(row.storyId);
868
+ const stitle = sid ? storyTitles[sid] : "";
869
+ const sidCell =
870
+ sid === ""
871
+ ? '<span class="muted">—</span>'
872
+ : stitle
873
+ ? `<button type="button" class="link-title btn-nav-story" data-story-id="${escapeHtml(sid)}"><span class="md-inline">${markdownInlineHtml(stitle)}</span></button>`
874
+ : idChip(sid);
875
+ return `<tr>
876
+ <td class="cell-title"><button type="button" class="link-title btn-open-ticket" data-ticket-id="${escapeHtml(id)}"><span class="md-inline">${markdownInlineHtml(row.title)}</span></button></td>
877
+ <td>${badgeHtml(String(row.status))}</td>
878
+ <td>${sidCell}</td>
879
+ <td>${idChip(id)}</td>
880
+ <td><button type="button" class="ghost btn-open-ticket" data-ticket-id="${escapeHtml(id)}">Open</button></td>
881
+ </tr>`;
882
+ }
883
+
884
+ async function loadHealth() {
885
+ const el = document.getElementById("healthLine");
886
+ try {
887
+ const r = await fetch("/api/health");
888
+ const j = await r.json();
889
+ if (el) {
890
+ el.textContent = `Repo: ${j.repoPath ?? "—"} · Worktrees: ${j.tempDirParent ?? "—"}`;
891
+ }
892
+ } catch (e) {
893
+ if (el) el.textContent = `Could not load health: ${String(e)}`;
894
+ }
895
+ }
896
+
897
+ async function renderDashboard() {
898
+ setPageTitle("Overview");
899
+ const main = document.getElementById("main");
900
+ if (!main) return;
901
+ main.innerHTML = '<p class="muted">Loading…</p>';
902
+ try {
903
+ const [ej, sj, tj] = await Promise.all([
904
+ runCli(["epic", "read"]),
905
+ runCli(["story", "read"]),
906
+ runCli(["ticket", "read"]),
907
+ ]);
908
+ const epics = listFromJson(ej);
909
+ const stories = listFromJson(sj);
910
+ const tickets = listFromJson(tj);
911
+ main.innerHTML = `
912
+ <div class="panel">
913
+ <div class="panel-head">
914
+ <h2>Overview</h2>
915
+ </div>
916
+ <p class="muted lead">Work item counts for this repository. Use <strong>Refresh</strong> in the header after edits outside this tab.</p>
917
+ <div class="stat-grid">
918
+ <div class="stat-card"><div class="label">Epics</div><strong>${epics.length}</strong></div>
919
+ <div class="stat-card"><div class="label">Stories</div><strong>${stories.length}</strong></div>
920
+ <div class="stat-card"><div class="label">Tickets</div><strong>${tickets.length}</strong></div>
921
+ </div>
922
+ <div class="nav-related row" style="margin-top:1.25rem">
923
+ <button type="button" class="primary" id="dashOpenEpics">Browse epics</button>
924
+ <button type="button" class="ghost" id="dashOpenStories">Browse stories</button>
925
+ <button type="button" class="ghost" id="dashOpenTickets">Browse tickets</button>
926
+ </div>
927
+ </div>`;
928
+ document.getElementById("dashOpenEpics")?.addEventListener("click", () => {
929
+ void pushAppRoute({ kind: "epics" });
930
+ });
931
+ document
932
+ .getElementById("dashOpenStories")
933
+ ?.addEventListener("click", () => {
934
+ void pushAppRoute({ kind: "stories", storyFilterEpic: "" });
935
+ });
936
+ document
937
+ .getElementById("dashOpenTickets")
938
+ ?.addEventListener("click", () => {
939
+ void pushAppRoute({ kind: "tickets", ticketFilterStory: "" });
940
+ });
941
+ } catch (e) {
942
+ main.innerHTML = `<div class="panel"><p class="muted">${escapeHtml(String(e))}</p></div>`;
943
+ }
944
+ }
945
+
946
+ async function renderEpicsList() {
947
+ setPageTitle("Epics");
948
+ state.view = "epics";
949
+ delete state.epicId;
950
+ delete state.epicDetailForm;
951
+ setNavCurrent();
952
+ const main = document.getElementById("main");
953
+ if (!main) return;
954
+ main.innerHTML = '<p class="muted">Loading epics…</p>';
955
+ try {
956
+ const json = await runCli(["epic", "read"]);
957
+ const items = listFromJson(json);
958
+ main.innerHTML = `
959
+ <div class="panel">
960
+ <div class="panel-head">
961
+ <h2>Epics</h2>
962
+ <button type="button" class="primary" id="btnNewEpic">New epic</button>
963
+ </div>
964
+ ${tableHtml(items, epicRowHtml)}
965
+ </div>`;
966
+ document.getElementById("btnNewEpic")?.addEventListener("click", () => {
967
+ void pushAppRoute({ kind: "epicNew" });
968
+ });
969
+ main.querySelectorAll(".btn-open-epic").forEach((btn) => {
970
+ btn.addEventListener("click", () => {
971
+ const eid = /** @type {HTMLButtonElement} */ (btn).dataset.epicId;
972
+ if (eid) navigateToEpic(eid);
973
+ });
974
+ });
975
+ } catch (e) {
976
+ main.innerHTML = `<div class="panel"><p class="muted">${escapeHtml(String(e))}</p></div>`;
977
+ }
978
+ }
979
+
980
+ async function renderEpicNew() {
981
+ setPageTitle("New epic");
982
+ state.view = "epicNew";
983
+ setNavCurrent();
984
+ const main = document.getElementById("main");
985
+ if (!main) return;
986
+ main.innerHTML = `
987
+ <div class="panel">
988
+ <div class="back-link">
989
+ <button type="button" class="ghost" id="btnBackEpics">← Back to epics</button>
990
+ </div>
991
+ <div class="panel-head" style="border-bottom:none;padding-bottom:0;margin-bottom:0.5rem">
992
+ <h2>Create epic</h2>
993
+ </div>
994
+ <label for="newEpicTitle">Title</label>
995
+ <input type="text" id="newEpicTitle" required />
996
+ <label for="newEpicBody">Description</label>
997
+ <textarea id="newEpicBody" rows="4"></textarea>
998
+ <label for="newEpicStatus">Status</label>
999
+ ${statusOptionsHtml("newEpicStatus", "backlog")}
1000
+ <div class="row">
1001
+ <button type="button" class="primary" id="btnCreateEpic">Create</button>
1002
+ </div>
1003
+ </div>`;
1004
+ document.getElementById("btnBackEpics")?.addEventListener("click", () => {
1005
+ void pushAppRoute({ kind: "epics" });
1006
+ });
1007
+ document
1008
+ .getElementById("btnCreateEpic")
1009
+ ?.addEventListener("click", async () => {
1010
+ const title = trimU(document.getElementById("newEpicTitle")?.value ?? "");
1011
+ if (!title) {
1012
+ toast("Title is required", true);
1013
+ return;
1014
+ }
1015
+ const body = document.getElementById("newEpicBody")?.value ?? "";
1016
+ const status =
1017
+ document.getElementById("newEpicStatus")?.value ?? "backlog";
1018
+ const argv = [
1019
+ "epic",
1020
+ "create",
1021
+ "--title",
1022
+ title,
1023
+ "--body",
1024
+ body,
1025
+ "--status",
1026
+ status,
1027
+ ];
1028
+ try {
1029
+ await runCli(argv);
1030
+ toast("Epic created", false);
1031
+ void pushAppRoute({ kind: "epics" });
1032
+ } catch (e) {
1033
+ toast(String(e), true);
1034
+ }
1035
+ });
1036
+ }
1037
+
1038
+ async function renderEpicEdit() {
1039
+ const id = state.epicId;
1040
+ if (!id) {
1041
+ void pushAppRoute({ kind: "epics" });
1042
+ return;
1043
+ }
1044
+ state.view = "epicEdit";
1045
+ setNavCurrent();
1046
+ const main = document.getElementById("main");
1047
+ if (!main) return;
1048
+ main.innerHTML = '<p class="muted">Loading…</p>';
1049
+ try {
1050
+ const row = /** @type {Record<string, unknown>} */ (
1051
+ await runCli(["epic", "read", "--id", id])
1052
+ );
1053
+ const showForm = Boolean(state.epicDetailForm);
1054
+ setPageTitle(
1055
+ showForm ? "Edit epic" : markdownToPlainTextForUiChrome(row.title),
1056
+ );
1057
+ const readBlock = `
1058
+ <div class="issue-detail-layout">
1059
+ <div class="issue-main">
1060
+ <div class="panel-head issue-panel-head">
1061
+ <div>
1062
+ <p class="muted issue-kicker" style="margin:0 0 0.25rem">Epic</p>
1063
+ <h2 class="issue-title"><span class="md-inline">${markdownInlineHtml(row.title)}</span></h2>
1064
+ </div>
1065
+ <div class="panel-head-actions">
1066
+ <button type="button" class="btn-subtle" id="btnEpicHistory">History</button>
1067
+ <button type="button" class="btn-subtle" id="btnEpicSeeStories">See stories</button>
1068
+ <button type="button" class="btn-subtle" id="btnEpicEnterEdit">Edit</button>
1069
+ </div>
1070
+ </div>
1071
+ <div class="issue-body">
1072
+ ${readBodyHtml(row.body)}
1073
+ </div>
1074
+ </div>
1075
+ <aside class="issue-sidebar" aria-label="Epic metadata">
1076
+ <div class="read-stack issue-meta-stack">
1077
+ ${readRowHtml("Epic ID", idChip(id))}
1078
+ ${readRowHtml("Status", badgeHtml(String(row.status)))}
1079
+ </div>
1080
+ </aside>
1081
+ </div>`;
1082
+ const formBlock = `
1083
+ <div class="issue-detail-layout">
1084
+ <div class="issue-main">
1085
+ <div class="panel-head issue-panel-head" style="border-bottom:none;padding-bottom:0;margin-bottom:0.5rem">
1086
+ <h2 class="issue-title">Edit epic</h2>
1087
+ <div class="panel-head-actions">
1088
+ <button type="button" class="btn-subtle" id="btnEpicSeeStories">See stories</button>
1089
+ </div>
1090
+ </div>
1091
+ <label for="editEpicTitle">Title</label>
1092
+ <input type="text" id="editEpicTitle" value="${escapeHtml(String(row.title))}" />
1093
+ <label for="editEpicBody">Description</label>
1094
+ <textarea id="editEpicBody" rows="6">${escapeHtml(String(row.body ?? ""))}</textarea>
1095
+ <div class="row">
1096
+ <button type="button" class="primary" id="btnSaveEpic">Save changes</button>
1097
+ <button type="button" class="ghost" id="btnEpicCancelEdit">Cancel</button>
1098
+ <button type="button" class="danger" id="btnDeleteEpic">Delete epic</button>
1099
+ </div>
1100
+ </div>
1101
+ <aside class="issue-sidebar" aria-label="Epic fields">
1102
+ <div class="read-stack issue-meta-stack">
1103
+ ${readRowHtml("Epic ID", idChip(id))}
1104
+ <div class="read-row">
1105
+ <div class="read-label">Status</div>
1106
+ <div class="read-value">${statusOptionsHtml("editEpicStatus", String(row.status))}</div>
1107
+ </div>
1108
+ </div>
1109
+ </aside>
1110
+ </div>`;
1111
+ const epicTopBar = `
1112
+ <nav class="detail-page-top" aria-label="Epic">
1113
+ <button type="button" class="ghost detail-back" id="btnBackEpics2">← Epics</button>
1114
+ </nav>`;
1115
+ main.innerHTML = `
1116
+ <div class="panel">
1117
+ ${epicTopBar}
1118
+ ${showForm ? formBlock : readBlock}
1119
+ </div>`;
1120
+ document.getElementById("btnBackEpics2")?.addEventListener("click", () => {
1121
+ void pushAppRoute({ kind: "epics" });
1122
+ });
1123
+ document
1124
+ .getElementById("btnEpicSeeStories")
1125
+ ?.addEventListener("click", () => {
1126
+ navigateToStoriesForEpic(id);
1127
+ });
1128
+ if (!showForm) {
1129
+ document
1130
+ .getElementById("btnEpicHistory")
1131
+ ?.addEventListener("click", () => {
1132
+ void openHistoryModal(id, "Epic");
1133
+ });
1134
+ document
1135
+ .getElementById("btnEpicEnterEdit")
1136
+ ?.addEventListener("click", () => {
1137
+ void pushAppRoute({ kind: "epicEdit", epicId: id, epicForm: true });
1138
+ });
1139
+ } else {
1140
+ document
1141
+ .getElementById("btnEpicCancelEdit")
1142
+ ?.addEventListener("click", () => {
1143
+ window.history.back();
1144
+ });
1145
+ document
1146
+ .getElementById("btnSaveEpic")
1147
+ ?.addEventListener("click", async () => {
1148
+ const title = trimU(
1149
+ document.getElementById("editEpicTitle")?.value ?? "",
1150
+ );
1151
+ const body = document.getElementById("editEpicBody")?.value ?? "";
1152
+ const status =
1153
+ document.getElementById("editEpicStatus")?.value ?? "backlog";
1154
+ if (!title) {
1155
+ toast("Title is required", true);
1156
+ return;
1157
+ }
1158
+ try {
1159
+ const prevTitle = trimU(String(row.title));
1160
+ const prevBody = String(row.body ?? "");
1161
+ const prevStatus = String(row.status);
1162
+ /** @type {string[]} */
1163
+ const argv = ["epic", "update", "--id", id];
1164
+ if (title !== prevTitle) {
1165
+ argv.push("--title", title);
1166
+ }
1167
+ if (body !== prevBody) {
1168
+ argv.push("--body", body);
1169
+ }
1170
+ if (status !== prevStatus) {
1171
+ argv.push("--status", status);
1172
+ }
1173
+ await runCli(argv);
1174
+ toast("Epic saved", false);
1175
+ replaceAppRoute({ kind: "epicEdit", epicId: id, epicForm: false });
1176
+ } catch (e) {
1177
+ toast(String(e), true);
1178
+ }
1179
+ });
1180
+ document
1181
+ .getElementById("btnDeleteEpic")
1182
+ ?.addEventListener("click", async () => {
1183
+ if (!window.confirm(`Delete epic ${id}? This cannot be undone.`))
1184
+ return;
1185
+ try {
1186
+ await runCli(["epic", "delete", "--id", id]);
1187
+ toast("Epic deleted", false);
1188
+ void pushAppRoute({ kind: "epics" });
1189
+ } catch (e) {
1190
+ toast(String(e), true);
1191
+ }
1192
+ });
1193
+ }
1194
+ } catch (e) {
1195
+ main.innerHTML = `<div class="panel"><p class="muted">${escapeHtml(String(e))}</p><button type="button" class="ghost" id="btnEpicErrBack">← Epics</button></div>`;
1196
+ document.getElementById("btnEpicErrBack")?.addEventListener("click", () => {
1197
+ void pushAppRoute({ kind: "epics" });
1198
+ });
1199
+ }
1200
+ }
1201
+
1202
+ async function renderStoriesList() {
1203
+ setPageTitle("Stories");
1204
+ state.view = "stories";
1205
+ delete state.storyId;
1206
+ delete state.storyDetailForm;
1207
+ setNavCurrent();
1208
+ const main = document.getElementById("main");
1209
+ if (!main) return;
1210
+ main.innerHTML = '<p class="muted">Loading…</p>';
1211
+ try {
1212
+ const ej = await runCli(["epic", "read"]);
1213
+ const epics = listFromJson(ej);
1214
+ const argv = ["story", "read"];
1215
+ const fe = trimU(state.storyFilterEpic);
1216
+ if (fe) {
1217
+ argv.push("--epic", fe);
1218
+ }
1219
+ const json = await runCli(argv);
1220
+ const items = listFromJson(json);
1221
+ /** @type {Record<string, string>} */
1222
+ const epicTitles = {};
1223
+ for (const e of epics) {
1224
+ epicTitles[String(/** @type {{id:string}} */ (e).id)] = String(
1225
+ /** @type {{title:string}} */ (e).title,
1226
+ );
1227
+ }
1228
+ const epicOpts = [
1229
+ `<option value="">All epics</option>`,
1230
+ ...epics.map(
1231
+ (e) =>
1232
+ `<option value="${escapeHtml(String(/** @type {{id:string}} */ (e).id))}"${String(/** @type {{id:string}} */ (e).id) === fe ? " selected" : ""}>${escapeHtml(String(/** @type {{title:string}} */ (e).title))}</option>`,
1233
+ ),
1234
+ ].join("");
1235
+ const filteredEpicRow = fe
1236
+ ? epics.find((e) => String(/** @type {{id:string}} */ (e).id) === fe)
1237
+ : undefined;
1238
+ const storyFilterBanner = fe
1239
+ ? `<div class="filter-context-bar"><span>Showing stories in </span><span class="filter-context-entity">${filteredEpicRow ? markdownInlineHtml(String(/** @type {{title:string}} */ (filteredEpicRow).title)) : escapeHtml(fe)}</span><div class="filter-actions"><button type="button" class="ghost" id="btnStoriesCtxEpic">View epic</button><button type="button" class="ghost" id="btnStoriesClearEpic">All stories</button></div></div>`
1240
+ : "";
1241
+ const storyTable =
1242
+ items.length === 0
1243
+ ? '<div class="empty-state">No stories match this filter.</div>'
1244
+ : `<div class="table-wrap"><table class="data-table"><thead><tr><th>Title</th><th>Status</th><th>Epic</th><th>Id</th><th></th></tr></thead><tbody>
1245
+ ${items
1246
+ .map((row) =>
1247
+ storyRowHtml(
1248
+ /** @type {Record<string, unknown>} */ (row),
1249
+ epicTitles,
1250
+ ),
1251
+ )
1252
+ .join("")}
1253
+ </tbody></table></div>`;
1254
+ main.innerHTML = `
1255
+ <div class="panel">
1256
+ ${storyFilterBanner}
1257
+ <div class="filter-bar">
1258
+ <label for="filterStoryEpic">Filter by epic</label>
1259
+ <select id="filterStoryEpic">${epicOpts}</select>
1260
+ </div>
1261
+ <div class="panel-head">
1262
+ <h2>Stories</h2>
1263
+ <button type="button" class="primary" id="btnNewStory">New story</button>
1264
+ </div>
1265
+ ${storyTable}
1266
+ </div>`;
1267
+ document
1268
+ .getElementById("filterStoryEpic")
1269
+ ?.addEventListener("change", (ev) => {
1270
+ const val = /** @type {HTMLSelectElement} */ (ev.target).value;
1271
+ replaceAppRoute({ kind: "stories", storyFilterEpic: val });
1272
+ });
1273
+ document.getElementById("btnNewStory")?.addEventListener("click", () => {
1274
+ void pushAppRoute({ kind: "storyNew" });
1275
+ });
1276
+ if (fe) {
1277
+ document
1278
+ .getElementById("btnStoriesCtxEpic")
1279
+ ?.addEventListener("click", () => {
1280
+ navigateToEpic(fe);
1281
+ });
1282
+ document
1283
+ .getElementById("btnStoriesClearEpic")
1284
+ ?.addEventListener("click", () => {
1285
+ void pushAppRoute({ kind: "stories", storyFilterEpic: "" });
1286
+ });
1287
+ }
1288
+ main.querySelectorAll(".btn-open-story").forEach((btn) => {
1289
+ btn.addEventListener("click", () => {
1290
+ const sid = /** @type {HTMLButtonElement} */ (btn).dataset.storyId;
1291
+ if (sid) navigateToStory(sid);
1292
+ });
1293
+ });
1294
+ main.querySelectorAll(".btn-nav-epic").forEach((btn) => {
1295
+ btn.addEventListener("click", (ev) => {
1296
+ ev.stopPropagation();
1297
+ const eid = /** @type {HTMLButtonElement} */ (btn).dataset.epicId;
1298
+ if (eid) navigateToEpic(eid);
1299
+ });
1300
+ });
1301
+ } catch (e) {
1302
+ main.innerHTML = `<div class="panel"><p class="muted">${escapeHtml(String(e))}</p></div>`;
1303
+ }
1304
+ }
1305
+
1306
+ async function renderStoryNew() {
1307
+ setPageTitle("New story");
1308
+ state.view = "storyNew";
1309
+ setNavCurrent();
1310
+ const main = document.getElementById("main");
1311
+ if (!main) return;
1312
+ const ej = await runCli(["epic", "read"]).catch(() => ({ items: [] }));
1313
+ const epics = listFromJson(ej);
1314
+ const epicOpts = epics
1315
+ .map(
1316
+ (e) =>
1317
+ `<option value="${escapeHtml(String(/** @type {{id:string}} */ (e).id))}">${escapeHtml(String(/** @type {{title:string}} */ (e).title))}</option>`,
1318
+ )
1319
+ .join("");
1320
+ main.innerHTML = `
1321
+ <div class="panel">
1322
+ <div class="back-link">
1323
+ <button type="button" class="ghost" id="btnBackStories">← Stories</button>
1324
+ </div>
1325
+ <div class="panel-head" style="border-bottom:none;padding-bottom:0;margin-bottom:0.5rem">
1326
+ <h2>Create story</h2>
1327
+ </div>
1328
+ <label for="newStoryEpic">Epic</label>
1329
+ <select id="newStoryEpic" required><option value="">Select epic…</option>${epicOpts}</select>
1330
+ <label for="newStoryTitle">Title</label>
1331
+ <input type="text" id="newStoryTitle" />
1332
+ <label for="newStoryBody">Description</label>
1333
+ <textarea id="newStoryBody" rows="4"></textarea>
1334
+ <label for="newStoryStatus">Status</label>
1335
+ ${statusOptionsHtml("newStoryStatus", "backlog")}
1336
+ <div class="row">
1337
+ <button type="button" class="primary" id="btnCreateStory">Create</button>
1338
+ </div>
1339
+ </div>`;
1340
+ document.getElementById("btnBackStories")?.addEventListener("click", () => {
1341
+ window.history.back();
1342
+ });
1343
+ document
1344
+ .getElementById("btnCreateStory")
1345
+ ?.addEventListener("click", async () => {
1346
+ const epic = trimU(document.getElementById("newStoryEpic")?.value ?? "");
1347
+ const title = trimU(
1348
+ document.getElementById("newStoryTitle")?.value ?? "",
1349
+ );
1350
+ const body = document.getElementById("newStoryBody")?.value ?? "";
1351
+ const status =
1352
+ document.getElementById("newStoryStatus")?.value ?? "backlog";
1353
+ if (!epic || !title) {
1354
+ toast("Epic and title are required", true);
1355
+ return;
1356
+ }
1357
+ try {
1358
+ await runCli([
1359
+ "story",
1360
+ "create",
1361
+ "--title",
1362
+ title,
1363
+ "--epic",
1364
+ epic,
1365
+ "--body",
1366
+ body,
1367
+ "--status",
1368
+ status,
1369
+ ]);
1370
+ toast("Story created", false);
1371
+ void pushAppRoute({ kind: "stories", storyFilterEpic: epic });
1372
+ } catch (e) {
1373
+ toast(String(e), true);
1374
+ }
1375
+ });
1376
+ }
1377
+
1378
+ async function renderStoryEdit() {
1379
+ const id = state.storyId;
1380
+ if (!id) {
1381
+ void pushAppRoute({ kind: "stories", storyFilterEpic: "" });
1382
+ return;
1383
+ }
1384
+ state.view = "storyEdit";
1385
+ setNavCurrent();
1386
+ const main = document.getElementById("main");
1387
+ if (!main) return;
1388
+ main.innerHTML = '<p class="muted">Loading…</p>';
1389
+ try {
1390
+ const row = /** @type {Record<string, unknown>} */ (
1391
+ await runCli(["story", "read", "--id", id])
1392
+ );
1393
+ const epicId = String(row.epicId);
1394
+ let epicReadInner = idChip(epicId);
1395
+ let epicFormLine = `Epic id <code>${escapeHtml(epicId)}</code>`;
1396
+ try {
1397
+ const epicRow = /** @type {Record<string, unknown>} */ (
1398
+ await runCli(["epic", "read", "--id", epicId])
1399
+ );
1400
+ const t = markdownInlineHtml(String(epicRow.title));
1401
+ epicReadInner = `<span class="md-inline">${t}</span> · ${idChip(epicId)}`;
1402
+ epicFormLine = `Epic: <span class="md-inline">${t}</span> (<code>${escapeHtml(epicId)}</code>)`;
1403
+ } catch {
1404
+ /* keep defaults */
1405
+ }
1406
+ const showForm = Boolean(state.storyDetailForm);
1407
+ setPageTitle(
1408
+ showForm ? "Edit story" : markdownToPlainTextForUiChrome(row.title),
1409
+ );
1410
+ const readBlock = `
1411
+ <div class="issue-detail-layout">
1412
+ <div class="issue-main">
1413
+ <div class="panel-head issue-panel-head">
1414
+ <div>
1415
+ <p class="muted issue-kicker" style="margin:0 0 0.25rem">Story</p>
1416
+ <h2 class="issue-title"><span class="md-inline">${markdownInlineHtml(row.title)}</span></h2>
1417
+ </div>
1418
+ <div class="panel-head-actions">
1419
+ <button type="button" class="btn-subtle" id="btnStoryHistory">History</button>
1420
+ <button type="button" class="btn-subtle" id="btnStoryOpenEpic">Open epic</button>
1421
+ <button type="button" class="btn-subtle" id="btnStorySeeTickets">See tickets</button>
1422
+ <button type="button" class="btn-subtle" id="btnStoryEnterEdit">Edit</button>
1423
+ </div>
1424
+ </div>
1425
+ <div class="issue-body">
1426
+ ${readBodyHtml(row.body)}
1427
+ </div>
1428
+ </div>
1429
+ <aside class="issue-sidebar" aria-label="Story metadata">
1430
+ <div class="read-stack issue-meta-stack">
1431
+ ${readRowHtml("Story ID", idChip(id))}
1432
+ ${readRowHtml("Epic", epicReadInner)}
1433
+ ${readRowHtml("Status", badgeHtml(String(row.status)))}
1434
+ </div>
1435
+ </aside>
1436
+ </div>`;
1437
+ const formBlock = `
1438
+ <div class="issue-detail-layout">
1439
+ <div class="issue-main">
1440
+ <div class="panel-head issue-panel-head" style="border-bottom:none;padding-bottom:0;margin-bottom:0.5rem">
1441
+ <h2 class="issue-title">Edit story</h2>
1442
+ <div class="panel-head-actions">
1443
+ <button type="button" class="btn-subtle" id="btnStoryOpenEpic">Open epic</button>
1444
+ <button type="button" class="btn-subtle" id="btnStorySeeTickets">See tickets</button>
1445
+ </div>
1446
+ </div>
1447
+ <p class="muted" style="font-size:0.85rem;margin-top:0">To move a story to another epic, delete and recreate it (CLI does not support changing epic on update).</p>
1448
+ <label for="editStoryTitle">Title</label>
1449
+ <input type="text" id="editStoryTitle" value="${escapeHtml(String(row.title))}" />
1450
+ <label for="editStoryBody">Description</label>
1451
+ <textarea id="editStoryBody" rows="6">${escapeHtml(String(row.body ?? ""))}</textarea>
1452
+ <div class="row">
1453
+ <button type="button" class="primary" id="btnSaveStory">Save</button>
1454
+ <button type="button" class="ghost" id="btnStoryCancelEdit">Cancel</button>
1455
+ <button type="button" class="danger" id="btnDeleteStory">Delete</button>
1456
+ </div>
1457
+ </div>
1458
+ <aside class="issue-sidebar" aria-label="Story fields">
1459
+ <div class="read-stack issue-meta-stack">
1460
+ ${readRowHtml("Story ID", idChip(id))}
1461
+ ${readRowHtml("Epic", epicFormLine)}
1462
+ <div class="read-row">
1463
+ <div class="read-label">Status</div>
1464
+ <div class="read-value">${statusOptionsHtml("editStoryStatus", String(row.status))}</div>
1465
+ </div>
1466
+ </div>
1467
+ </aside>
1468
+ </div>`;
1469
+ const storyTopBar = `
1470
+ <nav class="detail-page-top" aria-label="Story">
1471
+ <button type="button" class="ghost detail-back" id="btnBackStories2">← Stories</button>
1472
+ </nav>`;
1473
+ main.innerHTML = `
1474
+ <div class="panel">
1475
+ ${storyTopBar}
1476
+ ${showForm ? formBlock : readBlock}
1477
+ </div>`;
1478
+ document
1479
+ .getElementById("btnBackStories2")
1480
+ ?.addEventListener("click", () => {
1481
+ void pushAppRoute({ kind: "stories", storyFilterEpic: epicId });
1482
+ });
1483
+ document
1484
+ .getElementById("btnStoryOpenEpic")
1485
+ ?.addEventListener("click", () => {
1486
+ navigateToEpic(epicId);
1487
+ });
1488
+ document
1489
+ .getElementById("btnStorySeeTickets")
1490
+ ?.addEventListener("click", () => {
1491
+ navigateToTicketsForStory(id);
1492
+ });
1493
+ if (!showForm) {
1494
+ document
1495
+ .getElementById("btnStoryHistory")
1496
+ ?.addEventListener("click", () => {
1497
+ void openHistoryModal(id, "Story");
1498
+ });
1499
+ document
1500
+ .getElementById("btnStoryEnterEdit")
1501
+ ?.addEventListener("click", () => {
1502
+ void pushAppRoute({
1503
+ kind: "storyEdit",
1504
+ storyId: id,
1505
+ storyForm: true,
1506
+ });
1507
+ });
1508
+ } else {
1509
+ document
1510
+ .getElementById("btnStoryCancelEdit")
1511
+ ?.addEventListener("click", () => {
1512
+ window.history.back();
1513
+ });
1514
+ document
1515
+ .getElementById("btnSaveStory")
1516
+ ?.addEventListener("click", async () => {
1517
+ const title = trimU(
1518
+ document.getElementById("editStoryTitle")?.value ?? "",
1519
+ );
1520
+ const body = document.getElementById("editStoryBody")?.value ?? "";
1521
+ const status =
1522
+ document.getElementById("editStoryStatus")?.value ?? "backlog";
1523
+ if (!title) {
1524
+ toast("Title is required", true);
1525
+ return;
1526
+ }
1527
+ try {
1528
+ const prevTitle = trimU(String(row.title));
1529
+ const prevBody = String(row.body ?? "");
1530
+ const prevStatus = String(row.status);
1531
+ /** @type {string[]} */
1532
+ const argv = ["story", "update", "--id", id];
1533
+ if (title !== prevTitle) {
1534
+ argv.push("--title", title);
1535
+ }
1536
+ if (body !== prevBody) {
1537
+ argv.push("--body", body);
1538
+ }
1539
+ if (status !== prevStatus) {
1540
+ argv.push("--status", status);
1541
+ }
1542
+ await runCli(argv);
1543
+ toast("Story saved", false);
1544
+ replaceAppRoute({
1545
+ kind: "storyEdit",
1546
+ storyId: id,
1547
+ storyForm: false,
1548
+ });
1549
+ } catch (e) {
1550
+ toast(String(e), true);
1551
+ }
1552
+ });
1553
+ document
1554
+ .getElementById("btnDeleteStory")
1555
+ ?.addEventListener("click", async () => {
1556
+ if (!window.confirm(`Delete story ${id}?`)) return;
1557
+ try {
1558
+ await runCli(["story", "delete", "--id", id]);
1559
+ toast("Story deleted", false);
1560
+ void pushAppRoute({ kind: "stories", storyFilterEpic: "" });
1561
+ } catch (e) {
1562
+ toast(String(e), true);
1563
+ }
1564
+ });
1565
+ }
1566
+ } catch (e) {
1567
+ main.innerHTML = `<div class="panel"><p class="muted">${escapeHtml(String(e))}</p><button type="button" class="ghost" id="btnStoryErrBack">← Stories</button></div>`;
1568
+ document
1569
+ .getElementById("btnStoryErrBack")
1570
+ ?.addEventListener("click", () => {
1571
+ void pushAppRoute({ kind: "stories", storyFilterEpic: "" });
1572
+ });
1573
+ }
1574
+ }
1575
+
1576
+ async function renderTicketsList() {
1577
+ setPageTitle("Tickets");
1578
+ state.view = "tickets";
1579
+ delete state.ticketId;
1580
+ delete state.ticketDetailForm;
1581
+ setNavCurrent();
1582
+ const main = document.getElementById("main");
1583
+ if (!main) return;
1584
+ main.innerHTML = '<p class="muted">Loading…</p>';
1585
+ try {
1586
+ const sj = await runCli(["story", "read"]);
1587
+ const stories = listFromJson(sj);
1588
+ const argv = ["ticket", "read"];
1589
+ const fs = trimU(state.ticketFilterStory);
1590
+ if (fs) argv.push("--story", fs);
1591
+ const json = await runCli(argv);
1592
+ const items = listFromJson(json);
1593
+ /** @type {Record<string, string>} */
1594
+ const storyTitles = {};
1595
+ for (const s of stories) {
1596
+ storyTitles[String(/** @type {{id:string}} */ (s).id)] = String(
1597
+ /** @type {{title:string}} */ (s).title,
1598
+ );
1599
+ }
1600
+ const storyOpts = [
1601
+ `<option value="">All tickets</option>`,
1602
+ ...stories.map(
1603
+ (s) =>
1604
+ `<option value="${escapeHtml(String(/** @type {{id:string}} */ (s).id))}"${String(/** @type {{id:string}} */ (s).id) === fs ? " selected" : ""}>${escapeHtml(String(/** @type {{title:string}} */ (s).title))}</option>`,
1605
+ ),
1606
+ ].join("");
1607
+ const filteredStoryRow = fs
1608
+ ? stories.find((s) => String(/** @type {{id:string}} */ (s).id) === fs)
1609
+ : undefined;
1610
+ const ticketFilterBanner = fs
1611
+ ? `<div class="filter-context-bar"><span>Showing tickets for </span><span class="filter-context-entity">${filteredStoryRow ? markdownInlineHtml(String(/** @type {{title:string}} */ (filteredStoryRow).title)) : escapeHtml(fs)}</span><div class="filter-actions"><button type="button" class="ghost" id="btnTicketsCtxStory">View story</button><button type="button" class="ghost" id="btnTicketsClearStory">All tickets</button></div></div>`
1612
+ : "";
1613
+ const ticketTable =
1614
+ items.length === 0
1615
+ ? '<div class="empty-state">No tickets match this filter.</div>'
1616
+ : `<div class="table-wrap"><table class="data-table"><thead><tr><th>Title</th><th>Status</th><th>Story</th><th>Id</th><th></th></tr></thead><tbody>
1617
+ ${items
1618
+ .map((row) =>
1619
+ ticketRowHtml(
1620
+ /** @type {Record<string, unknown>} */ (row),
1621
+ storyTitles,
1622
+ ),
1623
+ )
1624
+ .join("")}
1625
+ </tbody></table></div>`;
1626
+ main.innerHTML = `
1627
+ <div class="panel">
1628
+ ${ticketFilterBanner}
1629
+ <div class="filter-bar">
1630
+ <label for="filterTicketStory">Filter by story</label>
1631
+ <select id="filterTicketStory">${storyOpts}</select>
1632
+ </div>
1633
+ <div class="panel-head">
1634
+ <h2>Tickets</h2>
1635
+ <button type="button" class="primary" id="btnNewTicket">New ticket</button>
1636
+ </div>
1637
+ ${ticketTable}
1638
+ </div>`;
1639
+ document
1640
+ .getElementById("filterTicketStory")
1641
+ ?.addEventListener("change", (ev) => {
1642
+ const val = /** @type {HTMLSelectElement} */ (ev.target).value;
1643
+ replaceAppRoute({ kind: "tickets", ticketFilterStory: val });
1644
+ });
1645
+ document.getElementById("btnNewTicket")?.addEventListener("click", () => {
1646
+ void pushAppRoute({ kind: "ticketNew" });
1647
+ });
1648
+ if (fs) {
1649
+ document
1650
+ .getElementById("btnTicketsCtxStory")
1651
+ ?.addEventListener("click", () => {
1652
+ navigateToStory(fs);
1653
+ });
1654
+ document
1655
+ .getElementById("btnTicketsClearStory")
1656
+ ?.addEventListener("click", () => {
1657
+ void pushAppRoute({ kind: "tickets", ticketFilterStory: "" });
1658
+ });
1659
+ }
1660
+ main.querySelectorAll(".btn-open-ticket").forEach((btn) => {
1661
+ btn.addEventListener("click", () => {
1662
+ const tid = /** @type {HTMLButtonElement} */ (btn).dataset.ticketId;
1663
+ if (tid) navigateToTicket(tid);
1664
+ });
1665
+ });
1666
+ main.querySelectorAll(".btn-nav-story").forEach((btn) => {
1667
+ btn.addEventListener("click", (ev) => {
1668
+ ev.stopPropagation();
1669
+ const sid = /** @type {HTMLButtonElement} */ (btn).dataset.storyId;
1670
+ if (sid) navigateToStory(sid);
1671
+ });
1672
+ });
1673
+ } catch (e) {
1674
+ main.innerHTML = `<div class="panel"><p class="muted">${escapeHtml(String(e))}</p></div>`;
1675
+ }
1676
+ }
1677
+
1678
+ async function renderTicketNew() {
1679
+ setPageTitle("New ticket");
1680
+ state.view = "ticketNew";
1681
+ setNavCurrent();
1682
+ const main = document.getElementById("main");
1683
+ if (!main) return;
1684
+ const [sj, authorJson] = await Promise.all([
1685
+ runCli(["story", "read"]).catch(() => ({ items: [] })),
1686
+ runCli(["repo", "commit-authors"]).catch(() => ({ items: [] })),
1687
+ ]);
1688
+ const stories = listFromJson(sj);
1689
+ const commitAuthors = commitAuthorsFromJson(authorJson);
1690
+ const authorOptsHtml = [
1691
+ `<option value="">Select commit author…</option>`,
1692
+ ...commitAuthors.map((a, i) => {
1693
+ const label = `${a.name || "(no name)"} <${a.email}>`;
1694
+ return `<option value="${String(i)}">${escapeHtml(label)}</option>`;
1695
+ }),
1696
+ ].join("");
1697
+ const storyOpts = [
1698
+ `<option value="">No story (unlinked)</option>`,
1699
+ ...stories.map(
1700
+ (s) =>
1701
+ `<option value="${escapeHtml(String(/** @type {{id:string}} */ (s).id))}">${escapeHtml(String(/** @type {{title:string}} */ (s).title))}</option>`,
1702
+ ),
1703
+ ].join("");
1704
+ main.innerHTML = `
1705
+ <div class="panel">
1706
+ <div class="back-link">
1707
+ <button type="button" class="ghost" id="btnBackTickets">← Tickets</button>
1708
+ </div>
1709
+ <div class="panel-head" style="border-bottom:none;padding-bottom:0;margin-bottom:0.5rem">
1710
+ <h2>Create ticket</h2>
1711
+ </div>
1712
+ <label for="newTicketStory">Story (optional)</label>
1713
+ <select id="newTicketStory">${storyOpts}</select>
1714
+ <label for="newTicketTitle">Title</label>
1715
+ <input type="text" id="newTicketTitle" />
1716
+ <label for="newTicketBody">Description</label>
1717
+ <textarea id="newTicketBody" rows="5"></textarea>
1718
+ <label for="newTicketStatus">Status</label>
1719
+ ${statusOptionsHtml("newTicketStatus", "todo")}
1720
+ <h3 style="margin-top:1.25rem">Assignee (optional)</h3>
1721
+ <label for="newTicketAssigneeLogin">GitHub login</label>
1722
+ <input type="text" id="newTicketAssigneeLogin" autocomplete="off" placeholder="e.g. octocat" />
1723
+ <label for="newTicketCommitAuthor">Pick from commit authors</label>
1724
+ <select id="newTicketCommitAuthor">${authorOptsHtml}</select>
1725
+ <label for="newTicketAssigneeName">Or enter name</label>
1726
+ <input type="text" id="newTicketAssigneeName" autocomplete="name" placeholder="Display name" />
1727
+ <label for="newTicketAssigneeEmail">And email</label>
1728
+ <input type="email" id="newTicketAssigneeEmail" autocomplete="email" placeholder="name@example.com" />
1729
+ <div class="row">
1730
+ <button type="button" class="ghost" id="btnNewTicketSuggestAssignee">Suggest login from name and email</button>
1731
+ </div>
1732
+ <div class="row">
1733
+ <button type="button" class="primary" id="btnCreateTicket">Create</button>
1734
+ </div>
1735
+ </div>`;
1736
+ wireTicketAssigneeControls("new", commitAuthors);
1737
+ document.getElementById("btnBackTickets")?.addEventListener("click", () => {
1738
+ window.history.back();
1739
+ });
1740
+ document
1741
+ .getElementById("btnCreateTicket")
1742
+ ?.addEventListener("click", async () => {
1743
+ const title = trimU(
1744
+ document.getElementById("newTicketTitle")?.value ?? "",
1745
+ );
1746
+ const body = document.getElementById("newTicketBody")?.value ?? "";
1747
+ const status =
1748
+ document.getElementById("newTicketStatus")?.value ?? "todo";
1749
+ const story = trimU(
1750
+ document.getElementById("newTicketStory")?.value ?? "",
1751
+ );
1752
+ if (!title) {
1753
+ toast("Title is required", true);
1754
+ return;
1755
+ }
1756
+ const argv = [
1757
+ "ticket",
1758
+ "create",
1759
+ "--title",
1760
+ title,
1761
+ "--body",
1762
+ body,
1763
+ "--status",
1764
+ status,
1765
+ ];
1766
+ if (story) argv.push("--story", story);
1767
+ const assigneeLogin = trimU(
1768
+ document.getElementById("newTicketAssigneeLogin")?.value ?? "",
1769
+ );
1770
+ if (assigneeLogin) argv.push("--assignee", assigneeLogin.toLowerCase());
1771
+ try {
1772
+ await runCli(argv);
1773
+ toast("Ticket created", false);
1774
+ void pushAppRoute({
1775
+ kind: "tickets",
1776
+ ticketFilterStory: story || "",
1777
+ });
1778
+ } catch (e) {
1779
+ toast(String(e), true);
1780
+ }
1781
+ });
1782
+ }
1783
+
1784
+ /**
1785
+ * @param {unknown[]} comments
1786
+ */
1787
+ function commentsHtml(comments) {
1788
+ if (!comments || comments.length === 0) {
1789
+ return '<p class="muted">No comments yet.</p>';
1790
+ }
1791
+ return comments
1792
+ .map((c) => {
1793
+ const r = /** @type {{body:string;createdAt:string;createdBy:string}} */ (
1794
+ c
1795
+ );
1796
+ return `<div class="comment">
1797
+ <div class="comment-meta">${escapeHtml(r.createdAt)} · ${escapeHtml(r.createdBy)}</div>
1798
+ <div>${escapeHtml(r.body).replace(/\n/g, "<br />")}</div>
1799
+ </div>`;
1800
+ })
1801
+ .join("");
1802
+ }
1803
+
1804
+ async function renderTicketEdit() {
1805
+ const id = state.ticketId;
1806
+ if (!id) {
1807
+ void pushAppRoute({ kind: "tickets", ticketFilterStory: "" });
1808
+ return;
1809
+ }
1810
+ state.view = "ticketEdit";
1811
+ setNavCurrent();
1812
+ const main = document.getElementById("main");
1813
+ if (!main) return;
1814
+ main.innerHTML = '<p class="muted">Loading…</p>';
1815
+ try {
1816
+ const [rowRaw, sj, authorJson] = await Promise.all([
1817
+ runCli(["ticket", "read", "--id", id]),
1818
+ runCli(["story", "read"]),
1819
+ runCli(["repo", "commit-authors"]).catch(() => ({ items: [] })),
1820
+ ]);
1821
+ const row = /** @type {Record<string, unknown>} */ (rowRaw);
1822
+ const commitAuthors = commitAuthorsFromJson(authorJson);
1823
+ const stories = listFromJson(sj);
1824
+ const curStory = row.storyId == null ? "" : String(row.storyId);
1825
+ let storyReadInner = '<span class="muted">No linked story</span>';
1826
+ /** @type {string} */
1827
+ let storyTitleBtn = "";
1828
+ /** @type {unknown | undefined} */
1829
+ let linkedStoryRow;
1830
+ if (curStory) {
1831
+ linkedStoryRow = stories.find(
1832
+ (s) => String(/** @type {{id:string}} */ (s).id) === curStory,
1833
+ );
1834
+ const storyLinkLabel = linkedStoryRow
1835
+ ? markdownInlineHtml(
1836
+ String(/** @type {{title:string}} */ (linkedStoryRow).title),
1837
+ )
1838
+ : escapeHtml(curStory);
1839
+ storyTitleBtn = `<button type="button" class="link-title issue-meta-link" id="ticketSidebarStoryLink"><span class="md-inline">${storyLinkLabel}</span></button>`;
1840
+ storyReadInner = linkedStoryRow
1841
+ ? `${storyTitleBtn}<span class="issue-meta-sep"> · </span>${idChip(curStory)}`
1842
+ : storyTitleBtn;
1843
+ }
1844
+ /** @type {string} */
1845
+ let storyEpicId = "";
1846
+ if (
1847
+ linkedStoryRow &&
1848
+ /** @type {{epicId?: unknown}} */ (linkedStoryRow).epicId != null &&
1849
+ String(/** @type {{epicId?: unknown}} */ (linkedStoryRow).epicId) !== ""
1850
+ ) {
1851
+ storyEpicId = String(
1852
+ /** @type {{epicId: string}} */ (linkedStoryRow).epicId,
1853
+ );
1854
+ }
1855
+ let epicReadInner = '<span class="muted">—</span>';
1856
+ if (storyEpicId) {
1857
+ try {
1858
+ const epicRow = /** @type {Record<string, unknown>} */ (
1859
+ await runCli(["epic", "read", "--id", storyEpicId])
1860
+ );
1861
+ const et = markdownInlineHtml(String(epicRow.title));
1862
+ const epicLink = `<button type="button" class="link-title issue-meta-link" id="ticketSidebarEpicLink"><span class="md-inline">${et}</span></button>`;
1863
+ epicReadInner = `${epicLink}<span class="issue-meta-sep"> · </span>${idChip(storyEpicId)}`;
1864
+ } catch {
1865
+ epicReadInner = `<button type="button" class="link-title issue-meta-link" id="ticketSidebarEpicLink">${escapeHtml(storyEpicId)}</button>`;
1866
+ }
1867
+ }
1868
+ const rawLabels = Array.isArray(row.labels) ? row.labels : [];
1869
+ const labelStrs = rawLabels.map((x) => String(x));
1870
+ const labelsInner =
1871
+ labelStrs.length === 0
1872
+ ? '<span class="muted">None</span>'
1873
+ : `<div class="label-pill-wrap">${labelStrs
1874
+ .map((lb) => `<span class="label-pill">${escapeHtml(lb)}</span>`)
1875
+ .join("")}</div>`;
1876
+ const storyOpts = [
1877
+ `<option value="">No story</option>`,
1878
+ ...stories.map((s) => {
1879
+ const sid = String(/** @type {{id:string}} */ (s).id);
1880
+ const sel = sid === curStory ? " selected" : "";
1881
+ return `<option value="${escapeHtml(sid)}"${sel}>${escapeHtml(String(/** @type {{title:string}} */ (s).title))}</option>`;
1882
+ }),
1883
+ ].join("");
1884
+ const comments = Array.isArray(row.comments) ? row.comments : [];
1885
+ const showForm = Boolean(state.ticketDetailForm);
1886
+ const curAssigneeRaw =
1887
+ row.assignee !== undefined && row.assignee !== null
1888
+ ? String(row.assignee).trim().toLowerCase()
1889
+ : "";
1890
+ const authorOptsHtml = [
1891
+ `<option value="">Select commit author…</option>`,
1892
+ ...commitAuthors.map((a, i) => {
1893
+ const label = `${a.name || "(no name)"} <${a.email}>`;
1894
+ return `<option value="${String(i)}">${escapeHtml(label)}</option>`;
1895
+ }),
1896
+ ].join("");
1897
+ setPageTitle(
1898
+ showForm ? "Edit ticket" : markdownToPlainTextForUiChrome(row.title),
1899
+ );
1900
+ const readBlock = `
1901
+ <div class="issue-detail-layout">
1902
+ <div class="issue-main">
1903
+ <div class="panel-head issue-panel-head">
1904
+ <div>
1905
+ <p class="muted issue-kicker" style="margin:0 0 0.25rem">Ticket</p>
1906
+ <h2 class="issue-title"><span class="md-inline">${markdownInlineHtml(row.title)}</span></h2>
1907
+ </div>
1908
+ <div class="panel-head-actions">
1909
+ <button type="button" class="btn-subtle" id="btnTicketHistory">History</button>
1910
+ <button type="button" class="btn-subtle" id="btnTicketEnterEdit">Edit</button>
1911
+ </div>
1912
+ </div>
1913
+ <div class="issue-body">
1914
+ ${readBodyHtml(row.body)}
1915
+ </div>
1916
+ </div>
1917
+ <aside class="issue-sidebar" aria-label="Ticket metadata">
1918
+ <div class="read-stack issue-meta-stack">
1919
+ ${readRowHtml("Ticket ID", idChip(id))}
1920
+ ${readRowHtml("Story", storyReadInner)}
1921
+ ${readRowHtml("Epic", epicReadInner)}
1922
+ ${readRowHtml("Status", badgeHtml(String(row.status)))}
1923
+ ${readRowHtml(
1924
+ "Assignee",
1925
+ curAssigneeRaw
1926
+ ? `<code>@${escapeHtml(curAssigneeRaw)}</code>`
1927
+ : '<span class="muted">Unassigned</span>',
1928
+ )}
1929
+ ${readRowHtml("Labels", labelsInner)}
1930
+ </div>
1931
+ </aside>
1932
+ </div>`;
1933
+ const formBlock = `
1934
+ <div class="issue-detail-layout">
1935
+ <div class="issue-main">
1936
+ <div class="panel-head issue-panel-head" style="border-bottom:none;padding-bottom:0;margin-bottom:0.5rem">
1937
+ <h2 class="issue-title">Edit ticket</h2>
1938
+ </div>
1939
+ <label for="editTicketTitle">Title</label>
1940
+ <input type="text" id="editTicketTitle" value="${escapeHtml(String(row.title))}" />
1941
+ <label for="editTicketBody">Description</label>
1942
+ <textarea id="editTicketBody" rows="8">${escapeHtml(String(row.body ?? ""))}</textarea>
1943
+ <div class="row">
1944
+ <button type="button" class="primary" id="btnSaveTicket">Save</button>
1945
+ <button type="button" class="ghost" id="btnTicketCancelEdit">Cancel</button>
1946
+ <button type="button" class="danger" id="btnDeleteTicket">Delete</button>
1947
+ </div>
1948
+ </div>
1949
+ <aside class="issue-sidebar" aria-label="Ticket fields">
1950
+ <div class="read-stack issue-meta-stack">
1951
+ ${readRowHtml("Ticket ID", idChip(id))}
1952
+ <div class="read-row">
1953
+ <div class="read-label">Story</div>
1954
+ <div class="read-value">
1955
+ ${
1956
+ curStory
1957
+ ? `<div class="issue-sidebar-story-nav">${storyTitleBtn}</div>`
1958
+ : ""
1959
+ }
1960
+ <select id="editTicketStory">${storyOpts}</select>
1961
+ </div>
1962
+ </div>
1963
+ ${readRowHtml("Epic", epicReadInner)}
1964
+ <div class="read-row">
1965
+ <div class="read-label">Status</div>
1966
+ <div class="read-value">${statusOptionsHtml("editTicketStatus", String(row.status))}</div>
1967
+ </div>
1968
+ <div class="read-row">
1969
+ <div class="read-label">Assignee</div>
1970
+ <div class="read-value">
1971
+ <label for="editTicketAssigneeLogin" class="muted" style="margin-top:0;font-size:0.75rem">GitHub login (synced to the issue assignee)</label>
1972
+ <input type="text" id="editTicketAssigneeLogin" autocomplete="off" placeholder="e.g. octocat" value="${escapeHtml(curAssigneeRaw)}" />
1973
+ <label for="editTicketCommitAuthor" style="margin-top:0.75rem">Pick from commit authors</label>
1974
+ <select id="editTicketCommitAuthor">${authorOptsHtml}</select>
1975
+ <label for="editTicketAssigneeName" style="margin-top:0.75rem">Or enter name</label>
1976
+ <input type="text" id="editTicketAssigneeName" autocomplete="name" placeholder="Display name" />
1977
+ <label for="editTicketAssigneeEmail">And email</label>
1978
+ <input type="email" id="editTicketAssigneeEmail" autocomplete="email" placeholder="name@example.com" />
1979
+ <div class="row" style="margin-top:0.5rem">
1980
+ <button type="button" class="ghost" id="btnEditTicketSuggestAssignee">Suggest login from name and email</button>
1981
+ </div>
1982
+ <p class="muted" style="font-size:0.8125rem;margin:0.5rem 0 0;line-height:1.45">Leave login empty and save to remove the assignee.</p>
1983
+ </div>
1984
+ </div>
1985
+ ${readRowHtml("Labels", labelsInner)}
1986
+ <p class="muted" style="font-size:0.8125rem;margin:0;line-height:1.45">Change labels with <code>hyper-pm ticket update</code> in the CLI.</p>
1987
+ </div>
1988
+ </aside>
1989
+ </div>`;
1990
+ const ticketTopBar = `
1991
+ <nav class="detail-page-top" aria-label="Ticket">
1992
+ <button type="button" class="ghost detail-back" id="btnBackTickets2">← Tickets</button>
1993
+ </nav>`;
1994
+ main.innerHTML = `
1995
+ <div class="panel">
1996
+ ${ticketTopBar}
1997
+ ${showForm ? formBlock : readBlock}
1998
+ </div>
1999
+ <div class="panel">
2000
+ <div class="panel-head">
2001
+ <h2>Comments</h2>
2002
+ </div>
2003
+ ${commentsHtml(comments)}
2004
+ <label for="newCommentBody">Add comment</label>
2005
+ <textarea id="newCommentBody" rows="3" placeholder="Write a comment…"></textarea>
2006
+ <div class="row">
2007
+ <button type="button" class="primary" id="btnAddComment">Post comment</button>
2008
+ </div>
2009
+ </div>`;
2010
+ if (showForm) {
2011
+ wireTicketAssigneeControls("edit", commitAuthors);
2012
+ }
2013
+ document
2014
+ .getElementById("btnBackTickets2")
2015
+ ?.addEventListener("click", () => {
2016
+ void pushAppRoute({
2017
+ kind: "tickets",
2018
+ ticketFilterStory: curStory || "",
2019
+ });
2020
+ });
2021
+ document
2022
+ .getElementById("ticketSidebarStoryLink")
2023
+ ?.addEventListener("click", () => {
2024
+ navigateToStory(curStory);
2025
+ });
2026
+ document
2027
+ .getElementById("ticketSidebarEpicLink")
2028
+ ?.addEventListener("click", () => {
2029
+ navigateToEpic(storyEpicId);
2030
+ });
2031
+ if (!showForm) {
2032
+ document
2033
+ .getElementById("btnTicketHistory")
2034
+ ?.addEventListener("click", () => {
2035
+ void openHistoryModal(id, "Ticket");
2036
+ });
2037
+ document
2038
+ .getElementById("btnTicketEnterEdit")
2039
+ ?.addEventListener("click", () => {
2040
+ void pushAppRoute({
2041
+ kind: "ticketEdit",
2042
+ ticketId: id,
2043
+ ticketForm: true,
2044
+ });
2045
+ });
2046
+ } else {
2047
+ document
2048
+ .getElementById("btnTicketCancelEdit")
2049
+ ?.addEventListener("click", () => {
2050
+ window.history.back();
2051
+ });
2052
+ document
2053
+ .getElementById("btnSaveTicket")
2054
+ ?.addEventListener("click", async () => {
2055
+ const title = trimU(
2056
+ document.getElementById("editTicketTitle")?.value ?? "",
2057
+ );
2058
+ const body = document.getElementById("editTicketBody")?.value ?? "";
2059
+ const status =
2060
+ document.getElementById("editTicketStatus")?.value ?? "todo";
2061
+ const storySel =
2062
+ document.getElementById("editTicketStory")?.value ?? "";
2063
+ if (!title) {
2064
+ toast("Title is required", true);
2065
+ return;
2066
+ }
2067
+ const prevTitle = trimU(String(row.title));
2068
+ const prevBody = String(row.body ?? "");
2069
+ const prevStatus = String(row.status);
2070
+ /** @type {string[]} */
2071
+ const argv = ["ticket", "update", "--id", id];
2072
+ if (title !== prevTitle) {
2073
+ argv.push("--title", title);
2074
+ }
2075
+ if (body !== prevBody) {
2076
+ argv.push("--body", body);
2077
+ }
2078
+ if (status !== prevStatus) {
2079
+ argv.push("--status", status);
2080
+ }
2081
+ if (storySel !== curStory) {
2082
+ if (storySel) {
2083
+ argv.push("--story", storySel);
2084
+ } else {
2085
+ argv.push("--unlink-story");
2086
+ }
2087
+ }
2088
+ const loginRaw = trimU(
2089
+ document.getElementById("editTicketAssigneeLogin")?.value ?? "",
2090
+ );
2091
+ const loginNorm = loginRaw ? loginRaw.toLowerCase() : "";
2092
+ if (loginNorm !== curAssigneeRaw) {
2093
+ if (loginNorm === "" && curAssigneeRaw !== "") {
2094
+ argv.push("--unassign");
2095
+ } else if (loginNorm !== "") {
2096
+ argv.push("--assignee", loginNorm);
2097
+ }
2098
+ }
2099
+ try {
2100
+ await runCli(argv);
2101
+ toast("Ticket saved", false);
2102
+ replaceAppRoute({
2103
+ kind: "ticketEdit",
2104
+ ticketId: id,
2105
+ ticketForm: false,
2106
+ });
2107
+ } catch (e) {
2108
+ toast(String(e), true);
2109
+ }
2110
+ });
2111
+ document
2112
+ .getElementById("btnDeleteTicket")
2113
+ ?.addEventListener("click", async () => {
2114
+ if (!window.confirm(`Delete ticket ${id}?`)) return;
2115
+ try {
2116
+ await runCli(["ticket", "delete", "--id", id]);
2117
+ toast("Ticket deleted", false);
2118
+ void pushAppRoute({ kind: "tickets", ticketFilterStory: "" });
2119
+ } catch (e) {
2120
+ toast(String(e), true);
2121
+ }
2122
+ });
2123
+ }
2124
+ document
2125
+ .getElementById("btnAddComment")
2126
+ ?.addEventListener("click", async () => {
2127
+ const text =
2128
+ document.getElementById("newCommentBody")?.value?.trim() ?? "";
2129
+ if (!text) {
2130
+ toast("Comment cannot be empty", true);
2131
+ return;
2132
+ }
2133
+ try {
2134
+ await runCli(["ticket", "comment", "--id", id, "--body", text]);
2135
+ toast("Comment added", false);
2136
+ void renderTicketEdit();
2137
+ } catch (e) {
2138
+ toast(String(e), true);
2139
+ }
2140
+ });
2141
+ } catch (e) {
2142
+ main.innerHTML = `<div class="panel"><p class="muted">${escapeHtml(String(e))}</p><button type="button" class="ghost" id="btnTicketErrBack">← Tickets</button></div>`;
2143
+ document
2144
+ .getElementById("btnTicketErrBack")
2145
+ ?.addEventListener("click", () => {
2146
+ void pushAppRoute({ kind: "tickets", ticketFilterStory: "" });
2147
+ });
2148
+ }
2149
+ }
2150
+
2151
+ function renderTools() {
2152
+ setPageTitle("Tools");
2153
+ state.view = "tools";
2154
+ setNavCurrent();
2155
+ const main = document.getElementById("main");
2156
+ if (!main) return;
2157
+ main.innerHTML = `
2158
+ <div class="panel panel-tools">
2159
+ <div class="panel-head">
2160
+ <h2>Initialize repository</h2>
2161
+ </div>
2162
+ <p class="muted">Creates the hyper-pm data branch and config if missing.</p>
2163
+ <label><input type="checkbox" id="initSyncOff" checked /> Start with sync off</label>
2164
+ <div class="row">
2165
+ <button type="button" class="primary" id="btnInit">Run init</button>
2166
+ </div>
2167
+ </div>
2168
+ <div class="panel panel-tools">
2169
+ <div class="panel-head">
2170
+ <h2>Sync with GitHub</h2>
2171
+ </div>
2172
+ <label><input type="checkbox" id="syncNoGithub" /> Skip GitHub network (<code>--no-github</code>)</label>
2173
+ <div class="row">
2174
+ <button type="button" class="primary" id="btnSync">Run sync</button>
2175
+ </div>
2176
+ </div>
2177
+ <div class="panel panel-tools">
2178
+ <div class="panel-head">
2179
+ <h2>Audit &amp; doctor</h2>
2180
+ </div>
2181
+ <label for="auditLimit">Audit limit</label>
2182
+ <input type="text" id="auditLimit" placeholder="e.g. 50" />
2183
+ <label for="auditType">Event type</label>
2184
+ <input type="text" id="auditType" placeholder="TicketUpdated" />
2185
+ <div class="row">
2186
+ <button type="button" id="btnAudit">Run audit</button>
2187
+ <button type="button" id="btnDoctor">Run doctor</button>
2188
+ </div>
2189
+ </div>`;
2190
+ document.getElementById("btnInit")?.addEventListener("click", async () => {
2191
+ const syncOff = document.getElementById("initSyncOff")?.checked;
2192
+ const argv = syncOff ? ["--sync", "off", "init"] : ["init"];
2193
+ try {
2194
+ await runCli(argv);
2195
+ toast("Init completed", false);
2196
+ } catch (e) {
2197
+ toast(String(e), true);
2198
+ }
2199
+ });
2200
+ document.getElementById("btnSync")?.addEventListener("click", async () => {
2201
+ const argv = ["sync"];
2202
+ if (document.getElementById("syncNoGithub")?.checked)
2203
+ argv.push("--no-github");
2204
+ try {
2205
+ await runCli(argv);
2206
+ toast("Sync finished", false);
2207
+ } catch (e) {
2208
+ toast(String(e), true);
2209
+ }
2210
+ });
2211
+ document.getElementById("btnAudit")?.addEventListener("click", async () => {
2212
+ const argv = ["audit"];
2213
+ const lim = trimU(document.getElementById("auditLimit")?.value ?? "");
2214
+ const typ = trimU(document.getElementById("auditType")?.value ?? "");
2215
+ if (lim) argv.push("--limit", lim);
2216
+ if (typ) argv.push("--type", typ);
2217
+ try {
2218
+ const j = await runCli(argv);
2219
+ window.alert(JSON.stringify(j, null, 2));
2220
+ } catch (e) {
2221
+ toast(String(e), true);
2222
+ }
2223
+ });
2224
+ document.getElementById("btnDoctor")?.addEventListener("click", async () => {
2225
+ try {
2226
+ const j = await runCli(["doctor"]);
2227
+ toast(JSON.stringify(j), false);
2228
+ } catch (e) {
2229
+ toast(String(e), true);
2230
+ }
2231
+ });
2232
+ }
2233
+
2234
+ function renderAdvanced() {
2235
+ setPageTitle("Advanced CLI");
2236
+ state.view = "advanced";
2237
+ setNavCurrent();
2238
+ const main = document.getElementById("main");
2239
+ if (!main) return;
2240
+ main.innerHTML = `
2241
+ <div class="panel">
2242
+ <div class="panel-head">
2243
+ <h2>Raw argv</h2>
2244
+ </div>
2245
+ <p class="muted">JSON array of CLI tokens after global flags. Repo, temp dir, and format are still enforced by the server.</p>
2246
+ <textarea id="advArgv" rows="6" placeholder='["ticket", "read", "--id", "…"]'></textarea>
2247
+ <div class="row">
2248
+ <button type="button" class="primary" id="btnAdvRun">Run</button>
2249
+ </div>
2250
+ <h3>Output</h3>
2251
+ <pre id="advOut" class="pre-out muted"></pre>
2252
+ </div>`;
2253
+ document.getElementById("btnAdvRun")?.addEventListener("click", async () => {
2254
+ const raw = document.getElementById("advArgv")?.value ?? "";
2255
+ const out = document.getElementById("advOut");
2256
+ let argv;
2257
+ try {
2258
+ argv = JSON.parse(raw);
2259
+ } catch (e) {
2260
+ if (out) out.textContent = String(e);
2261
+ return;
2262
+ }
2263
+ if (!Array.isArray(argv) || !argv.every((x) => typeof x === "string")) {
2264
+ if (out) out.textContent = "Expected JSON array of strings.";
2265
+ return;
2266
+ }
2267
+ const r = await runApi({ argv });
2268
+ if (out) out.textContent = JSON.stringify(r.body, null, 2);
2269
+ });
2270
+ }
2271
+
2272
+ function refreshCurrentView() {
2273
+ const r = parseHash();
2274
+ syncStateFromRoute(r);
2275
+ const out = renderForRoute(r);
2276
+ setNavCurrent();
2277
+ return out;
2278
+ }
2279
+
2280
+ function wireNav() {
2281
+ document.querySelectorAll(".nav-btn[data-nav]").forEach((btn) => {
2282
+ btn.addEventListener("click", () => {
2283
+ const v = /** @type {HTMLButtonElement} */ (btn).dataset.nav;
2284
+ if (!v) return;
2285
+ if (v === "dashboard") void pushAppRoute({ kind: "dashboard" });
2286
+ else if (v === "epics") void pushAppRoute({ kind: "epics" });
2287
+ else if (v === "stories") {
2288
+ void pushAppRoute({ kind: "stories", storyFilterEpic: "" });
2289
+ } else if (v === "tickets") {
2290
+ void pushAppRoute({ kind: "tickets", ticketFilterStory: "" });
2291
+ } else if (v === "tools") void pushAppRoute({ kind: "tools" });
2292
+ else if (v === "advanced") void pushAppRoute({ kind: "advanced" });
2293
+ });
2294
+ });
2295
+ document.getElementById("btnRefresh")?.addEventListener("click", () => {
2296
+ void loadHealth();
2297
+ void refreshCurrentView();
2298
+ });
2299
+ document.getElementById("saveToken")?.addEventListener("click", () => {
2300
+ const v = document.getElementById("bearer")?.value ?? "";
2301
+ if (v.trim()) localStorage.setItem(TOKEN_KEY, v.trim());
2302
+ else localStorage.removeItem(TOKEN_KEY);
2303
+ toast("Token saved", false);
2304
+ });
2305
+ }
2306
+
2307
+ window.addEventListener("DOMContentLoaded", async () => {
2308
+ const existing = localStorage.getItem(TOKEN_KEY);
2309
+ const bearerInput = document.getElementById("bearer");
2310
+ if (bearerInput && existing) bearerInput.value = existing;
2311
+ wireHistoryNavigation();
2312
+ wireNav();
2313
+ await loadHealth();
2314
+ const h = window.location.hash;
2315
+ if (!h || h === "#") {
2316
+ history.replaceState(null, "", "#/");
2317
+ syncStateFromRoute({ kind: "dashboard" });
2318
+ setNavCurrent();
2319
+ await renderDashboard();
2320
+ } else {
2321
+ const initial = parseHash();
2322
+ syncStateFromRoute(initial);
2323
+ setNavCurrent();
2324
+ await renderForRoute(initial);
2325
+ }
2326
+ });