import { api, toast } from '/static/js/api.js';
import { subscribe } from '/static/js/ws.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.acceptanceCriteria.map((c) => `- ${esc(c)}
`).join('')}
` : ''}
${(t.attachments || []).length ? `
Attachments
` : ''}
${(t.links || []).length ? `
Links
` : ''}
${(t.claimRequests || []).length ? `
` : ''}
Comments
${comments.join('') || '
No comments yet.
'}
${mine() ? `
Log time
${timeRows ? `
` : ''}
` : ''}
Timeline
| When | Who | Event |
${timeline.join('')}
`;
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'); }
};
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 body = document.getElementById('comment-body').value;
try {
await api('POST', `/api/v1/tasks/${taskId}/comments`, { body: '' + esc(body).replace(/\n/g, '
') + '
' });
load();
} catch (err) { toast(err.message, 'err'); }
});
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();