Files
BountyBoard/web/static/js/atomization.js
T
etalon 70cb664246 feat: @-mention autocomplete; consultant bounty-board access; clickable atomization tasks
- @-mentions: typing "@" in task comments, the messages composer and the chat
  widget now opens a user picker (new shared mention.js) that inserts "@Name ".
  Backed by the existing /api/v1/users search; menus de-dupe per field.
- Consultants can now open the bounty board (read-only): nav link + page/API
  access extended to consultants, board visibility resolves their assigned
  customers, and cards show "Open" instead of claim actions for non-developers.
- Atomization board task titles (roots and subtasks) link to /tasks/{id} so
  consultants can reach the detail view (comments, assignment, timeline).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:54:44 +02:00

324 lines
14 KiB
JavaScript

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) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[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 = '<p class="muted">No tasks on the atomization board for this selection.</p>';
}
checkHealth();
}
function statusBadge(t) {
const cls = { atomizing: 'warn', published: 'ok' }[t.status] || '';
return `<span class="badge" style="${cls === 'ok' ? 'color:var(--ok)' : cls === 'warn' ? 'color:var(--warn)' : ''}">${esc(t.status)}</span>`;
}
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
? '<span class="badge" style="color:var(--err)" title="No longer returned by the upstream system">orphaned</span>' : '';
card.innerHTML = `
<div class="spread">
<h3>${icon} <a href="/tasks/${root.id}">${esc(root.title)}</a> ${statusBadge(root)} ${orphan}</h3>
<span class="muted">${esc(cust ? cust.name : '')} ${root.external ? '· ' + esc(root.external.key) : ''}</span>
</div>
<p class="muted">${esc((root.description || '').slice(0, 240))}</p>
<div class="spread">
<span>Budget <strong>${root.budget}</strong> · bounty preview <strong>${root.bounty}</strong></span>
<span>
${root.external && root.external.url ? `<a class="btn small" href="${esc(root.external.url)}" target="_blank" rel="noopener">source ↗</a>` : ''}
<button class="btn small" data-act="edit">Edit</button>
<button class="btn small primary" data-act="subdivide" data-needs-atomizer ${root.status === 'atomizing' ? 'disabled' : ''}>Subdivide</button>
<button class="btn small" data-act="extend" data-needs-atomizer>Extend</button>
<button class="btn small" data-act="archive">Archive</button>
</span>
</div>
<div class="children"></div>`;
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: <strong style="color:${bad ? 'var(--err)' : 'var(--ok)'}">${sum.toFixed(2)}</strong>` +
(hasExtension ? ' <span class="muted">(extensions allow > 1.00)</span>'
: 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' ? '<span class="badge">extension</span>' : '';
row.innerHTML = `
<div class="spread">
<span>
${k.status === 'atomized' ? `<input type="checkbox" data-sel aria-label="Select ${esc(k.title)} for publishing" ${selected.has(k.id) ? 'checked' : ''}>` : ''}
<a href="/tasks/${k.id}"><strong>${esc(k.title)}</strong></a> ${ext} ${statusBadge(k)}
</span>
<span>bounty <strong data-bounty>${k.bounty}</strong></span>
</div>
<p class="muted" style="margin:4px 0">${esc((k.description || '').slice(0, 160))}</p>
<div class="spread">
<label style="flex:1;font-weight:normal">
<span class="muted">effort <span data-coeff>${k.effortCoefficient.toFixed(2)}</span></span>
<input type="range" min="0.01" max="${k.origin === 'extended' ? 2 : 1}" step="0.01"
value="${k.effortCoefficient}" ${['atomized', 'published'].includes(k.status) ? '' : 'disabled'}
aria-label="Effort coefficient for ${esc(k.title)}">
</label>
<span>
<button class="btn small" data-act="edit">Edit</button>
${['imported', 'atomized'].includes(k.status)
? '<button class="btn small" data-act="subdivide" data-needs-atomizer>Subdivide</button>' : ''}
<button class="btn small" data-act="extend" data-needs-atomizer>Extend</button>
${k.status === 'atomized' ? '<button class="btn small primary" data-act="publish">Publish</button>' : ''}
<button class="btn small" data-act="archive">Archive</button>
</span>
</div>`;
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));
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));
});
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();