70cb664246
- @-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>
324 lines
14 KiB
JavaScript
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) => ({
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
}[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();
|