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 +++++++++++++++++++++++++++++++++++++++- web/index.html | 21 + web/style.css | 365 ++++++++++++++++ web/test/stories-board.test.mjs | 197 +++++++++ 4 files changed, 1510 insertions(+), 1 deletion(-) create mode 100644 web/test/stories-board.test.mjs (limited to 'web') 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)); diff --git a/web/index.html b/web/index.html index 5aa7b44..523edd9 100644 --- a/web/index.html +++ b/web/index.html @@ -44,6 +44,7 @@ + @@ -65,6 +66,17 @@
+ @@ -96,6 +108,15 @@ + + +
+

Story

+ +
+
+
+ diff --git a/web/style.css b/web/style.css index abbf3b9..b11faf6 100644 --- a/web/style.css +++ b/web/style.css @@ -1868,12 +1868,18 @@ dialog label select:focus { } .story-status-badge[data-status="PENDING"] { background: var(--state-pending); } +.story-status-badge[data-status="DISCOVERY"] { background: var(--state-pending); } +.story-status-badge[data-status="FRAMING"] { background: var(--state-pending); } +.story-status-badge[data-status="BACKLOG"] { background: var(--state-pending); } +.story-status-badge[data-status="PRIORITIZED"] { background: var(--state-queued); } .story-status-badge[data-status="IN_PROGRESS"] { background: var(--state-running); } .story-status-badge[data-status="SHIPPABLE"] { background: var(--state-completed); } .story-status-badge[data-status="DEPLOYED"] { background: #60a5fa; } .story-status-badge[data-status="VALIDATING"] { background: #c084fc; } .story-status-badge[data-status="REVIEW_READY"] { background: var(--state-completed); } .story-status-badge[data-status="NEEDS_FIX"] { background: var(--state-failed); color: #fff; } +.story-status-badge[data-status="DONE"] { background: var(--state-completed); } +.story-status-badge[data-status="CANCELLED"] { background: var(--state-cancelled); } .story-meta { display: flex; @@ -2005,6 +2011,12 @@ dialog label select:focus { .event-timeline__item--subtask_spawned { border-left-color: var(--accent); } .event-timeline__item--execution_started, .event-timeline__item--execution_ended { border-left-color: var(--state-running); } +.event-timeline__item--eval_verdict { border-left-color: var(--state-completed); } +.event-timeline__item--arbitration_decided { border-left-color: var(--accent); } +.event-timeline__item--human_accepted { border-left-color: var(--state-completed); } +.event-timeline__item--retro_captured { border-left-color: var(--state-timed-out); } +.event-timeline__item--epic_proposed { border-left-color: var(--accent); } +.event-timeline__item--escalated { border-left-color: var(--state-budget-exceeded); } .event-timeline__actor { flex: 0 0 auto; text-transform: uppercase; @@ -2043,3 +2055,356 @@ dialog label select:focus { border-color: var(--state-budget-exceeded); color: var(--state-budget-exceeded); } + +/* ── Stories tab: Kanban board ────────────────────────────────────────────── */ +/* main{max-width:640px} stays untouched for every other tab; the board opts + out locally (negative margins) and scrolls horizontally within itself so + a 7-column board never forces the whole page to scroll sideways. */ + +[data-panel="stories"] { + max-width: none; + margin: 0 -1rem; + padding: 0 1rem; +} + +.stories-toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: 0.75rem; +} + +.view-toggle { + display: inline-flex; + border: 1px solid var(--border); + border-radius: 0.375rem; + overflow: hidden; +} + +.view-toggle-btn { + font-size: 0.8rem; + font-weight: 600; + padding: 0.35em 0.9em; + border: none; + background: transparent; + color: var(--text-muted); + cursor: pointer; +} + +.view-toggle-btn + .view-toggle-btn { + border-left: 1px solid var(--border); +} + +.view-toggle-btn.active { + background: var(--accent); + color: #0f172a; +} + +.stories-board { + display: flex; + align-items: flex-start; + gap: 0.75rem; + overflow-x: auto; + padding-bottom: 0.5rem; +} + +.stories-column { + flex: 0 0 200px; + width: 200px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 0.625rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + transition: border-color 0.15s, background 0.15s; +} + +.stories-column.drag-over { + border-color: var(--accent); + background: var(--surface); +} + +.stories-column-header { + display: flex; + align-items: baseline; + justify-content: space-between; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--text-muted); + padding-bottom: 0.4rem; + border-bottom: 1px solid var(--border); +} + +.stories-column-count { + font-weight: 700; + color: var(--text); + background: var(--surface); + border-radius: 999px; + padding: 0 0.45em; + font-size: 0.7rem; +} + +.stories-column-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + min-height: 40px; + max-height: 60vh; + overflow-y: auto; +} + +.story-card { + cursor: grab; +} + +.story-card.dragging { + opacity: 0.4; +} + +.story-card-header { + flex-wrap: wrap; +} + +.story-eval-indicator { + font-size: 0.72rem; + color: var(--text-muted); + margin-top: 0.1rem; +} + +.story-modal-actions { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.5rem; +} + +/* ── Stories tab: Epic swimlanes ──────────────────────────────────────────── */ + +.epic-swimlanes { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.epic-swimlane { + border: 1px solid var(--border); + border-radius: 0.5rem; + background: var(--bg); + padding: 0.75rem; +} + +.epic-swimlane-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 0.625rem; +} + +.epic-swimlane-name { + font-weight: 700; + font-size: 0.92rem; + flex-shrink: 0; +} + +.epic-progress { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; + min-width: 0; +} + +/* Meter: fill is a single hue (success), track is a lighter neutral step — + same-ramp read, not a multi-color segmented bar. */ +.epic-progress-track { + flex: 1; + height: 6px; + border-radius: 999px; + background: var(--border); + overflow: hidden; +} + +.epic-progress-fill { + height: 100%; + background: var(--state-completed); + border-radius: 999px; + transition: width 0.2s; +} + +.epic-progress-label { + font-size: 0.75rem; + color: var(--text-muted); + white-space: nowrap; + flex-shrink: 0; +} + +.epic-swimlane-stories { + display: flex; + gap: 0.625rem; + overflow-x: auto; + padding-bottom: 0.25rem; +} + +.epic-swimlane-stories .story-card { + flex: 0 0 220px; + width: 220px; + cursor: pointer; +} + +/* ── Story detail modal ───────────────────────────────────────────────────── */ + +#story-modal { + max-width: 900px; + width: 92vw; + max-height: 88vh; + overflow-y: auto; +} + +.story-modal-body { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.ac-list { + margin: 0.25rem 0 0; + padding-left: 1.25rem; +} + +.ac-list li { + padding: 0.15rem 0; + font-size: 0.875rem; +} + +.ac-list.empty { + list-style: none; + padding-left: 0; + color: var(--text-muted); + font-size: 0.85rem; +} + +/* ── Story task-tree DAG ──────────────────────────────────────────────────── */ + +.dag-container { + overflow: auto; + max-height: 440px; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: var(--bg); + padding: 0.5rem; +} + +.dag-svg { + display: block; +} + +.dag-node-rect { + stroke: rgba(15, 23, 42, 0.35); + stroke-width: 1; +} + +.dag-node-rect[data-state="PENDING"] { fill: var(--state-pending); } +.dag-node-rect[data-state="QUEUED"] { fill: var(--state-queued); } +.dag-node-rect[data-state="RUNNING"] { fill: var(--state-running); } +.dag-node-rect[data-state="READY"] { fill: var(--state-running); } +.dag-node-rect[data-state="COMPLETED"] { fill: var(--state-completed); } +.dag-node-rect[data-state="FAILED"] { fill: var(--state-failed); } +.dag-node-rect[data-state="TIMED_OUT"] { fill: var(--state-timed-out); } +.dag-node-rect[data-state="CANCELLED"] { fill: var(--state-cancelled); } +.dag-node-rect[data-state="BUDGET_EXCEEDED"] { fill: var(--state-budget-exceeded); } +.dag-node-rect[data-state="BLOCKED"] { fill: var(--state-blocked); } + +.dag-node-name, .dag-node-sub { + font-family: inherit; + fill: #0f172a; +} + +.dag-node-name { + font-size: 11px; + font-weight: 700; +} + +.dag-node-sub { + font-size: 9px; + opacity: 0.85; +} + +.dag-edge { + stroke: var(--text-muted); + stroke-width: 1.5; + fill: none; +} + +.dag-edge--depends { + stroke-dasharray: 4 3; +} + +.dag-arrow-head { + fill: var(--text-muted); +} + +.dag-legend { + display: flex; + flex-wrap: wrap; + gap: 0.4rem 0.9rem; + margin-top: 0.75rem; + font-size: 0.72rem; + color: var(--text-muted); +} + +.dag-legend-item { + display: inline-flex; + align-items: center; + gap: 0.3rem; +} + +.dag-legend-swatch { + width: 10px; + height: 10px; + border-radius: 2px; + display: inline-block; +} + +.dag-legend-swatch[data-state="PENDING"] { background: var(--state-pending); } +.dag-legend-swatch[data-state="QUEUED"] { background: var(--state-queued); } +.dag-legend-swatch[data-state="RUNNING"] { background: var(--state-running); } +.dag-legend-swatch[data-state="READY"] { background: var(--state-running); } +.dag-legend-swatch[data-state="COMPLETED"] { background: var(--state-completed); } +.dag-legend-swatch[data-state="FAILED"] { background: var(--state-failed); } +.dag-legend-swatch[data-state="TIMED_OUT"] { background: var(--state-timed-out); } +.dag-legend-swatch[data-state="CANCELLED"] { background: var(--state-cancelled); } +.dag-legend-swatch[data-state="BUDGET_EXCEEDED"] { background: var(--state-budget-exceeded); } +.dag-legend-swatch[data-state="BLOCKED"] { background: var(--state-blocked); } + +.dag-edge-legend { + display: flex; + flex-wrap: wrap; + gap: 0.4rem 1.2rem; + margin-top: 0.35rem; + font-size: 0.72rem; + color: var(--text-muted); +} + +.dag-edge-legend-swatch { + display: inline-block; + width: 18px; + border-top: 2px solid var(--text-muted); + margin-right: 0.35rem; + vertical-align: middle; +} + +.dag-edge-legend-swatch--depends { + border-top-style: dashed; +} + +@media (max-width: 640px) { + #story-modal { + max-width: 100vw; + width: 100vw; + max-height: 100vh; + border-radius: 0; + } +} diff --git a/web/test/stories-board.test.mjs b/web/test/stories-board.test.mjs new file mode 100644 index 0000000..482628f --- /dev/null +++ b/web/test/stories-board.test.mjs @@ -0,0 +1,197 @@ +// stories-board.test.mjs — Unit tests for the Phase 9a Stories tab's pure +// logic: column grouping/sorting, eval-verdict counting, DAG BFS-depth +// layout, and the new story-related event-timeline text formatting. +// +// Run with: node --test web/test/stories-board.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + STORY_COLUMNS, + columnForStatus, + groupStoriesByColumn, + storyPriorityComparator, + storyHasReachedValidation, + countEvalVerdicts, + computeTaskTreeDepths, + layoutTaskTree, + formatEventText, +} from '../app.js'; + +describe('columnForStatus', () => { + it('maps each documented status to a column', () => { + assert.equal(columnForStatus('DISCOVERY'), 'discovery'); + assert.equal(columnForStatus('FRAMING'), 'discovery'); + assert.equal(columnForStatus('BACKLOG'), 'backlog'); + assert.equal(columnForStatus('PRIORITIZED'), 'prioritized'); + assert.equal(columnForStatus('IN_PROGRESS'), 'in_progress'); + assert.equal(columnForStatus('SHIPPABLE'), 'in_progress'); + assert.equal(columnForStatus('DEPLOYED'), 'in_progress'); + assert.equal(columnForStatus('VALIDATING'), 'validating'); + assert.equal(columnForStatus('REVIEW_READY'), 'validating'); + assert.equal(columnForStatus('DONE'), 'done'); + assert.equal(columnForStatus('NEEDS_FIX'), 'issues'); + assert.equal(columnForStatus('CANCELLED'), 'issues'); + }); + + it('falls back to backlog for an unrecognized/empty status', () => { + assert.equal(columnForStatus(''), 'backlog'); + assert.equal(columnForStatus(undefined), 'backlog'); + assert.equal(columnForStatus('SOME_FUTURE_STATUS'), 'backlog'); + }); + + it('every column key is unique and covers the full lifecycle', () => { + const keys = STORY_COLUMNS.map(c => c.key); + assert.equal(new Set(keys).size, keys.length); + const allStatuses = STORY_COLUMNS.flatMap(c => c.statuses); + for (const s of [ + 'DISCOVERY', 'FRAMING', 'BACKLOG', 'PRIORITIZED', 'IN_PROGRESS', + 'SHIPPABLE', 'DEPLOYED', 'VALIDATING', 'REVIEW_READY', 'NEEDS_FIX', + 'DONE', 'CANCELLED', + ]) { + assert.ok(allStatuses.includes(s), `${s} missing from any column`); + } + }); +}); + +describe('storyPriorityComparator / groupStoriesByColumn', () => { + it('sorts numeric priority ascending', () => { + const a = { id: 'a', priority: '20', created_at: '2024-01-01T00:00:00Z' }; + const b = { id: 'b', priority: '10', created_at: '2024-01-01T00:00:00Z' }; + assert.ok(storyPriorityComparator(a, b) > 0); + assert.ok(storyPriorityComparator(b, a) < 0); + }); + + it('sorts missing/non-numeric priority after numeric ones', () => { + const numeric = { id: 'n', priority: '0', created_at: '2024-01-01T00:00:00Z' }; + const missing = { id: 'm', priority: '', created_at: '2023-01-01T00:00:00Z' }; + assert.ok(storyPriorityComparator(numeric, missing) < 0); + }); + + it('breaks ties by created_at ascending', () => { + const older = { id: 'o', priority: '5', created_at: '2023-01-01T00:00:00Z' }; + const newer = { id: 'n', priority: '5', created_at: '2024-01-01T00:00:00Z' }; + assert.ok(storyPriorityComparator(older, newer) < 0); + }); + + it('groups and sorts stories into their column', () => { + const stories = [ + { id: '1', status: 'BACKLOG', priority: '10', created_at: '2024-01-01T00:00:00Z' }, + { id: '2', status: 'BACKLOG', priority: '0', created_at: '2024-01-01T00:00:00Z' }, + { id: '3', status: 'DONE', priority: '0', created_at: '2024-01-01T00:00:00Z' }, + ]; + const groups = groupStoriesByColumn(stories); + assert.deepEqual(groups.backlog.map(s => s.id), ['2', '1']); + assert.deepEqual(groups.done.map(s => s.id), ['3']); + assert.equal(groups.discovery.length, 0); + }); +}); + +describe('storyHasReachedValidation / countEvalVerdicts', () => { + it('is true only from VALIDATING onward', () => { + assert.equal(storyHasReachedValidation('BACKLOG'), false); + assert.equal(storyHasReachedValidation('IN_PROGRESS'), false); + assert.equal(storyHasReachedValidation('VALIDATING'), true); + assert.equal(storyHasReachedValidation('REVIEW_READY'), true); + assert.equal(storyHasReachedValidation('NEEDS_FIX'), true); + assert.equal(storyHasReachedValidation('DONE'), true); + }); + + it('counts only eval_verdict events', () => { + const events = [ + { kind: 'eval_verdict' }, + { kind: 'eval_verdict' }, + { kind: 'state_change' }, + { kind: 'arbitration_decided' }, + ]; + assert.equal(countEvalVerdicts(events), 2); + assert.equal(countEvalVerdicts([]), 0); + assert.equal(countEvalVerdicts(null), 0); + }); +}); + +describe('computeTaskTreeDepths / layoutTaskTree', () => { + const nodes = [ + { id: 'root', parent_task_id: '', depends_on: [] }, + { id: 'child1', parent_task_id: 'root', depends_on: [] }, + { id: 'child2', parent_task_id: 'root', depends_on: [] }, + { id: 'dependent', parent_task_id: '', depends_on: ['child1', 'child2'] }, + ]; + + it('assigns depth 0 to root and increasing depth via parent/depends_on edges', () => { + const depths = computeTaskTreeDepths(nodes, 'root'); + assert.equal(depths.get('root'), 0); + assert.equal(depths.get('child1'), 1); + assert.equal(depths.get('child2'), 1); + assert.equal(depths.get('dependent'), 2); + }); + + it('falls back to depth 0 for everything when root is unresolvable', () => { + const depths = computeTaskTreeDepths(nodes, 'missing-root'); + for (const n of nodes) assert.equal(depths.get(n.id), 0); + }); + + it('places isolated/unreachable nodes past the max depth rather than dropping them', () => { + const withOrphan = [...nodes, { id: 'orphan', parent_task_id: 'nonexistent', depends_on: [] }]; + const depths = computeTaskTreeDepths(withOrphan, 'root'); + assert.equal(depths.get('orphan'), 3); // maxDepth(2) + 1 + }); + + it('lays out nodes with non-overlapping rows/columns', () => { + const { positions, width, height } = layoutTaskTree(nodes, 'root'); + assert.equal(positions.get('root').y, 0); + assert.equal(positions.get('child1').y, positions.get('child2').y); + assert.notEqual(positions.get('child1').x, positions.get('child2').x); + assert.ok(positions.get('dependent').y > positions.get('child1').y); + assert.ok(width > 0); + assert.ok(height > 0); + }); + + it('handles a single-node tree', () => { + const single = [{ id: 'only', parent_task_id: '', depends_on: [] }]; + const { positions, width, height } = layoutTaskTree(single, 'only'); + assert.deepEqual(positions.get('only'), { x: 0, y: 0 }); + assert.ok(width > 0 && height > 0); + }); +}); + +describe('formatEventText — story/planning-layer event kinds', () => { + it('formats eval_verdict', () => { + assert.equal( + formatEventText({ kind: 'eval_verdict', payload: { role: 'evaluator_security', summary: 'No issues found' } }), + 'Eval verdict (evaluator_security): No issues found', + ); + }); + + it('formats arbitration_decided', () => { + assert.equal( + formatEventText({ kind: 'arbitration_decided', payload: { summary: 'Ship it' } }), + 'Arbitration decided: Ship it', + ); + }); + + it('formats human_accepted', () => { + assert.equal( + formatEventText({ kind: 'human_accepted', payload: { from: 'REVIEW_READY', to: 'DONE' } }), + 'Accepted: REVIEW_READY → DONE', + ); + }); + + it('formats retro_captured with proposal count', () => { + assert.equal( + formatEventText({ kind: 'retro_captured', payload: { proposals: [{ role: 'builder', version: 2 }], summary: 'Went well' } }), + 'Retro captured (1 config proposal): Went well', + ); + assert.equal( + formatEventText({ kind: 'retro_captured', payload: { proposals: [], summary: 'No changes needed' } }), + 'Retro captured (0 config proposals): No changes needed', + ); + }); + + it('formats epic_proposed', () => { + assert.equal( + formatEventText({ kind: 'epic_proposed', payload: { name: 'Search revamp', story_ids: ['a', 'b', 'c'] } }), + 'Epic proposed: Search revamp (3 stories)', + ); + }); +}); -- cgit v1.2.3