summaryrefslogtreecommitdiff
path: root/web/app.js
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-25 23:28:22 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-25 23:28:22 +0000
commit36d50b5049996064fbdcb9338e70d4f81c2e6873 (patch)
tree8c48a6e7986f167c0c927510f7bd65aeb2143ec6 /web/app.js
parent053ec03306f37e354c013beb8b4baf8eae27af97 (diff)
feat: add Stories UI β€” tab, list, new story elaborate/approve flow
- Stories tab (πŸ“–) with story card list, status badges, project/branch meta - New Story modal: project picker + goal textarea + AI elaboration β†’ plan review β†’ approve & queue - Story detail modal: status, project, branch, created date - Export storyStatusLabel() and renderStoryCard() with 16 unit tests - CSS for story cards, status badges, and modals Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'web/app.js')
-rw-r--r--web/app.js320
1 files changed, 320 insertions, 0 deletions
diff --git a/web/app.js b/web/app.js
index 2241c67..25bdee6 100644
--- a/web/app.js
+++ b/web/app.js
@@ -525,6 +525,63 @@ export function computeExecutionStats(executions) {
};
}
+// ── Stories ───────────────────────────────────────────────────────────────────
+
+const STORY_STATUS_LABELS = {
+ PENDING: 'Pending',
+ IN_PROGRESS: 'In Progress',
+ SHIPPABLE: 'Shippable',
+ DEPLOYED: 'Deployed',
+ VALIDATING: 'Validating',
+ REVIEW_READY: 'Review Ready',
+ NEEDS_FIX: 'Needs Fix',
+};
+
+export function storyStatusLabel(status) {
+ return STORY_STATUS_LABELS[status] || status;
+}
+
+export function renderStoryCard(story, doc = document) {
+ const card = doc.createElement('div');
+ card.className = 'story-card';
+ card.dataset.storyId = story.id;
+
+ const header = doc.createElement('div');
+ header.className = 'story-card-header';
+
+ const name = doc.createElement('span');
+ name.className = 'story-name';
+ name.textContent = story.name;
+ header.appendChild(name);
+
+ const badge = doc.createElement('span');
+ badge.className = 'story-status-badge';
+ badge.dataset.status = story.status;
+ badge.textContent = storyStatusLabel(story.status);
+ header.appendChild(badge);
+
+ card.appendChild(header);
+
+ const meta = doc.createElement('div');
+ meta.className = 'story-meta';
+
+ const project = doc.createElement('span');
+ project.className = 'story-project';
+ project.textContent = story.project_id || 'β€”';
+ meta.appendChild(project);
+
+ if (story.branch_name) {
+ const branch = doc.createElement('span');
+ branch.className = 'story-branch';
+ branch.textContent = story.branch_name;
+ meta.appendChild(branch);
+ }
+
+ card.appendChild(meta);
+
+ return card;
+}
+
export function updateFilterTabs() {
const current = getTaskFilterTab();
document.querySelectorAll('.filter-tab[data-filter]').forEach(el => {
@@ -1193,6 +1250,9 @@ function renderActiveTab(allTasks) {
.then(([execs, agentData, dashStats]) => renderStatsPanel(allTasks, execs, agentData, dashStats))
.catch(() => {});
break;
+ case 'stories':
+ renderStoriesPanel();
+ break;
case 'drops':
renderDropsPanel();
break;
@@ -3019,6 +3079,180 @@ async function fetchDrops() {
return res.json();
}
+// ── Stories panel ─────────────────────────────────────────────────────────────
+
+async function renderStoriesPanel() {
+ const panel = document.querySelector('[data-panel="stories"]');
+ if (!panel) return;
+
+ let stories;
+ try {
+ const res = await fetch(`${BASE_PATH}/api/stories`);
+ stories = res.ok ? await res.json() : [];
+ } catch {
+ panel.innerHTML = '<p class="task-meta" style="padding:1rem">Failed to load stories.</p>';
+ return;
+ }
+
+ panel.innerHTML = '';
+
+ const toolbar = document.createElement('div');
+ toolbar.className = 'stories-toolbar';
+ const btnNew = document.createElement('button');
+ btnNew.className = 'btn-primary';
+ btnNew.textContent = 'New Story';
+ btnNew.addEventListener('click', openStoryModal);
+ toolbar.appendChild(btnNew);
+ panel.appendChild(toolbar);
+
+ if (!stories || stories.length === 0) {
+ const empty = document.createElement('p');
+ empty.className = 'task-empty';
+ empty.textContent = 'No stories yet. Create one to get started.';
+ panel.appendChild(empty);
+ return;
+ }
+
+ const list = document.createElement('div');
+ list.className = 'stories-list';
+ for (const story of stories) {
+ const card = renderStoryCard(story);
+ card.addEventListener('click', () => openStoryDetail(story));
+ list.appendChild(card);
+ }
+ panel.appendChild(list);
+}
+
+function openStoryDetail(story) {
+ const modal = document.getElementById('story-detail-modal');
+ if (!modal) return;
+
+ document.getElementById('story-detail-name').textContent = story.name;
+
+ const body = document.getElementById('story-detail-body');
+ body.innerHTML = '';
+
+ function addRow(label, value) {
+ const row = document.createElement('div');
+ row.className = 'meta-item';
+ const lbl = document.createElement('div');
+ lbl.className = 'meta-label';
+ lbl.textContent = label;
+ const val = document.createElement('div');
+ val.className = 'meta-value';
+ val.textContent = value || 'β€”';
+ row.appendChild(lbl);
+ row.appendChild(val);
+ body.appendChild(row);
+ }
+
+ const badge = document.createElement('span');
+ badge.className = 'story-status-badge';
+ badge.dataset.status = story.status;
+ badge.textContent = storyStatusLabel(story.status);
+
+ const statusRow = document.createElement('div');
+ statusRow.className = 'meta-item';
+ const statusLbl = document.createElement('div');
+ statusLbl.className = 'meta-label';
+ statusLbl.textContent = 'Status';
+ statusRow.appendChild(statusLbl);
+ statusRow.appendChild(badge);
+ body.appendChild(statusRow);
+
+ addRow('Project', story.project_id);
+ addRow('Branch', story.branch_name);
+ addRow('Created', story.created_at ? new Date(story.created_at).toLocaleString() : 'β€”');
+
+ modal.showModal();
+}
+
+function openStoryModal() {
+ const modal = document.getElementById('story-modal');
+ if (!modal) return;
+
+ // Reset form state
+ document.getElementById('story-goal').value = '';
+ const planArea = document.getElementById('story-plan-area');
+ planArea.innerHTML = '';
+ planArea.setAttribute('hidden', '');
+
+ const btnElaborate = document.getElementById('btn-story-elaborate');
+ btnElaborate.disabled = false;
+ btnElaborate.textContent = 'Elaborate with AI ✦';
+
+ const btnApprove = document.getElementById('btn-story-approve');
+ btnApprove.setAttribute('hidden', '');
+ btnApprove._elaboratedPlan = null;
+
+ // Populate project dropdown
+ fetch(`${BASE_PATH}/api/projects`)
+ .then(r => r.ok ? r.json() : [])
+ .then(projects => {
+ const sel = document.getElementById('story-project');
+ sel.innerHTML = '';
+ for (const p of projects) {
+ const opt = document.createElement('option');
+ opt.value = p.id;
+ opt.textContent = p.name;
+ sel.appendChild(opt);
+ }
+ })
+ .catch(() => {});
+
+ modal.showModal();
+}
+
+function renderElaboratedPlan(plan) {
+ const planArea = document.getElementById('story-plan-area');
+ planArea.innerHTML = '';
+ planArea.removeAttribute('hidden');
+
+ const nameEl = document.createElement('p');
+ nameEl.className = 'story-plan-name';
+ nameEl.textContent = `Story: ${plan.name}`;
+ planArea.appendChild(nameEl);
+
+ if (plan.branch_name) {
+ const branchEl = document.createElement('p');
+ branchEl.className = 'story-plan-branch';
+ branchEl.textContent = `Branch: ${plan.branch_name}`;
+ planArea.appendChild(branchEl);
+ }
+
+ if (plan.tasks && plan.tasks.length > 0) {
+ const tasksHeader = document.createElement('p');
+ tasksHeader.className = 'story-plan-section';
+ tasksHeader.textContent = `Tasks (${plan.tasks.length}):`;
+ planArea.appendChild(tasksHeader);
+
+ const taskList = document.createElement('ol');
+ taskList.className = 'story-plan-tasks';
+ for (const t of plan.tasks) {
+ const li = document.createElement('li');
+ li.textContent = t.name;
+ if (t.subtasks && t.subtasks.length > 0) {
+ const subList = document.createElement('ul');
+ for (const s of t.subtasks) {
+ const subLi = document.createElement('li');
+ subLi.textContent = s.name;
+ subList.appendChild(subLi);
+ }
+ li.appendChild(subList);
+ }
+ taskList.appendChild(li);
+ }
+ planArea.appendChild(taskList);
+ }
+
+ if (plan.validation && plan.validation.type) {
+ const valHeader = document.createElement('p');
+ valHeader.className = 'story-plan-section';
+ valHeader.textContent = `Validation: ${plan.validation.type}`;
+ planArea.appendChild(valHeader);
+ }
+}
+
async function renderDropsPanel() {
const panel = document.querySelector('[data-panel="drops"] .drops-panel');
if (!panel) return;
@@ -3263,5 +3497,91 @@ if (typeof document !== 'undefined') {
btn.textContent = 'Create & Queue';
}
});
+
+ // Story modal
+ const storyModal = document.getElementById('story-modal');
+ if (storyModal) {
+ document.getElementById('btn-close-story-modal').addEventListener('click', () => storyModal.close());
+
+ document.getElementById('btn-story-elaborate').addEventListener('click', async () => {
+ const btn = document.getElementById('btn-story-elaborate');
+ const goal = document.getElementById('story-goal').value.trim();
+ const projectId = document.getElementById('story-project').value;
+
+ if (!goal) {
+ const errEl = document.createElement('p');
+ errEl.className = 'form-error';
+ errEl.textContent = 'Please enter a goal before elaborating.';
+ storyModal.querySelector('.story-modal-body').appendChild(errEl);
+ return;
+ }
+
+ storyModal.querySelectorAll('.form-error').forEach(el => el.remove());
+ btn.disabled = true;
+ btn.textContent = 'Elaborating…';
+
+ try {
+ const res = await fetch(`${BASE_PATH}/api/stories/elaborate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ goal, project_id: projectId }),
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: res.statusText }));
+ throw new Error(err.error || res.statusText);
+ }
+ const plan = await res.json();
+ renderElaboratedPlan(plan);
+
+ const btnApprove = document.getElementById('btn-story-approve');
+ btnApprove._elaboratedPlan = { ...plan, project_id: projectId };
+ btnApprove.removeAttribute('hidden');
+ } catch (err) {
+ const errEl = document.createElement('p');
+ errEl.className = 'form-error';
+ errEl.textContent = `Elaboration failed: ${err.message}`;
+ storyModal.querySelector('.story-modal-body').appendChild(errEl);
+ } finally {
+ btn.disabled = false;
+ btn.textContent = 'Elaborate with AI ✦';
+ }
+ });
+
+ document.getElementById('btn-story-approve').addEventListener('click', async () => {
+ const btn = document.getElementById('btn-story-approve');
+ const plan = btn._elaboratedPlan;
+ if (!plan) return;
+
+ btn.disabled = true;
+ btn.textContent = 'Approving…';
+
+ try {
+ const res = await fetch(`${BASE_PATH}/api/stories/approve`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(plan),
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: res.statusText }));
+ throw new Error(err.error || res.statusText);
+ }
+ storyModal.close();
+ renderStoriesPanel();
+ } catch (err) {
+ const errEl = document.createElement('p');
+ errEl.className = 'form-error';
+ errEl.textContent = `Approve failed: ${err.message}`;
+ storyModal.querySelector('.story-modal-body').appendChild(errEl);
+ btn.disabled = false;
+ btn.textContent = 'Approve & Queue';
+ }
+ });
+ }
+
+ // Story detail modal
+ const storyDetailModal = document.getElementById('story-detail-modal');
+ if (storyDetailModal) {
+ document.getElementById('btn-close-story-detail').addEventListener('click', () => storyDetailModal.close());
+ }
});
}