From d105eca610b0e737c3313e4978d6a917b4f55d10 Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Sat, 4 Jul 2026 05:31:42 +0000 Subject: feat(web): add Stories tab -- Kanban board, epic swimlanes, story-detail DAG (Phase 9a) First UI work on top of the harness's 8 backend phases -- pure web/* frontend, no backend changes, keeping the existing embedded vanilla-JS convention (no build step, no framework). - Kanban board: columns spanning the full story lifecycle (DISCOVERY/FRAMING -> BACKLOG -> PRIORITIZED -> IN_PROGRESS -> VALIDATING/REVIEW_READY -> DONE), cards showing priority/acceptance-criteria count/eval-verdict progress. Drag-and-drop reorders priority within a column (persisted via a partial PUT /api/stories/{id}); cross-column drag is disabled outright (a dragover handler only preventDefault()s when the dragged card's own status already maps to that column) rather than allowed-then-snapped-back, since the latter would visibly misplace a card for several seconds until the next poll -- reads as a bug, not a feature. - Epic swimlanes: stories grouped client-side by epic_id (incl. an "Unassigned" lane) with a DONE/total progress meter per epic, built from data the board already fetches (avoids N+1 GET /api/epics/{id}/stories calls for a result that's a strict subset of the already-loaded list). - Story detail modal: metadata/spec/acceptance criteria, a BFS-layered plain- SVG DAG of the story's task-tree (rows = BFS depth from root_task_id over both parent_task_id and depends_on edges, solid vs. dashed strokes distinguishing the two edge kinds), an event timeline rendering eval_verdict/arbitration_decided/human_accepted/retro_captured/ epic_proposed with human-readable text, and an Accept button wired to POST /api/stories/{id}/accept when status is REVIEW_READY. - DAG node coloring reuses the app's existing --state-* CSS custom properties (the dataviz skill's "reserved status palette" pattern, already used elsewhere in the app) rather than introducing a new palette. Running the skill's own palette validator against that pre-existing 9-color set fails its CVD/lightness checks (a pre-existing condition, out of scope to fix here since those colors are used elsewhere in the app) -- mitigated per the skill's documented remedy for a failing palette: every node always carries a text label (name + role + state) and a full legend, so identity never depends on color alone; edge kind is encoded by stroke style, not color. Verified with a real running server and a real headless-browser session (Chromium via playwright-core, offline-cached), not just code review: seeded real data (an epic, 11 stories across every lifecycle status, a 6-node Builder->4-Evaluator->Arbitration task tree, 4 story events) via the live REST API plus two throwaway fixture scripts for the two write paths the API doesn't expose (task depends_on, direct event writes), then confirmed via screenshots/DOM assertions: correct column placement for every status, working swimlane grouping/progress meter, correct 6-node/8-edge DAG render, working event timeline, a real Accept-button API call flipping REVIEW_READY->DONE, real drag-and-drop priority persistence (re-fetched to confirm), and a genuine cross-column-drag no-op. Caught and fixed a real bug during this process: DAG node-label truncation now measures getComputedTextLength() and binary-searches the longest fit, instead of a fixed character-count budget that collapsed every "Eval: evaluator_X" label to "Eval:..." (only one space in the string). go build ./... clean; node --test web/test/*.mjs -- 275 tests pass (19 new). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs --- web/app.js | 928 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 927 insertions(+), 1 deletion(-) (limited to 'web/app.js') diff --git a/web/app.js b/web/app.js index 0328579..6f45253 100644 --- a/web/app.js +++ b/web/app.js @@ -146,6 +146,30 @@ export function formatEventText(event) { return 'Execution started'; case 'execution_ended': return p.status ? `Execution ended (${p.status})` : 'Execution ended'; + case 'eval_verdict': + return `Eval verdict (${p.role || '?'}): ${p.summary || ''}`; + case 'arbitration_decided': + return `Arbitration decided: ${p.summary || ''}`; + case 'human_accepted': + return `Accepted: ${p.from || '?'} → ${p.to || '?'}`; + case 'retro_captured': { + const n = (p.proposals || []).length; + return `Retro captured (${n} config proposal${n === 1 ? '' : 's'}): ${p.summary || ''}`; + } + case 'epic_proposed': + return `Epic proposed: ${p.name || ''} (${(p.story_ids || []).length} stories)`; + case 'discovery_proposed': + return 'Discovery proposed'; + case 'framing_decided': + return 'Framing decided'; + case 'groomed': + return 'Groomed'; + case 'prioritized': + return 'Prioritized'; + case 'escalated': + return `Escalated: rung ${p.from_rung ?? '?'} → ${p.to_rung ?? '?'}${p.final ? ' (final)' : ''}`; + case 'role_config_proposed': + return `Role config proposed: ${p.role || '?'} v${p.version ?? '?'}`; default: return event.kind || ''; } @@ -1333,6 +1357,12 @@ function renderActiveTab(allTasks) { .then(([execs, agentData, dashStats]) => renderStatsPanel(allTasks, execs, agentData, dashStats)) .catch(() => {}); break; + case 'stories': + // Guard against yanking the board out from under an in-progress + // HTML5 drag (see draggingStoryId) — the next poll tick after the + // drag ends will pick up any server-side change. + if (!draggingStoryId) renderStoriesPanel(); + break; case 'drops': renderDropsPanel(); break; @@ -3133,7 +3163,7 @@ async function fetchDrops() { return res.json(); } -// ── Stories panel ───────────────────────────────────────────────────────────── +// ── Drops panel ─────────────────────────────────────────────────────────────── async function renderDropsPanel() { const panel = document.querySelector('[data-panel="drops"] .drops-panel'); @@ -3180,6 +3210,890 @@ async function renderDropsPanel() { } } +// ── Stories tab: data fetch ────────────────────────────────────────────────── +// Phase 9a: consumes the existing, unchanged epics/stories REST surface +// (internal/api/stories.go, internal/api/epics.go) — no backend changes. + +async function fetchStories(fetchImpl = fetch) { + const res = await fetchImpl(`${API_BASE}/api/stories`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +} + +async function fetchEpics(fetchImpl = fetch) { + const res = await fetchImpl(`${API_BASE}/api/epics`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +} + +async function fetchEpic(epicId, fetchImpl = fetch) { + const res = await fetchImpl(`${API_BASE}/api/epics/${epicId}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +} + +async function fetchStory(id, fetchImpl = fetch) { + const res = await fetchImpl(`${API_BASE}/api/stories/${id}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +} + +// fetchStoryEvents loads a story's event stream. Mirrors fetchTaskEvents: +// returns [] on any error so the board/modal degrade gracefully rather than +// throwing. +export async function fetchStoryEvents(storyId, fetchImpl = (typeof fetch !== 'undefined' ? fetch : null)) { + if (!fetchImpl) return []; + try { + const resp = await fetchImpl(`${API_BASE}/api/stories/${storyId}/events`); + if (!resp.ok) return []; + const data = await resp.json(); + return Array.isArray(data) ? data : []; + } catch { + return []; + } +} + +async function fetchStoryTaskTree(storyId, fetchImpl = fetch) { + const res = await fetchImpl(`${API_BASE}/api/stories/${storyId}/task-tree`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +} + +// updateStory issues a partial PUT — handleUpdateStory (internal/api/stories.go) +// decodes the request body onto the already-fetched existing row, so fields +// omitted from `body` are left untouched. Used here to write only `priority` +// on drag-and-drop reorder without clobbering the rest of the story. +async function updateStory(id, body) { + const res = await fetch(`${API_BASE}/api/stories/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + let msg = `HTTP ${res.status}`; + try { const b = await res.json(); msg = b.error || msg; } catch {} + throw new Error(msg); + } + return res.json(); +} + +async function acceptStory(id) { + const res = await fetch(`${API_BASE}/api/stories/${id}/accept`, { method: 'POST' }); + if (!res.ok) { + let msg = `HTTP ${res.status}`; + try { const b = await res.json(); msg = b.error || msg; } catch {} + throw new Error(msg); + } + return res.json(); +} + +// ── Stories tab: board/column model (pure — unit-tested in web/test) ─────── +// +// Columns collapse the full story.Status lifecycle (DISCOVERY|FRAMING| +// BACKLOG|PRIORITIZED|IN_PROGRESS|SHIPPABLE|DEPLOYED|VALIDATING| +// REVIEW_READY|NEEDS_FIX|DONE|CANCELLED — internal/story/story.go) into 7 +// columns. SHIPPABLE/DEPLOYED fold into "In Progress" (deploy-gating isn't +// built yet, so there's nothing actionable to show separately for them); +// VALIDATING/REVIEW_READY fold into one "Validating" column; NEEDS_FIX/ +// CANCELLED get their own small side column rather than a hidden filter +// toggle, per the phase's "your judgment" call, so a story that needs +// attention is never one click away from invisible. +export const STORY_COLUMNS = [ + { key: 'discovery', label: 'Discovery', statuses: ['DISCOVERY', 'FRAMING'] }, + { key: 'backlog', label: 'Backlog', statuses: ['BACKLOG'] }, + { key: 'prioritized', label: 'Prioritized', statuses: ['PRIORITIZED'] }, + { key: 'in_progress', label: 'In Progress', statuses: ['IN_PROGRESS', 'SHIPPABLE', 'DEPLOYED'] }, + { key: 'validating', label: 'Validating', statuses: ['VALIDATING', 'REVIEW_READY'] }, + { key: 'done', label: 'Done', statuses: ['DONE'] }, + { key: 'issues', label: 'Needs Fix / Cancelled', statuses: ['NEEDS_FIX', 'CANCELLED'] }, +]; + +// columnForStatus returns the column key for a given story status, falling +// back to 'backlog' for an empty/unrecognized status so a story is never +// dropped off the board entirely. +export function columnForStatus(status) { + const col = STORY_COLUMNS.find(c => c.statuses.includes(status)); + return col ? col.key : 'backlog'; +} + +function priorityRank(p) { + // Number('') === 0 and Number(null) === 0, so both would otherwise sort + // as the highest priority — treat "no priority set" as missing, not zero. + if (p === null || p === undefined || p === '') return Number.POSITIVE_INFINITY; + const n = Number(p); + return Number.isFinite(n) ? n : Number.POSITIVE_INFINITY; +} + +// storyPriorityComparator sorts ascending by numeric priority (this phase's +// convention: drag-and-drop reorder writes the story's rank as a stringified +// integer — story.Priority is a freeform string with no enum, so there's no +// existing convention to collide with). Non-numeric/missing priority sorts +// last; ties break by created_at ascending (oldest first). +export function storyPriorityComparator(a, b) { + const ra = priorityRank(a.priority); + const rb = priorityRank(b.priority); + if (ra !== rb) return ra - rb; + return new Date(a.created_at || 0) - new Date(b.created_at || 0); +} + +// groupStoriesByColumn returns { [columnKey]: story[] }, each sub-array +// sorted by storyPriorityComparator. +export function groupStoriesByColumn(stories) { + const groups = {}; + for (const col of STORY_COLUMNS) groups[col.key] = []; + for (const s of stories || []) { + const key = columnForStatus(s.status); + if (!groups[key]) groups[key] = []; + groups[key].push(s); + } + for (const key of Object.keys(groups)) { + groups[key].sort(storyPriorityComparator); + } + return groups; +} + +// A story "has reached validation" once evaluator verdicts could plausibly +// exist for it — VALIDATING or later in the lifecycle (see StoryOrchestrator +// stage 2 in CLAUDE.md). Used to gate the eval-verdict indicator so we don't +// fetch/show "Evals: 0/4" for a story that's still in Backlog. +export function storyHasReachedValidation(status) { + return ['VALIDATING', 'REVIEW_READY', 'NEEDS_FIX', 'DONE', 'CANCELLED'].includes(status); +} + +// countEvalVerdicts counts event.KindEvalVerdict entries in a story's event +// stream — up to 4 expected (evaluator_quality/security/correctness/performance). +export function countEvalVerdicts(events) { + return (events || []).filter(e => e && e.kind === 'eval_verdict').length; +} + +// ── Stories tab: rendering state ───────────────────────────────────────────── + +// draggingStoryId is set for the duration of an HTML5 drag so column +// dragover handlers can check whether the dragged story's own status maps +// to *this* column (reorder-in-place only — see renderStoriesBoard) and so +// poll() can avoid re-rendering the board out from under an active drag. +let draggingStoryId = null; +// storiesById is refreshed on every renderStoriesPanel() call; column +// dragover/drop handlers and persistColumnOrder consult it instead of +// re-fetching per drag event. +let storiesById = new Map(); + +function getStoriesViewMode() { + return localStorage.getItem('storiesViewMode') || 'board'; +} +function setStoriesViewMode(mode) { + localStorage.setItem('storiesViewMode', mode); +} + +// ── Stories tab: card ───────────────────────────────────────────────────────── + +function createStoryCard(story, { draggable = true } = {}) { + const card = document.createElement('div'); + card.className = 'story-card'; + card.dataset.storyId = story.id; + card.draggable = draggable; + + const header = document.createElement('div'); + header.className = 'story-card-header'; + const name = document.createElement('span'); + name.className = 'story-name'; + name.textContent = story.name; + const badge = document.createElement('span'); + badge.className = 'story-status-badge'; + badge.dataset.status = story.status; + badge.textContent = (story.status || '').replace(/_/g, ' '); + header.append(name, badge); + card.appendChild(header); + + const meta = document.createElement('div'); + meta.className = 'story-meta'; + if (story.priority) { + const p = document.createElement('span'); + p.textContent = `priority: ${story.priority}`; + meta.appendChild(p); + } + const ac = document.createElement('span'); + ac.textContent = `AC: ${(story.acceptance_criteria || []).length}`; + meta.appendChild(ac); + card.appendChild(meta); + + if (storyHasReachedValidation(story.status)) { + const evalEl = document.createElement('div'); + evalEl.className = 'story-eval-indicator'; + evalEl.textContent = 'Evals: …'; + card.appendChild(evalEl); + fetchStoryEvents(story.id).then(events => { + evalEl.textContent = `Evals: ${countEvalVerdicts(events)}/4`; + }); + } + + card.addEventListener('click', () => openStoryModal(story.id)); + + if (draggable) { + card.addEventListener('dragstart', (e) => { + draggingStoryId = story.id; + card.classList.add('dragging'); + e.dataTransfer.setData('text/plain', story.id); + e.dataTransfer.effectAllowed = 'move'; + }); + card.addEventListener('dragend', () => { + draggingStoryId = null; + card.classList.remove('dragging'); + document.querySelectorAll('.stories-column.drag-over').forEach(c => c.classList.remove('drag-over')); + }); + } + + return card; +} + +// getDragAfterElement finds the card the dragged element should be inserted +// before, based on vertical cursor position — standard vanilla-JS +// drag-reorder technique (no library). +function getDragAfterElement(container, y) { + const els = [...container.querySelectorAll('.story-card:not(.dragging)')]; + return els.reduce((closest, child) => { + const box = child.getBoundingClientRect(); + const offset = y - box.top - box.height / 2; + if (offset < 0 && offset > closest.offset) { + return { offset, element: child }; + } + return closest; + }, { offset: Number.NEGATIVE_INFINITY, element: null }).element; +} + +// persistColumnOrder re-ranks every card currently in `list` (in DOM order) +// as priority "0", "10", "20", … and PUTs any that actually changed. +// Ranks are only unique within a column, never globally — sorting only ever +// happens within one column's group (groupStoriesByColumn), so cross-column +// rank collisions are harmless. +async function persistColumnOrder(list) { + const ids = [...list.querySelectorAll('.story-card')].map(c => c.dataset.storyId); + await Promise.all(ids.map((id, idx) => { + const story = storiesById.get(id); + const newPriority = String(idx * 10); + if (!story || story.priority === newPriority) return Promise.resolve(); + return updateStory(id, { priority: newPriority }) + .then(() => { story.priority = newPriority; }) + .catch(err => console.error('Failed to persist story priority reorder:', err)); + })); +} + +// ── Stories tab: Kanban board ───────────────────────────────────────────────── +// +// Drag-and-drop decision (documented per the phase spec's either/or): dragging +// a card to a *different* column is disabled outright rather than allowed-but- +// a-no-op. A column's dragover handler only ever calls preventDefault() when +// the dragged story's own status already maps to that column — so a foreign +// column never shows a reorder preview and the browser's native "not allowed" +// drop-cursor kicks in. The alternative (accept the drop, snap back on next +// poll) would let a card visibly move to the wrong column for several seconds, +// which reads as a bug rather than a deliberate constraint. +function renderStoriesBoard(stories, container) { + const groups = groupStoriesByColumn(stories); + container.innerHTML = ''; + const board = document.createElement('div'); + board.className = 'stories-board'; + + for (const col of STORY_COLUMNS) { + const colEl = document.createElement('div'); + colEl.className = 'stories-column'; + colEl.dataset.columnKey = col.key; + + const header = document.createElement('div'); + header.className = 'stories-column-header'; + const title = document.createElement('span'); + title.textContent = col.label; + const count = document.createElement('span'); + count.className = 'stories-column-count'; + count.textContent = String(groups[col.key].length); + header.append(title, count); + colEl.appendChild(header); + + const list = document.createElement('div'); + list.className = 'stories-column-list'; + for (const story of groups[col.key]) { + list.appendChild(createStoryCard(story)); + } + colEl.appendChild(list); + + list.addEventListener('dragover', (e) => { + const dragged = storiesById.get(draggingStoryId); + if (!dragged || columnForStatus(dragged.status) !== col.key) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + colEl.classList.add('drag-over'); + const draggingEl = list.querySelector('.dragging'); + if (!draggingEl) return; + const after = getDragAfterElement(list, e.clientY); + if (after == null) list.appendChild(draggingEl); + else list.insertBefore(draggingEl, after); + }); + list.addEventListener('dragleave', (e) => { + if (!colEl.contains(e.relatedTarget)) colEl.classList.remove('drag-over'); + }); + list.addEventListener('drop', (e) => { + e.preventDefault(); + colEl.classList.remove('drag-over'); + persistColumnOrder(list); + }); + + board.appendChild(colEl); + } + + container.appendChild(board); +} + +// ── Stories tab: epic swimlanes ─────────────────────────────────────────────── + +function renderSwimlane(lane) { + const laneEl = document.createElement('div'); + laneEl.className = 'epic-swimlane'; + + const header = document.createElement('div'); + header.className = 'epic-swimlane-header'; + + const name = document.createElement('span'); + name.className = 'epic-swimlane-name'; + name.textContent = lane.name; + header.appendChild(name); + + const total = lane.stories.length; + const done = lane.stories.filter(s => s.status === 'DONE').length; + + const progressWrap = document.createElement('div'); + progressWrap.className = 'epic-progress'; + const track = document.createElement('div'); + track.className = 'epic-progress-track'; + const fill = document.createElement('div'); + fill.className = 'epic-progress-fill'; + fill.style.width = total > 0 ? `${Math.round((done / total) * 100)}%` : '0%'; + track.appendChild(fill); + const label = document.createElement('span'); + label.className = 'epic-progress-label'; + label.textContent = `${done}/${total} done`; + progressWrap.append(track, label); + header.appendChild(progressWrap); + + laneEl.appendChild(header); + + const row = document.createElement('div'); + row.className = 'epic-swimlane-stories'; + if (lane.stories.length === 0) { + const empty = document.createElement('span'); + empty.className = 'task-meta'; + empty.textContent = 'No stories.'; + row.appendChild(empty); + } else { + for (const s of lane.stories.slice().sort(storyPriorityComparator)) { + row.appendChild(createStoryCard(s, { draggable: false })); + } + } + laneEl.appendChild(row); + + return laneEl; +} + +async function renderEpicSwimlanes(stories, container) { + container.innerHTML = '

