Add the live ticket page and epic index renderer

This commit is contained in:
Gabriel Brown
2026-09-22 14:54:05 -04:00
parent dfc009e422
commit fd24b69039
4 changed files with 513 additions and 0 deletions
@@ -0,0 +1,308 @@
// Renderer for ticket and epic pages. Copied into .claude/docs/epics/_site/ by ticket-page.
// A page shell names its data file in <body data-src>. This script loads that file with a
// <script> tag (file:// pages cannot fetch), re-loads it every two seconds, and redraws
// only when the data changed. The data shape is defined in scripts/ticket-data.ts.
// @ts-check
/** @typedef {import('../../scripts/ticket-data').Ticket} Ticket */
/** @typedef {import('../../scripts/ticket-data').Epic} Epic */
(() => {
const PAGE = document.body.dataset.page;
const SRC = document.body.dataset.src || '';
const GLOBAL = PAGE === 'epic' ? 'EPIC' : 'TICKET';
// ── DOM helpers ──────────────────────────────────────────────────────────
/** h('div.card#id', {attrs}, ...children). Strings become text; html() parses markup. */
function h(sel, attrs, ...kids) {
const [, tag, rest] = /** @type {RegExpMatchArray} */ (sel.match(/^([a-z0-9]*)(.*)$/i));
const node = document.createElement(tag || 'div');
(rest.match(/[.#][^.#]+/g) || []).forEach(p => p[0] === '.' ? node.classList.add(p.slice(1)) : (node.id = p.slice(1)));
if (attrs && (typeof attrs !== 'object' || attrs instanceof Node || Array.isArray(attrs))) { kids.unshift(attrs); attrs = null; }
for (const [k, v] of Object.entries(attrs || {})) {
if (v == null || v === false) continue;
if (k.startsWith('on')) node.addEventListener(k.slice(2), v); else node.setAttribute(k, v === true ? '' : String(v));
}
kids.flat(Infinity).forEach(k => k != null && k !== false && node.append(k instanceof Node ? k : document.createTextNode(String(k))));
return node;
}
/** Markup from the data file: sanitized Jira HTML at import, or the agent's own notes. */
const html = s => { const t = document.createElement('template'); t.innerHTML = s || ''; return t.content; };
// Nodes that must survive a redraw (a playing video, a loaded iframe) are built once per key.
const kept = new Map();
const keep = (key, make) => { if (!kept.has(key)) kept.set(key, make()); return kept.get(key); };
const clock = iso => iso ? new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
const day = iso => iso ? new Date(iso).toLocaleDateString([], { month: 'short', day: 'numeric' }) : '';
const mmss = s => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
const size = b => b > 1e6 ? `${(b / 1e6).toFixed(1)} MB` : `${Math.max(1, Math.round(b / 1e3))} KB`;
const pct = ([a, b]) => b ? Math.round(100 * a / b) : 0;
const bar = pair => h('div.bar', h('i', { style: `width:${pct(pair)}%` }));
const mark = st => h('span.mark' + (st === 'done' ? '.done' : st === 'running' ? '.run' : ''), { title: st });
const pill = (text, tone) => h('span.pill' + (tone ? '.' + tone : ''), text);
/** The viewer's own review tick, kept in this browser only and separate from the agent's done mark. */
function seen(scope, id) {
const key = `seen:${scope}:${id}`;
let on = false; try { on = localStorage.getItem(key) === '1'; } catch {}
return h('label.seen', { title: 'Your own review tick, stored in this browser' },
h('input', { type: 'checkbox', checked: on || null, onchange: e => { try { localStorage.setItem(key, e.target.checked ? '1' : '0'); } catch {} } }), 'reviewed');
}
// ── Loading and live reload ──────────────────────────────────────────────
let last = '', loadedAt = 0, failed = false;
function load() {
const s = document.createElement('script');
s.src = `${SRC}?t=${Date.now()}`;
s.onload = () => {
s.remove(); failed = false; loadedAt = Date.now();
const next = JSON.stringify(window[GLOBAL]);
if (next !== last) { last = next; draw(); } else liveNote();
};
s.onerror = () => { s.remove(); failed = true; if (!last) fatal(`Could not load ${SRC}. Run ticket-page check for this ticket.`); else liveNote(); };
document.head.append(s);
}
function fatal(msg) { document.getElementById('app')?.replaceChildren(h('p.error', msg)); }
function liveNote() {
const el = document.querySelector('.live');
if (!el) return;
el.classList.toggle('stale', failed);
el.textContent = failed ? 'Live: last reload failed, showing the previous version' : `Live, checked ${clock(new Date(loadedAt).toISOString())}`;
}
function draw() {
const data = window[GLOBAL];
const y = scrollY;
try {
document.getElementById('app')?.replaceChildren(PAGE === 'epic' ? epicPage(data) : ticketPage(data));
} catch (err) { fatal('The page could not render this data: ' + err.message); console.error(err); }
liveNote();
scrollTo(0, y);
}
addEventListener('hashchange', () => last && draw());
// ── Ticket page ──────────────────────────────────────────────────────────
const TABS = ['Ticket', 'Plan', 'Progress', 'Proof', 'Review'];
const route = () => { const [tab, pick] = location.hash.slice(1).split('/'); return { tab: TABS.includes(tab) ? tab : null, pick }; };
/** @param {Ticket} t */
function phase(t) {
const pl = t.plan;
if (t.review.preMr?.verdict === 'Ready to Open MR') return ['Ready to Open MR', 'ok'];
if (!pl) return ['Not planned', ''];
if (!pl.approvedAt) return ['Plan ready for review', 'warn'];
const run = pl.steps.find(s => s.status === 'running');
if (run) return [`Building step ${run.n}`, 'run'];
if (pl.steps.every(s => s.status === 'done')) return [t.review.preMr ? `pre-mr-review: ${t.review.preMr.verdict}` : 'Verifying', 'run'];
return ['Building', 'run'];
}
/** @param {Ticket} t */
function ticketPage(t) {
const r = route();
const tab = r.tab || (t.plan ? (t.plan.approvedAt ? 'Progress' : 'Plan') : 'Ticket');
const pl = t.plan;
const count = xs => [xs.filter(x => x.status === 'done').length, xs.length];
const crit = pl ? count(pl.criteria) : [0, 0], steps = pl ? count(pl.steps) : [0, 0];
const [phaseText, tone] = phase(t);
const openFindings = t.review.findings.filter(f => f.status === 'open').length;
const badge = { Plan: pl && !pl.approvedAt ? 'review' : null, Progress: pl ? `${steps[0]}/${steps[1]}` : null, Proof: t.proof.length || null,
Review: t.review.preMr?.verdict === 'Ready to Open MR' ? 'Ready' : openFindings || null };
const header = h('div.top', h('div.wrap',
h('div.head',
h('div',
h('div.crumbs', t.epic ? [h('a', { href: '../index.html' }, `${t.epic.key} ${t.epic.title}`), ' / '] : null, t.key),
h('h1', t.title),
h('div.meta', pill(phaseText, tone), h('span', [t.type, t.estimate].filter(Boolean).join(', ')),
t.branch ? h('span', 'Branch ', h('code', t.branch)) : null, h('a', { href: t.jira, target: '_blank', rel: 'noreferrer' }, 'Open in Jira'), h('span.live'))),
pl ? h('div.meters',
h('div.row', h('span', 'Acceptance criteria'), h('b', `${crit[0]} of ${crit[1]}`), bar(crit)),
h('div.row', h('span', 'Steps'), h('b', `${steps[0]} of ${steps[1]}`), bar(steps))) : h('div')),
h('nav.tabs', TABS.map(x => h('a', { href: '#' + x, 'aria-current': x === tab ? 'page' : null }, x, badge[x] ? h('span.pill.n', badge[x]) : null)))));
return h('div', header, h('main.wrap', TICKET_TABS[tab](t, r.pick)));
}
function videoBlock(a) {
return keep('video:' + a.path, () => {
const player = /** @type {HTMLVideoElement} */ (h('video', { src: a.path, poster: a.frames?.[0], controls: true, preload: 'metadata' }));
const segs = a.transcript || [];
const rows = segs.map(seg => h('button', { onclick: () => { player.currentTime = seg.t; player.play().catch(() => {}); } }, h('span.t', mmss(seg.t)), h('span', seg.text)));
const follow = () => {
let i = segs.findIndex(s => s.t > player.currentTime) - 1;
if (i < -1) i = segs.length - 1;
rows.forEach((row, j) => row.classList.toggle('now', j === i));
};
player.addEventListener('timeupdate', follow);
player.addEventListener('seeked', follow);
const frames = a.frames?.length ? h('div.frames', a.frames.map(f => h('a', { href: f, target: '_blank' }, h('img', { src: f, alt: 'Frame', loading: 'lazy' })))) : null;
return h('div.card', h('h2', a.name),
h('div.video', h('div', player, frames), segs.length ? h('div.transcript', rows) : h('p.muted', 'No transcript. Transcribe it and run ticket-page scan.')),
segs.length ? h('p.muted', { style: 'margin-top:8px;font-size:.85em' }, 'Click a transcript line to jump there.') : null);
});
}
function artboard(m) {
return keep('mock:' + m.id + ':' + m.html.length + ':' + m.html.slice(0, 64), () => {
const f = /** @type {HTMLIFrameElement} */ (h('iframe.artboard', { title: m.title, sandbox: '' }));
f.srcdoc = m.html;
return f;
});
}
/** @param {Ticket} t @param {import('../../scripts/ticket-data').Proof} p */
function compare(t, p) {
const m = p.mock && t.plan?.mocks.find(x => x.id === p.mock);
const evidence = p.kind === 'text' ? h('pre.textproof', p.text || '') : p.kind === 'test' ? h('pre.textproof', 'Passing test: ' + p.test) : h('a', { href: p.file, target: '_blank' }, h('img.shot', { src: p.file, alt: p.file }));
return h('div.compare',
h('figure', h('figcaption', 'Mock, drawn at plan time'), m ? artboard(m) : h('div.placeholder', 'No mock for this one.')),
h('figure', h('figcaption', 'Proof, captured from the build'), evidence));
}
const pending = m => h('div.compare',
h('figure', h('figcaption', 'Mock, drawn at plan time'), artboard(m)),
h('figure', h('figcaption', 'Proof, captured from the build'), h('div.placeholder', 'Not captured yet. It appears here when the proof is recorded.')));
function timeline(events) {
if (!events.length) return h('p.muted', 'Nothing yet.');
let lastDay = '';
return h('ul.timeline', events.slice().reverse().map(e => {
const d = day(e.at), label = d !== lastDay ? d : ''; lastDay = d;
return h('li', h('time', { title: new Date(e.at).toLocaleString() }, label ? h('div', label) : null, clock(e.at)), h('span.dot.' + e.kind), h('span', e.text, e.commit ? [' ', h('span.sha', e.commit)] : null));
}));
}
const TICKET_TABS = {
/** @param {Ticket} t */
Ticket: t => {
const media = t.attachments.filter(a => a.kind === 'video' || a.kind === 'audio');
const images = t.attachments.filter(a => a.kind === 'image');
const files = t.attachments.filter(a => a.kind === 'file');
return h('div.stack',
h('div.grid2',
h('div.stack', t.source.sections.length ? t.source.sections.map(s => h('div.card', h('h2', s.title, s.origin === 'note' ? h('span.note-tag', 'notes') : null), h('div.jira', html(s.html))))
: h('div.card', h('p.muted', 'The ticket has no written content.'))),
h('div.stack',
h('div.card', h('h2', 'Details'), h('table.kv', t.source.fields.map(([k, v]) => h('tr', h('td', k), h('td', v))))),
t.links.length ? h('div.card', h('h2', 'Linked issues'), h('ul', t.links.map(l => h('li', l.type, ' ', h('code', l.key), ' ', l.title)))) : null,
files.length ? h('div.card', h('h2', 'Files'), h('ul', files.map(f => h('li', h('a', { href: f.path }, f.name), h('span.muted', ' ' + size(f.bytes)))))) : null)),
media.map(videoBlock),
images.length ? h('div.card', h('h2', 'Images'), h('div.images', images.map(i => h('a', { href: i.path, target: '_blank' }, h('img', { src: i.path, alt: i.name, loading: 'lazy' }))))) : null);
},
/** @param {Ticket} t */
Plan: t => {
const pl = t.plan;
if (!pl) return h('p.empty', 'No plan yet.');
return h('div.grid2',
h('div.stack',
h('div.card', h('h2', 'Acceptance criteria'), pl.criteria.map(c => h('div.check', mark(c.status),
h('div.body', h('div', h('b', c.id + ' '), c.text), h('div.sub', c.steps.length ? 'Steps ' + c.steps.join(', ') : 'No step named')), seen(t.key, c.id)))),
h('div.card', h('h2', 'Approach'), h('div.jira', html(pl.approach))),
pl.mocks.length ? h('div.card', h('h2', 'Mocks'), pl.mocks.map(m => h('div', h('h3', m.title), artboard(m), m.note ? h('p.muted', { style: 'font-size:.85em;margin-top:6px' }, m.note) : null))) : null),
h('div.stack',
h('div.card', h('h2', 'Open questions'), pl.questions.length ? h('div.callout', h('ul', pl.questions.map(q => h('li', html(q))))) : h('p.muted', 'None.')),
pl.decisions.length ? h('div.card', h('h2', 'Decisions'), h('table', pl.decisions.map(([q, c, why]) => h('tr', h('td', h('b', q), h('div.muted', { style: 'font-size:.9em' }, why)), h('td', c))))) : null,
h('div.card', h('h2', 'Steps'), h('ol', { style: 'padding-left:22px;margin:0' }, pl.steps.map(s => h('li', s.text, s.kind !== 'code' ? [' ', pill(s.kind)] : null, s.tdd ? [' ', pill('test first')] : null)))),
h('div.card', h('h2', 'Risks and edge cases'), pl.risks.length ? h('ul', pl.risks.map(r => h('li', h('b', r.risk + '. '), r.handling, r.fromTicket ? [' ', pill('ticket')] : null))) : h('p.muted', 'None listed.')),
h('div.card', h('h2', 'Test plan'), pl.tests.length ? h('ul', pl.tests.map(x => h('li', pill(x.kind), ' ', x.text, x.fromTicket ? [' ', pill('ticket')] : null))) : h('p.muted', 'None listed.'))));
},
/** @param {Ticket} t @param {string} pick */
Progress: (t, pick) => {
const pl = t.plan;
if (!pl) return h('p.empty', 'No plan yet.');
const count = xs => [xs.filter(x => x.status === 'done').length, xs.length];
const [cd, ct] = count(pl.criteria), [sd, st] = count(pl.steps);
const open = t.review.findings.filter(f => f.status === 'open').length;
const review = t.review.preMr ? t.review.preMr.verdict : t.review.findings.length ? `${open} open` : 'Not yet';
const tiles = h('div.tiles', [[`${cd}/${ct}`, 'Criteria met'], [`${sd}/${st}`, 'Steps committed'], [String(t.proof.length), 'Proof captured'], [review, 'Review']]
.map(([n, l]) => h('div.tile', h('div.n', n), h('div.l', l))));
const step = n => pl.steps.find(s => s.n === n);
const c = pl.criteria.find(x => x.id === pick) || pl.criteria.find(x => x.status !== 'done') || pl.criteria[0];
const spine = h('div.spine', pl.criteria.map(x => h('button', { 'aria-current': x === c ? 'true' : null, onclick: () => { location.hash = `Progress/${x.id}`; } },
mark(x.status), h('span.id', x.id),
h('span', x.text, h('div.dots', { title: 'Steps behind this criterion' }, x.steps.map(n => h('i' + (step(n)?.status === 'done' ? '.on' : step(n)?.status === 'running' ? '.run' : ''))))))));
let detail = h('div.card', h('p.muted', 'The plan has no acceptance criteria.'));
if (c) {
const proofs = t.proof.filter(p => p.proves.includes(c.id));
const risks = pl.risks.filter(r => r.criteria?.includes(c.id));
const tests = pl.tests.filter(x => x.criteria?.includes(c.id));
const mocks = pl.mocks.filter(m => m.criteria.includes(c.id) && !proofs.some(p => p.mock === m.id));
detail = h('div.card',
h('div', { style: 'display:flex;justify-content:space-between;gap:12px;align-items:baseline' }, h('h2', c.id, ' ', c.status === 'done' ? pill('met ' + clock(c.doneAt), 'ok') : pill('open')), seen(t.key, c.id)),
h('p', c.text), c.evidence ? h('p.muted', c.evidence) : null,
h('dl.trace',
h('dt', 'Steps'), h('dd', c.steps.length ? c.steps.map(n => { const s = step(n); return s && h('div', { style: 'display:flex;gap:8px;margin-bottom:4px' }, mark(s.status), h('span', `${s.n}. ${s.text} `, s.commits.map(x => h('span.sha', x + ' ')))); }) : h('span.muted', 'None named')),
h('dt', 'Tests'), h('dd', tests.length ? tests.map(x => h('div', pill(x.kind), ' ', x.text)) : h('span.muted', 'None tied to this one')),
h('dt', 'Risks'), h('dd', risks.length ? risks.map(r => h('div', h('b', r.risk + '. '), r.handling)) : h('span.muted', 'None tied to this one'))),
proofs.map(p => h('div', { style: 'margin-bottom:14px' }, h('h3', p.caption), compare(t, p))),
mocks.map(m => h('div', h('h3', m.title), pending(m))),
!proofs.length && !mocks.length ? h('p.muted', 'No proof recorded for this criterion yet.') : null);
}
const steps = h('div.card', h('h2', 'Steps'), pl.steps.map(s => h('div.check', mark(s.status),
h('div.body', h('div', h('b', s.n + '. '), s.text, s.kind !== 'code' ? [' ', pill(s.kind)] : null),
h('div.sub', s.status === 'done' ? [s.commits.map(x => h('span.sha', x + ' ')), 'at ', clock(s.doneAt)] : s.status === 'running' ? 'In progress' : 'Not started')))));
return h('div', tiles, h('div.split', spine, detail), h('div.grid2', steps, h('div.card', h('h2', 'Live log'), timeline(t.events))));
},
/** @param {Ticket} t */
Proof: t => {
const mocks = t.plan?.mocks || [];
const waiting = mocks.filter(m => !t.proof.some(p => p.mock === m.id));
if (!t.proof.length && !waiting.length) return h('p.empty', 'No proof yet.');
return h('div.stack',
t.proof.map(p => h('div.card', h('h2', p.caption),
h('p.muted', 'Proves ', p.proves.join(', '), '. ', p.file ? ['File ', h('code', p.file), ', for you to attach in Jira.'] : 'Named test.'), compare(t, p))),
waiting.map(m => h('div.card', h('h2', m.title), pending(m))));
},
/** @param {Ticket} t */
Review: t => {
const R = t.review;
return h('div.grid2',
h('div.card', h('h2', 'Code review'), R.findings.length ? R.findings.map(f => h('div.check', mark(f.status === 'open' ? 'running' : 'done'),
h('div.body', h('div', pill(f.severity, f.severity === 'CONFIRMED' ? 'warn' : ''), ' ', h('b', f.id + ' '), f.text),
h('div.sub', f.where ? [h('code', f.where), ' '] : null, f.status === 'fixed' ? ['Fixed in ', h('span.sha', f.commit)] : f.status === 'rejected' ? 'Rejected: ' + f.reason : 'Open'))))
: h('p.muted', 'No reviewer has run yet.')),
h('div.stack',
h('div.card', h('h2', 'pre-mr-review'), R.preMr ? [h('p', pill(R.preMr.verdict, R.preMr.verdict === 'Ready to Open MR' ? 'ok' : 'warn')),
h('p', R.preMr.recommendation ? ['Recommendation: ', h('b', R.preMr.recommendation), '. '] : null, `Run ${R.preMr.runs} time${R.preMr.runs === 1 ? '' : 's'}`, R.preMr.head ? [' at ', h('span.sha', R.preMr.head)] : null, '.')]
: h('p.muted', 'Not run yet. You run it from your work account.')),
h('div.card', h('h2', 'Jira fields'), t.jiraFields.length ? h('ul', t.jiraFields.map(f => h('li', f.name, h('span.muted', ' ' + clock(f.at))))) : h('p.muted', 'Filled after the verdict.')),
h('div.card', h('h2', 'MR description'), t.mr ? h('p', h('code', 'mr.md'), ' written ', day(t.mr.at), ' ', clock(t.mr.at), '.') : h('p.muted', 'Written once the verdict is Ready to Open MR.'))));
},
};
// ── Epic page ────────────────────────────────────────────────────────────
const STATUS = { todo: ['To do', ''], planned: ['Planned', 'warn'], building: ['Building', 'run'], verifying: ['Verifying', 'run'], ready: ['Ready for MR', 'ok'], done: ['Done', 'ok'] };
/** @param {Epic} e */
function epicPage(e) {
document.body.classList.add('epic');
const byKey = Object.fromEntries(e.stories.map((s, i) => [s.key, { ...s, n: i + 1 }]));
const done = e.stories.filter(s => s.status === 'done');
const hours = e.stories.reduce((a, s) => a + (s.hours || 0), 0), doneH = done.reduce((a, s) => a + (s.hours || 0), 0);
const ready = s => s.status === 'todo' && s.after.every(k => byKey[k]?.status === 'done');
const next = e.stories.find(s => ['planned', 'building', 'verifying', 'ready'].includes(s.status)) || e.stories.find(ready);
return h('div',
h('div.top', h('div.wrap', h('div.head', h('div',
h('div.crumbs', e.key), h('h1', e.title),
h('div.meta', h('span', `${done.length} of ${e.stories.length} stories done`), hours ? h('span', `${doneH} of ${hours}h`) : null,
h('a', { href: e.jira, target: '_blank', rel: 'noreferrer' }, 'Open in Jira'), h('span.live'))),
h('div.meters', h('div.row', h('span', 'Stories done'), h('b', `${done.length} of ${e.stories.length}`), bar([done.length, e.stories.length])))))),
h('main.wrap',
e.why.length ? h('div.card.why', h('b', 'Build order, and why it is this order'), h('ol', e.why.map(w => h('li', w)))) : null,
e.stories.length ? h('div.seq', e.stories.map((s, i) => {
const [label, tone] = STATUS[s.status] || [s.status, ''];
const p = s.progress;
return h('div.row.' + s.status + (s === next ? '.next' : ''),
h('div.n', i + 1),
h('div.t', h('a', { href: s.page ? `${s.key}/index.html` : null, title: s.page ? 'Open the ticket page' : 'No ticket page yet' }, s.title), ' ', h('span.k', s.key),
s.after.length ? h('div.after', 'After ', s.after.map(k => '#' + (byKey[k]?.n ?? '?')).join(', ')) : null),
h('div.prog', s.status === 'todo' || !p ? h('span', ready(s) ? 'Unblocked, ready to start' : s.after.length ? 'Waiting on earlier stories' : '') : [h('span', `Criteria ${p.criteria.join('/')} · Steps ${p.steps.join('/')}`), bar(p.steps)]),
h('div.meta', pill(s === next && s.status === 'todo' ? 'Up next' : label, s === next && s.status === 'todo' ? 'run' : tone),
h('span', [s.hours ? s.hours + 'h' : '', s.risk ? h('span.risk-' + s.risk, s.risk) : ''].filter(Boolean).flatMap((x, j) => j ? [' · ', x] : [x]))));
})) : h('p.empty', 'No stories yet.'),
e.closed.length ? h('p.muted', { style: 'margin-top:18px;font-size:.9em' }, 'Closed: ', e.closed.map(c => `${c.key} ${c.title}`).join(', '), '.') : null));
}
load();
setInterval(load, 2000);
})();