import { api, toast } from '/static/js/api.js'; import { subscribe, onPollFallback } from '/static/js/ws.js'; const errorBox = document.getElementById('error'); const board = document.getElementById('board'); const customerSel = document.getElementById('board-customer'); const icons = { epic: '◆', story: '▣', task: '▢' }; let customers = []; let tasks = []; let atomizerUp = true; const selected = new Set(); function esc(s) { return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }[c])); } async function checkHealth() { const badge = document.getElementById('atomizer-health'); try { const res = await api('GET', '/api/v1/service-health'); const a = res.atomizer; atomizerUp = a.healthy && a.breaker !== 'open'; badge.textContent = atomizerUp ? `atomizer healthy · ${a.latencyMs} ms` : `atomizer ${a.healthy ? 'breaker ' + a.breaker : 'DOWN'} — Subdivide/Extend disabled`; badge.style.color = atomizerUp ? 'var(--ok)' : 'var(--err)'; } catch (e) { atomizerUp = false; badge.textContent = 'atomizer status unknown'; } document.querySelectorAll('[data-needs-atomizer]').forEach((b) => { b.disabled = !atomizerUp; b.title = atomizerUp ? '' : 'Atomization Service is unavailable'; }); } async function load() { try { const cid = customerSel.value || ''; const res = await api('GET', `/api/v1/consultant/board?customerId=${encodeURIComponent(cid)}`); customers = res.customers; tasks = res.tasks; if (!customerSel.options.length) { customerSel.replaceChildren( new Option('All customers', ''), ...customers.map((c) => new Option(c.name, c.id)), ); customerSel.value = new URLSearchParams(location.search).get('customerId') || ''; if (customerSel.value) return load(); } render(); } catch (e) { errorBox.textContent = e.message; } } function childrenOf(id) { return tasks.filter((t) => t.parentId === id); } function render() { selected.forEach((id) => { if (!tasks.find((t) => t.id === id)) selected.delete(id); }); updateBulkButton(); const roots = tasks.filter((t) => !t.parentId); board.replaceChildren(...roots.map(renderRoot)); if (!roots.length) { board.innerHTML = '

No tasks on the atomization board for this selection.

'; } checkHealth(); } function statusBadge(t) { const cls = { atomizing: 'warn', published: 'ok' }[t.status] || ''; return `${esc(t.status)}`; } function renderRoot(root) { const card = document.createElement('div'); card.className = 'card' + (root.status === 'atomizing' ? ' shimmer' : ''); const cust = customers.find((c) => c.id === root.customerId); const icon = root.external ? (icons[root.external.type] || '▢') : '▢'; const orphan = root.external && root.external.orphaned ? 'orphaned' : ''; card.innerHTML = `

${icon} ${esc(root.title)} ${statusBadge(root)} ${orphan}

${esc(cust ? cust.name : '')} ${root.external ? '· ' + esc(root.external.key) : ''}

${esc((root.description || '').slice(0, 240))}

Budget ${root.budget} · bounty preview ${root.bounty} ${root.external && root.external.url ? `source ↗` : ''}
`; card.querySelector('[data-act=subdivide]').addEventListener('click', () => openSubdivide(root)); card.querySelector('[data-act=extend]').addEventListener('click', () => openExtend(root)); card.querySelector('[data-act=edit]').addEventListener('click', () => openEdit(root, true)); card.querySelector('[data-act=archive]').addEventListener('click', () => archive(root)); const kids = childrenOf(root.id); if (kids.length) { card.querySelector('.children').appendChild(renderChildren(root, kids, 1)); } return card; } function renderChildren(parent, kids, depth) { const wrap = document.createElement('div'); wrap.style.marginLeft = `${depth * 16}px`; wrap.className = 'stack mt'; const subdivided = kids.filter((k) => k.origin === 'subdivided'); const sum = subdivided.reduce((acc, k) => acc + k.effortCoefficient, 0); const hasExtension = kids.some((k) => k.origin === 'extended'); if (subdivided.length) { const ind = document.createElement('p'); const bad = !hasExtension && Math.abs(sum - 1) > 0.005; ind.innerHTML = `Coefficient sum: ${sum.toFixed(2)}` + (hasExtension ? ' (extensions allow > 1.00)' : bad ? ' — should be 1.00' : ''); wrap.appendChild(ind); } kids.forEach((k) => { const row = document.createElement('div'); row.className = 'card' + (k.status === 'atomizing' ? ' shimmer' : ''); row.style.padding = '12px 16px'; const ext = k.origin === 'extended' ? 'extension' : ''; row.innerHTML = `
${k.status === 'atomized' ? `` : ''} ${esc(k.title)} ${ext} ${statusBadge(k)} bounty ${k.bounty}

${esc((k.description || '').slice(0, 160))}

${k.status === 'atomized' ? '' : ''}
`; const slider = row.querySelector('input[type=range]'); let sliderTimer = null; slider.addEventListener('input', () => { row.querySelector('[data-coeff]').textContent = parseFloat(slider.value).toFixed(2); row.querySelector('[data-bounty]').textContent = (slider.value * k.budget).toFixed(2); clearTimeout(sliderTimer); sliderTimer = setTimeout(async () => { try { const res = await api('PATCH', `/api/v1/tasks/${k.id}`, { effortCoefficient: parseFloat(slider.value), version: k.version, }); k.version = res.task.version; k.effortCoefficient = res.task.effortCoefficient; k.bounty = res.task.bounty; render(); } catch (e) { if (e.status === 409) { toast('Task changed elsewhere — reloading.', 'err'); load(); } else toast(e.message, 'err'); } }, 400); }); const sel = row.querySelector('[data-sel]'); if (sel) { sel.addEventListener('change', () => { if (sel.checked) selected.add(k.id); else selected.delete(k.id); updateBulkButton(); }); } row.querySelector('[data-act=edit]').addEventListener('click', () => openEdit(k, false)); row.querySelector('[data-act=extend]').addEventListener('click', () => openExtend(k)); row.querySelector('[data-act=archive]').addEventListener('click', () => archive(k)); const pub = row.querySelector('[data-act=publish]'); if (pub) { pub.addEventListener('click', async () => { try { await api('POST', `/api/v1/tasks/${k.id}/publish`, {}); toast('Published.', 'ok'); load(); } catch (e) { toast(e.message, 'err'); } }); } wrap.appendChild(row); const grand = childrenOf(k.id); if (grand.length) wrap.appendChild(renderChildren(k, grand, depth + 1)); }); return wrap; } async function archive(t) { if (!confirm(`Archive "${t.title}"?`)) return; try { await api('POST', `/api/v1/tasks/${t.id}/archive`, {}); load(); } catch (e) { toast(e.message, 'err'); } } function updateBulkButton() { const btn = document.getElementById('bulk-publish'); btn.textContent = `Publish selected (${selected.size})`; btn.disabled = selected.size === 0; } document.getElementById('select-all').addEventListener('click', () => { tasks.filter((t) => t.status === 'atomized').forEach((t) => selected.add(t.id)); render(); }); document.getElementById('bulk-publish').addEventListener('click', async () => { try { const res = await api('POST', '/api/v1/tasks/publish', { ids: [...selected] }); toast(`Published ${res.published.length} task(s)` + (Object.keys(res.failed).length ? `, ${Object.keys(res.failed).length} failed` : '.'), Object.keys(res.failed).length ? 'err' : 'ok'); selected.clear(); load(); } catch (e) { toast(e.message, 'err'); } }); // ---- dialogs ---- let dialogTask = null; const sdDlg = document.getElementById('subdivide-dialog'); function openSubdivide(t) { dialogTask = t; document.getElementById('subdivide-task-title').textContent = t.title; document.getElementById('sd-replace-warning').hidden = !childrenOf(t.id).length; sdDlg.showModal(); } document.getElementById('sd-cancel').addEventListener('click', () => sdDlg.close()); document.getElementById('subdivide-form').addEventListener('submit', async (e) => { e.preventDefault(); try { await api('POST', `/api/v1/tasks/${dialogTask.id}/subdivide`, { note: document.getElementById('sd-note').value.trim(), constraints: { minTasks: parseInt(document.getElementById('sd-min').value, 10) || 0, maxTasks: parseInt(document.getElementById('sd-max').value, 10) || 0, }, confirmReplace: !document.getElementById('sd-replace-warning').hidden, }); sdDlg.close(); toast('Subdivision queued — results stream in shortly.', 'ok'); load(); } catch (err) { toast(err.message, 'err'); } }); const exDlg = document.getElementById('extend-dialog'); function openExtend(t) { dialogTask = t; document.getElementById('extend-task-title').textContent = t.title; exDlg.showModal(); } document.getElementById('ex-cancel').addEventListener('click', () => exDlg.close()); document.getElementById('extend-form').addEventListener('submit', async (e) => { e.preventDefault(); try { await api('POST', `/api/v1/tasks/${dialogTask.id}/extend`, { note: document.getElementById('ex-note').value.trim(), }); exDlg.close(); document.getElementById('ex-note').value = ''; toast('Extension queued.', 'ok'); } catch (err) { toast(err.message, 'err'); } }); const edDlg = document.getElementById('edit-dialog'); function openEdit(t, isRoot) { dialogTask = t; document.getElementById('ed-title').value = t.title; document.getElementById('ed-desc').value = t.description || ''; document.getElementById('ed-ac').value = (t.acceptanceCriteria || []).join('\n'); document.getElementById('ed-budget').value = t.budget; document.getElementById('ed-cascade-wrap').style.display = isRoot ? '' : 'none'; edDlg.showModal(); } document.getElementById('ed-cancel').addEventListener('click', () => edDlg.close()); document.getElementById('edit-form').addEventListener('submit', async (e) => { e.preventDefault(); try { await api('PATCH', `/api/v1/tasks/${dialogTask.id}`, { version: dialogTask.version, title: document.getElementById('ed-title').value.trim(), description: document.getElementById('ed-desc').value, acceptanceCriteria: document.getElementById('ed-ac').value.split('\n').map((s) => s.trim()).filter(Boolean), budget: parseFloat(document.getElementById('ed-budget').value) || 0, cascadeBudget: document.getElementById('ed-cascade').checked && document.getElementById('ed-cascade-wrap').style.display !== 'none', }); edDlg.close(); toast('Saved.', 'ok'); load(); } catch (err) { if (err.status === 409) { toast('Task changed elsewhere — reloading.', 'err'); load(); } else toast(err.message, 'err'); } }); // ---- live updates ---- let reloadTimer = null; function scheduleReload() { clearTimeout(reloadTimer); reloadTimer = setTimeout(load, 300); } subscribe('board', () => scheduleReload()); onPollFallback(load); customerSel.addEventListener('change', load); setInterval(checkHealth, 30000); load();