Loading epics…

'; + let epics; + try { + epics = await fetchEpics(); + } catch (err) { + container.innerHTML = `

Failed to load epics: ${err.message}

`; + return; + } + + const byEpic = new Map(); + const unassigned = []; + for (const s of stories) { + if (s.epic_id) { + if (!byEpic.has(s.epic_id)) byEpic.set(s.epic_id, []); + byEpic.get(s.epic_id).push(s); + } else { + unassigned.push(s); + } + } + + const lanes = epics.map(e => ({ id: e.id, name: e.name, stories: byEpic.get(e.id) || [] })); + if (unassigned.length > 0 || lanes.length === 0) { + lanes.push({ id: '', name: 'Unassigned', stories: unassigned }); + } + + container.innerHTML = ''; + const wrap = document.createElement('div'); + wrap.className = 'epic-swimlanes'; + if (lanes.length === 0) { + wrap.innerHTML = '

No epics or stories yet.

'; + } else { + for (const lane of lanes) wrap.appendChild(renderSwimlane(lane)); + } + container.appendChild(wrap); +} + +// ── Stories tab: panel entry point ─────────────────────────────────────────── + +async function renderStoriesPanel() { + const panel = document.querySelector('[data-panel="stories"]'); + if (!panel) return; + const container = panel.querySelector('.stories-view-container'); + if (!container) return; + + const mode = getStoriesViewMode(); + panel.querySelectorAll('.view-toggle-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.view === mode); + }); + + let stories; + try { + stories = await fetchStories(); + } catch (err) { + container.innerHTML = `

