import { api, toast } from '/static/js/api.js'; import { subscribe } from '/static/js/ws.js'; import { attachMentions, mentionHTML } from '/static/js/mention.js'; const root = document.getElementById('task-root'); const errorBox = document.getElementById('error'); const taskId = root.dataset.taskId; const meId = root.dataset.userId; const isConsultant = root.dataset.isConsultant === '1' || root.dataset.isAdmin === '1'; let task = null; const userCache = new Map(); const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }[c])); async function userName(id) { if (!id || id.startsWith('system')) return id || 'system'; if (!userCache.has(id)) { try { const res = await api('GET', `/api/v1/users/${id}/card`); userCache.set(id, res.card.name); } catch (e) { userCache.set(id, id.slice(0, 8)); } } return userCache.get(id); } async function load() { try { const res = await api('GET', `/api/v1/tasks/${taskId}`); task = res.task; await render(); } catch (e) { errorBox.textContent = e.message; root.innerHTML = ''; } } const mine = () => task.assignee && task.assignee.kind === 'human' && task.assignee.userId === meId; const myClaim = () => (task.claimRequests || []).some((c) => c.developerId === meId); async function render() { const t = task; const assigneeName = t.assignee ? (t.assignee.kind === 'ai' ? `AI (job ${esc(t.assignee.jobId || '')})` : esc(await userName(t.assignee.userId))) : '—'; const orphan = t.external && t.external.orphaned ? 'orphaned upstream' : ''; const claims = await Promise.all((t.claimRequests || []).map(async (c) => `
  • ${esc(await userName(c.developerId))}${c.note ? ' — ' + esc(c.note) : ''} ${isConsultant ? ` ` : ''}
  • `)); const comments = await Promise.all((t.comments || []).map(async (c) => `

    ${esc(await userName(c.authorId))} · ${new Date(c.at).toLocaleString()}

    ${c.body}
    `)); const timeline = await Promise.all((t.timeline || []).slice().reverse().map(async (e) => ` ${new Date(e.at).toLocaleString()} ${esc(await userName(e.actorId))} ${esc(e.event)}${e.data && e.data.note ? ' — ' + esc(e.data.note) : ''}`)); const timeRows = (t.timeLog || []).map((e) => `${new Date(e.at).toLocaleString()}${e.minutes} min${esc(e.note)}`).join(''); const totalMin = (t.timeLog || []).reduce((a, e) => a + e.minutes, 0); root.innerHTML = `

    ${esc(t.title)}

    ◈ ${t.bounty}

    ${esc(t.status)} ${orphan} ${t.external ? `· ${esc(t.external.key)} ↗` : ''} · assignee: ${assigneeName} ${totalMin ? `· time logged: ${Math.floor(totalMin / 60)}h ${totalMin % 60}m` : ''}

    Description

    ${esc(t.description)}

    ${(t.acceptanceCriteria || []).length ? `

    Acceptance criteria

    ` : ''} ${(t.attachments || []).length ? `

    Attachments

    ` : ''} ${(t.links || []).length ? `

    Links

    ` : ''}
    ${(t.claimRequests || []).length ? `

    Assignment requests

    ` : ''}

    Comments

    ${comments.join('') || '

    No comments yet.

    '}
    ${mine() ? `

    Log time

    ${timeRows ? `${timeRows}
    WhenTimeNote
    ` : ''}
    ` : ''}

    Timeline

    ${timeline.join('')}
    WhenWhoEvent
    `; renderActions(); wireHandlers(); } function renderActions() { const host = document.getElementById('actions'); const t = task; const add = (label, fn, primary) => { const b = document.createElement('button'); b.className = 'btn' + (primary ? ' primary' : ''); b.style.marginRight = '8px'; b.textContent = label; b.addEventListener('click', fn); host.appendChild(b); }; const post = (path, body = {}) => async () => { try { await api('POST', `/api/v1/tasks/${taskId}/${path}`, body); load(); } catch (e) { toast(e.message, 'err'); } }; // link to the upstream ticket (same affordance as the atomization board) if (t.external && t.external.url) { const a = document.createElement('a'); a.className = 'btn'; a.style.marginRight = '8px'; a.href = t.external.url; a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'Source ↗'; host.appendChild(a); } if (root.dataset.isDeveloper === '1') { if (t.status === 'published' && !myClaim()) add('Request assignment', post('claim', { note: '' }), true); if (myClaim() && ['published', 'claim_requested'].includes(t.status)) add('Withdraw request', post('claim/withdraw')); if (mine() && ['assigned', 'changes_requested'].includes(t.status)) add('Start work', post('start'), true); if (mine() && t.status === 'in_progress') add('Submit for review', post('submit-review'), true); if (mine() && ['assigned', 'in_progress'].includes(t.status)) add('Abandon', post('abandon')); } if (isConsultant) { if (t.status === 'in_review') { add('Review…', openReview, true); } if (t.status === 'published') add('Assign to AI…', () => document.getElementById('ai-dialog').showModal(), false); if (['assigned', 'in_progress'].includes(t.status)) add('Unassign', post('unassign')); if (t.status !== 'archived' && t.status !== 'approved') add('Archive', post('archive')); } } function wireHandlers() { document.querySelectorAll('[data-approve]').forEach((b) => { b.addEventListener('click', async () => { try { await api('POST', `/api/v1/tasks/${taskId}/approve-claim`, { developerId: b.dataset.approve }); load(); } catch (e) { toast(e.message, 'err'); } }); }); document.querySelectorAll('[data-decline]').forEach((b) => { b.addEventListener('click', async () => { try { await api('POST', `/api/v1/tasks/${taskId}/decline-claim`, { developerId: b.dataset.decline }); load(); } catch (e) { toast(e.message, 'err'); } }); }); document.getElementById('comment-form').addEventListener('submit', async (e) => { e.preventDefault(); const field = document.getElementById('comment-body'); const body = mentionHTML(field.value, field).replace(/\n/g, '
    '); try { await api('POST', `/api/v1/tasks/${taskId}/comments`, { body: '

    ' + body + '

    ' }); load(); } catch (err) { toast(err.message, 'err'); } }); attachMentions(document.getElementById('comment-body')); const timeForm = document.getElementById('time-form'); if (timeForm) { timeForm.addEventListener('submit', async (e) => { e.preventDefault(); try { await api('POST', `/api/v1/tasks/${taskId}/time`, { minutes: parseInt(document.getElementById('time-minutes').value, 10), note: document.getElementById('time-note').value.trim(), }); load(); } catch (err) { toast(err.message, 'err'); } }); } } // ---- review dialog with per-AC checklist (§11.18) ---- const reviewDlg = document.getElementById('review-dialog'); function openReview() { const host = document.getElementById('review-checklist'); host.replaceChildren(...(task.acceptanceCriteria || []).map((c, i) => { const div = document.createElement('div'); div.innerHTML = ``; return div; })); reviewDlg.showModal(); } document.getElementById('review-cancel').addEventListener('click', () => reviewDlg.close()); async function submitReview(decision) { const checklist = [...document.querySelectorAll('[data-ac]')].map((cb, i) => ({ criterion: task.acceptanceCriteria[i], ok: cb.checked, })); try { await api('POST', `/api/v1/tasks/${taskId}/review`, { decision, note: document.getElementById('review-note').value.trim(), checklist, }); reviewDlg.close(); toast(decision === 'approve' ? 'Approved — bounty awarded.' : 'Changes requested.', 'ok'); load(); } catch (e) { toast(e.message, 'err'); } } document.getElementById('review-approve').addEventListener('click', () => submitReview('approve')); document.getElementById('review-changes').addEventListener('click', () => submitReview('request_changes')); // ---- AI dialog ---- const aiDlg = document.getElementById('ai-dialog'); document.getElementById('ai-cancel').addEventListener('click', () => aiDlg.close()); document.getElementById('ai-form').addEventListener('submit', async (e) => { e.preventDefault(); try { await api('POST', `/api/v1/tasks/${taskId}/assign-ai`, { context: { repositoryUrl: document.getElementById('ai-repo').value.trim(), branch: document.getElementById('ai-branch').value.trim(), instructions: document.getElementById('ai-instructions').value.trim(), }, }); aiDlg.close(); toast('AI job created — the result will arrive for review.', 'ok'); load(); } catch (err) { toast(err.message, 'err'); } }); subscribe('board', (event, data) => { if (data && data.taskId === taskId) setTimeout(load, 200); }); load();