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(); // Hold Ctrl/Cmd while dragging an effort slider to rebalance siblings. let ctrlHeld = false; window.addEventListener('keydown', (e) => { if (e.key === 'Control' || e.key === 'Meta') ctrlHeld = true; }); window.addEventListener('keyup', (e) => { if (e.key === 'Control' || e.key === 'Meta') ctrlHeld = false; }); window.addEventListener('blur', () => { ctrlHeld = false; }); 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 hasExtension = kids.some((k) => k.origin === 'extended'); const subSiblings = []; // {k, slider, coeffEl, bountyEl} for adjustable subdivided rows let ind = null; function updateSumIndicator() { if (!ind) return; const s = subSiblings.reduce((a, x) => a + parseFloat(x.slider.value), 0); const bad = !hasExtension && Math.abs(s - 1) > 0.005; ind.innerHTML = `Coefficient sum: ${s.toFixed(2)}` + (hasExtension ? ' (extensions allow > 1.00)' : bad ? ' — should be 1.00' : '') + ' · hold Ctrl while dragging to rebalance the others'; } if (subdivided.length) { ind = document.createElement('p'); 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))}

${['imported', 'atomized'].includes(k.status) ? '' : ''} ${k.status === 'atomized' ? '' : ''}
`; const slider = row.querySelector('input[type=range]'); const coeffEl = row.querySelector('[data-coeff]'); const bountyEl = row.querySelector('[data-bounty]'); if (k.origin === 'subdivided' && !slider.disabled) { subSiblings.push({ k, slider, coeffEl, bountyEl }); } let sliderTimer = null; const round2 = (n) => Math.round(n * 100) / 100; slider.addEventListener('input', () => { const newVal = parseFloat(slider.value); coeffEl.textContent = newVal.toFixed(2); bountyEl.textContent = (newVal * k.budget).toFixed(2); const changed = [{ k, value: newVal }]; // Ctrl/Cmd held: rebalance the other subdivided siblings so the sum // stays ~1.00 (increase one → the rest shrink proportionally). if (ctrlHeld && k.origin === 'subdivided') { const others = subSiblings.filter((s) => s.k.id !== k.id); const curOthers = others.reduce((a, s) => a + parseFloat(s.slider.value), 0); const targetOthers = Math.max(0, 1 - newVal); if (others.length && curOthers > 0) { const scale = targetOthers / curOthers; others.forEach((s) => { const max = parseFloat(s.slider.max); const nv = round2(Math.max(0.01, Math.min(max, parseFloat(s.slider.value) * scale))); s.slider.value = nv; s.coeffEl.textContent = nv.toFixed(2); s.bountyEl.textContent = (nv * s.k.budget).toFixed(2); changed.push({ k: s.k, value: nv }); }); } updateSumIndicator(); } clearTimeout(sliderTimer); sliderTimer = setTimeout(async () => { try { for (const ch of changed) { const res = await api('PATCH', `/api/v1/tasks/${ch.k.id}`, { effortCoefficient: ch.value, version: ch.k.version, }); ch.k.version = res.task.version; ch.k.effortCoefficient = res.task.effortCoefficient; ch.k.bounty = res.task.bounty; } render(); } catch (e) { if (e.status === 409) { toast('Task changed elsewhere — reloading.', 'err'); load(); } else toast(e.message, 'err'); } }, 450); }); 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)); const sd = row.querySelector('[data-act=subdivide]'); if (sd) sd.addEventListener('click', () => openSubdivide(k)); 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)); }); updateSumIndicator(); 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();