Failed to load stories: ${err.message}

`; + return; + } + + storiesById = new Map(stories.map(s => [s.id, s])); + + if (mode === 'epics') { + await renderEpicSwimlanes(stories, container); + } else { + renderStoriesBoard(stories, container); + } +} + +// ── Story task-tree DAG ────────────────────────────────────────────────────── +// Plain SVG + vanilla JS, no graph-layout library: a simple layered +// top-to-bottom layout, rows assigned by BFS depth from root_task_id +// following both "spawned" (parent_task_id) and "depends on" (depends_on) +// edges — the same two edge types GET /api/stories/{id}/task-tree itself +// walks server-side (ListSubtasks/ListDependents) to build the flat node +// list this renders. + +// computeTaskTreeDepths returns Map. Defensive fallbacks: +// an unresolvable root, or a node genuinely unreachable from it (shouldn't +// happen given how the API built the list, but a cycle or a stale +// parent_task_id could in principle produce one), still get a depth so +// nothing silently vanishes from the layout. +export function computeTaskTreeDepths(nodes, rootId) { + const byId = new Map(nodes.map(n => [n.id, n])); + const childrenOf = new Map(); + const addEdge = (fromId, toId) => { + if (!childrenOf.has(fromId)) childrenOf.set(fromId, []); + childrenOf.get(fromId).push(toId); + }; + for (const n of nodes) { + if (n.parent_task_id && byId.has(n.parent_task_id)) addEdge(n.parent_task_id, n.id); + for (const dep of n.depends_on || []) { + if (byId.has(dep)) addEdge(dep, n.id); + } + } + + const depths = new Map(); + if (!byId.has(rootId)) { + for (const n of nodes) depths.set(n.id, 0); + return depths; + } + + depths.set(rootId, 0); + const queue = [rootId]; + while (queue.length > 0) { + const id = queue.shift(); + const depth = depths.get(id); + for (const childId of childrenOf.get(id) || []) { + if (!depths.has(childId)) { + depths.set(childId, depth + 1); + queue.push(childId); + } + } + } + let maxDepth = 0; + for (const d of depths.values()) maxDepth = Math.max(maxDepth, d); + for (const n of nodes) { + if (!depths.has(n.id)) depths.set(n.id, maxDepth + 1); + } + return depths; +} + +const DAG_NODE_WIDTH = 160; +const DAG_NODE_HEIGHT = 44; +const DAG_COL_GAP = 24; +const DAG_ROW_GAP = 48; + +// layoutTaskTree assigns each node an {x, y} top-left position: rows by BFS +// depth (top-to-bottom), columns by original node order within a row. +export function layoutTaskTree(nodes, rootId) { + const depths = computeTaskTreeDepths(nodes, rootId); + const byDepth = new Map(); + for (const n of nodes) { + const d = depths.get(n.id) || 0; + if (!byDepth.has(d)) byDepth.set(d, []); + byDepth.get(d).push(n); + } + const depthKeys = [...byDepth.keys()]; + const maxDepth = depthKeys.length > 0 ? Math.max(...depthKeys) : 0; + + const positions = new Map(); + for (let d = 0; d <= maxDepth; d++) { + const row = byDepth.get(d) || []; + row.forEach((n, i) => { + positions.set(n.id, { + x: i * (DAG_NODE_WIDTH + DAG_COL_GAP), + y: d * (DAG_NODE_HEIGHT + DAG_ROW_GAP), + }); + }); + } + const rowLengths = [...byDepth.values()].map(r => r.length); + const maxCols = rowLengths.length > 0 ? Math.max(...rowLengths) : 1; + return { + positions, + width: Math.max(DAG_NODE_WIDTH, maxCols * (DAG_NODE_WIDTH + DAG_COL_GAP) - DAG_COL_GAP), + height: (maxDepth + 1) * (DAG_NODE_HEIGHT + DAG_ROW_GAP) - DAG_ROW_GAP, + }; +} + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +const DAG_LEGEND_STATES = [ + 'PENDING', 'QUEUED', 'RUNNING', 'READY', 'BLOCKED', + 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED', +]; + +function renderDagLegend() { + const wrap = document.createElement('div'); + + const stateLegend = document.createElement('div'); + stateLegend.className = 'dag-legend'; + for (const state of DAG_LEGEND_STATES) { + const item = document.createElement('span'); + item.className = 'dag-legend-item'; + const swatch = document.createElement('span'); + swatch.className = 'dag-legend-swatch'; + swatch.dataset.state = state; + const label = document.createElement('span'); + label.textContent = state.replace(/_/g, ' '); + item.append(swatch, label); + stateLegend.appendChild(item); + } + wrap.appendChild(stateLegend); + + const edgeLegend = document.createElement('div'); + edgeLegend.className = 'dag-edge-legend'; + const parentItem = document.createElement('span'); + const parentSwatch = document.createElement('span'); + parentSwatch.className = 'dag-edge-legend-swatch'; + parentItem.append(parentSwatch, document.createTextNode('spawned (parent → child)')); + const dependsItem = document.createElement('span'); + const dependsSwatch = document.createElement('span'); + dependsSwatch.className = 'dag-edge-legend-swatch dag-edge-legend-swatch--depends'; + dependsItem.append(dependsSwatch, document.createTextNode('depends on')); + edgeLegend.append(parentItem, dependsItem); + wrap.appendChild(edgeLegend); + + return wrap; +} + +function drawDagEdge(g, from, to, kind) { + const line = document.createElementNS(SVG_NS, 'line'); + line.setAttribute('x1', from.x); + line.setAttribute('y1', from.y); + line.setAttribute('x2', to.x); + line.setAttribute('y2', to.y); + line.setAttribute('class', `dag-edge dag-edge--${kind}`); + line.setAttribute('marker-end', 'url(#dag-arrow)'); + g.appendChild(line); +} + +function renderDagNode(node, pos, pad) { + const g = document.createElementNS(SVG_NS, 'g'); + g.setAttribute('class', 'dag-node'); + g.setAttribute('transform', `translate(${pos.x + pad}, ${pos.y + pad})`); + + const rect = document.createElementNS(SVG_NS, 'rect'); + rect.setAttribute('width', DAG_NODE_WIDTH); + rect.setAttribute('height', DAG_NODE_HEIGHT); + rect.setAttribute('rx', 6); + rect.setAttribute('class', 'dag-node-rect'); + rect.dataset.state = node.state; + g.appendChild(rect); + + const title = document.createElementNS(SVG_NS, 'title'); + title.textContent = `${node.name}\nState: ${node.state}${node.role ? `\nRole: ${node.role}` : ''}`; + g.appendChild(title); + + const nameText = document.createElementNS(SVG_NS, 'text'); + nameText.setAttribute('x', 8); + nameText.setAttribute('y', 18); + nameText.setAttribute('class', 'dag-node-name'); + // Full text for now — fitDagNodeLabels (run once every node is attached to + // the live document) measures and truncates with an ellipsis, since a + // single long word (e.g. a role name) has no word-boundary to truncate at + // and a fixed character count either clips it anyway or wastes space on + // shorter names. "Measure first" per the dataviz guidance, not guess. + nameText.textContent = node.name || node.id; + g.appendChild(nameText); + + const subText = document.createElementNS(SVG_NS, 'text'); + subText.setAttribute('x', 8); + subText.setAttribute('y', 34); + subText.setAttribute('class', 'dag-node-sub'); + subText.textContent = node.role ? `${node.role} · ${node.state}` : node.state; + g.appendChild(subText); + + return g; +} + +// renderTaskTreeDAG renders a GET /api/stories/{id}/task-tree response into +// `container` as an SVG layered graph, color-coded by task.State (reusing +// the app's existing --state-* tokens — see dag-node-rect rules in +// style.css) with a text legend, plus a distinct name/state text label on +// every node so identity is never color-alone. +function renderTaskTreeDAG(tree, container) { + container.innerHTML = ''; + const nodes = (tree && tree.nodes) || []; + if (nodes.length === 0) { + container.innerHTML = '

No task tree yet — this story has no root_task_id, or its root task has not been created.

'; + return; + } + + const { positions, width, height } = layoutTaskTree(nodes, tree.root_task_id); + const pad = 16; + + const svg = document.createElementNS(SVG_NS, 'svg'); + svg.setAttribute('viewBox', `0 0 ${width + pad * 2} ${height + pad * 2}`); + svg.setAttribute('width', width + pad * 2); + svg.setAttribute('height', height + pad * 2); + svg.classList.add('dag-svg'); + + const defs = document.createElementNS(SVG_NS, 'defs'); + const marker = document.createElementNS(SVG_NS, 'marker'); + marker.setAttribute('id', 'dag-arrow'); + marker.setAttribute('viewBox', '0 0 10 10'); + marker.setAttribute('refX', '9'); + marker.setAttribute('refY', '5'); + marker.setAttribute('markerWidth', '7'); + marker.setAttribute('markerHeight', '7'); + marker.setAttribute('orient', 'auto-start-reverse'); + const arrowPath = document.createElementNS(SVG_NS, 'path'); + arrowPath.setAttribute('d', 'M0,0 L10,5 L0,10 z'); + arrowPath.setAttribute('class', 'dag-arrow-head'); + marker.appendChild(arrowPath); + defs.appendChild(marker); + svg.appendChild(defs); + + const byId = new Map(nodes.map(n => [n.id, n])); + function center(n) { + const p = positions.get(n.id) || { x: 0, y: 0 }; + return { x: p.x + DAG_NODE_WIDTH / 2 + pad, y: p.y + DAG_NODE_HEIGHT / 2 + pad }; + } + + const edgesG = document.createElementNS(SVG_NS, 'g'); + edgesG.setAttribute('class', 'dag-edges'); + for (const n of nodes) { + if (n.parent_task_id && byId.has(n.parent_task_id)) { + drawDagEdge(edgesG, center(byId.get(n.parent_task_id)), center(n), 'parent'); + } + for (const dep of n.depends_on || []) { + if (byId.has(dep)) { + drawDagEdge(edgesG, center(byId.get(dep)), center(n), 'depends'); + } + } + } + svg.appendChild(edgesG); + + const nodesG = document.createElementNS(SVG_NS, 'g'); + nodesG.setAttribute('class', 'dag-nodes'); + for (const n of nodes) { + nodesG.appendChild(renderDagNode(n, positions.get(n.id) || { x: 0, y: 0 }, pad)); + } + svg.appendChild(nodesG); + + const wrap = document.createElement('div'); + wrap.className = 'dag-container'; + wrap.appendChild(svg); + container.appendChild(wrap); + container.appendChild(renderDagLegend()); + + // Text elements are now part of the live document (container was already + // on-page) — getComputedTextLength() needs that to return real numbers. + fitDagNodeLabels(svg, DAG_NODE_WIDTH - 16); +} + +// fitDagNodeLabels truncates each .dag-node-name/.dag-node-sub text element +// to fit maxWidth, measuring actual rendered glyph width via +// getComputedTextLength() rather than assuming a character-count budget — +// a role name like "evaluator_performance" is one unbroken word with no +// truncateToWordBoundary-style word break to fall back on, and different +// browsers/fonts render the same string at different widths. +function fitDagNodeLabels(svgRoot, maxWidth) { + const els = svgRoot.querySelectorAll('.dag-node-name, .dag-node-sub'); + for (const el of els) { + const full = el.textContent; + let length; + try { + length = el.getComputedTextLength(); + } catch { + continue; // no layout available (e.g. non-browser test env) — leave as-is + } + if (!Number.isFinite(length) || length <= maxWidth) continue; + + let lo = 0; + let hi = full.length; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + el.textContent = full.slice(0, mid) + '…'; + if (el.getComputedTextLength() <= maxWidth) lo = mid; + else hi = mid - 1; + } + el.textContent = lo > 0 ? full.slice(0, lo) + '…' : '…'; + } +} + +// ── Story detail modal ──────────────────────────────────────────────────────── + +function acListElement(items) { + const ul = document.createElement('ul'); + if (!items || items.length === 0) { + ul.className = 'ac-list empty'; + const li = document.createElement('li'); + li.textContent = 'No acceptance criteria recorded.'; + ul.appendChild(li); + return ul; + } + ul.className = 'ac-list'; + for (const item of items) { + const li = document.createElement('li'); + li.textContent = item; + ul.appendChild(li); + } + return ul; +} + +async function renderStoryModalContent(storyId) { + const title = document.getElementById('story-modal-title'); + const body = document.getElementById('story-modal-body'); + + let story, tree, events; + try { + [story, tree, events] = await Promise.all([ + fetchStory(storyId), + fetchStoryTaskTree(storyId), + fetchStoryEvents(storyId), + ]); + } catch (err) { + title.textContent = 'Story'; + body.innerHTML = `
Failed to load: ${err.message}
`; + return; + } + + let epic = null; + if (story.epic_id) { + try { epic = await fetchEpic(story.epic_id); } catch { epic = null; } + } + + title.textContent = story.name; + body.innerHTML = ''; + + // ── Metadata ── + const metaSection = makeSection('Story'); + const grid = document.createElement('div'); + grid.className = 'meta-grid'; + + const statusItem = document.createElement('div'); + statusItem.className = 'meta-item'; + const statusLbl = document.createElement('div'); + statusLbl.className = 'meta-label'; + statusLbl.textContent = 'Status'; + const statusBadge = document.createElement('span'); + statusBadge.className = 'story-status-badge'; + statusBadge.dataset.status = story.status; + statusBadge.textContent = (story.status || '').replace(/_/g, ' '); + statusItem.append(statusLbl, statusBadge); + grid.appendChild(statusItem); + + grid.append( + makeMetaItem('Priority', story.priority), + makeMetaItem('Epic', epic ? epic.name : (story.epic_id ? story.epic_id : '—'), { muted: !epic && !story.epic_id }), + makeMetaItem('Created', formatDateLong(story.created_at)), + makeMetaItem('Updated', formatDateLong(story.updated_at)), + makeMetaItem('ID', story.id, { fullWidth: true, mono: true }), + ); + metaSection.appendChild(grid); + + if (story.spec) { + const specLabel = document.createElement('div'); + specLabel.className = 'meta-label'; + specLabel.style.marginTop = '0.75rem'; + specLabel.textContent = 'Spec'; + const specText = document.createElement('p'); + specText.className = 'task-summary'; + specText.textContent = story.spec; + metaSection.appendChild(specLabel); + metaSection.appendChild(specText); + } + + const acLabel = document.createElement('div'); + acLabel.className = 'meta-label'; + acLabel.style.marginTop = '0.75rem'; + acLabel.textContent = 'Acceptance Criteria'; + metaSection.appendChild(acLabel); + metaSection.appendChild(acListElement(story.acceptance_criteria)); + + if (story.status === 'REVIEW_READY') { + const acceptRow = document.createElement('div'); + acceptRow.className = 'story-modal-actions'; + const acceptBtn = document.createElement('button'); + acceptBtn.className = 'btn-accept'; + acceptBtn.textContent = 'Accept'; + acceptBtn.addEventListener('click', async () => { + acceptBtn.disabled = true; + acceptBtn.textContent = 'Accepting…'; + try { + await acceptStory(story.id); + await renderStoryModalContent(storyId); + renderStoriesPanel(); + } catch (err) { + acceptBtn.disabled = false; + acceptBtn.textContent = 'Accept'; + const errEl = document.createElement('span'); + errEl.className = 'task-error'; + errEl.textContent = `Failed: ${err.message}`; + acceptRow.appendChild(errEl); + } + }); + acceptRow.appendChild(acceptBtn); + metaSection.appendChild(acceptRow); + } + + body.appendChild(metaSection); + + // ── Task tree DAG ── + const dagSection = makeSection('Task Tree'); + const dagContainer = document.createElement('div'); + dagSection.appendChild(dagContainer); + body.appendChild(dagSection); + renderTaskTreeDAG(tree, dagContainer); + + // ── Event timeline ── + const timelineSection = makeSection('Timeline'); + timelineSection.appendChild(renderEventTimeline(events)); + body.appendChild(timelineSection); +} + +async function openStoryModal(storyId) { + const modal = document.getElementById('story-modal'); + const title = document.getElementById('story-modal-title'); + const body = document.getElementById('story-modal-body'); + if (!modal) return; + title.textContent = 'Loading…'; + body.innerHTML = '
Loading…
'; + modal.showModal(); + await renderStoryModalContent(storyId); +} + +function closeStoryModal() { + const modal = document.getElementById('story-modal'); + if (modal) modal.close(); +} + // ── Tab switching ───────────────────────────────────────────────────────────── function switchTab(name) { @@ -3242,6 +4156,18 @@ if (typeof document !== 'undefined') { document.getElementById('logs-modal').close(); }); + // Story detail modal close + const btnCloseStoryModal = document.getElementById('btn-close-story-modal'); + if (btnCloseStoryModal) btnCloseStoryModal.addEventListener('click', closeStoryModal); + + // Stories board/epics view toggle + document.querySelectorAll('.view-toggle-btn').forEach(btn => { + btn.addEventListener('click', () => { + setStoriesViewMode(btn.dataset.view); + renderStoriesPanel(); + }); + }); + // Tab bar document.querySelectorAll('.tab').forEach(btn => { btn.addEventListener('click', () => switchTab(btn.dataset.tab)); -- cgit v1.2.3