feat: bounty board, task lifecycle, AI work performer flow, notifications (phase 8)
- whitelist HTML sanitizer (stdlib tokenizer) with XSS vector tests - developer board: pool-scoped visibility, customer/search/minBounty/sort filters, stale-task age badges, competing-claims visibility setting, saved filters in profile extras - claims: request/withdraw (developer), approve/decline (consultant) with notifications to winners and losers; unassign/abandon back to board - work tracking: start, sanitized comments with @mention notifications, time logging, submit for review - review queue + review with per-AC checklist stored on the timeline; approve writes the immutable bountyAwards row (human assignees only) - assign-to-AI: §5.2 job submission, HMAC-verified callback endpoint, idempotent by jobId, artifacts downloaded into GridFS, failure path keeps the task assigned with timeline + notification - notifications API + bell with unread badge, dropdown, page, WS toasts - pages: bounty board, my-tasks kanban, task detail (role-driven actions, review dialog, AI dialog), review queue, developer pool Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import { api, toast } from '/static/js/api.js';
|
||||
import { subscribe, onPollFallback } from '/static/js/ws.js';
|
||||
|
||||
const grid = document.getElementById('board-grid');
|
||||
const errorBox = document.getElementById('error');
|
||||
let meId = '';
|
||||
let tasks = [];
|
||||
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
|
||||
function filters() {
|
||||
return {
|
||||
q: document.getElementById('f-q').value.trim(),
|
||||
customerId: document.getElementById('f-customer').value,
|
||||
minBounty: document.getElementById('f-min').value || '0',
|
||||
sort: document.getElementById('f-sort').value,
|
||||
};
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const f = filters();
|
||||
const qs = new URLSearchParams(f).toString();
|
||||
const res = await api('GET', '/api/v1/board?' + qs);
|
||||
tasks = res.tasks;
|
||||
const sel = document.getElementById('f-customer');
|
||||
if (sel.options.length === 1) {
|
||||
res.customers.forEach((c) => sel.add(new Option(c.name, c.id)));
|
||||
}
|
||||
render();
|
||||
} catch (e) { errorBox.textContent = e.message; }
|
||||
}
|
||||
|
||||
function ageBadge(t) {
|
||||
if (!t.publishedAt) return '';
|
||||
const days = (Date.now() - new Date(t.publishedAt).getTime()) / 86400000;
|
||||
if (days > 14) return '<span class="badge" style="color:var(--err)" title="Published more than 14 days ago">14d+</span>';
|
||||
if (days > 7) return '<span class="badge" style="color:var(--warn)" title="Published more than 7 days ago">7d+</span>';
|
||||
return '';
|
||||
}
|
||||
|
||||
function render() {
|
||||
grid.replaceChildren(...tasks.map(renderCard));
|
||||
if (!tasks.length) {
|
||||
grid.innerHTML = '<p class="muted">No published tasks right now. Consultants add you to their pool to share work here.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderCard(t) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
const myClaim = (t.claimRequests || []).some((c) => c.developerId === meId);
|
||||
const others = (t.claimRequests || []).filter((c) => c.developerId !== meId).length;
|
||||
card.innerHTML = `
|
||||
<div class="spread">
|
||||
<span class="badge accent" style="font-size:1rem">◈ ${t.bounty}</span>
|
||||
<span>${ageBadge(t)}</span>
|
||||
</div>
|
||||
<h3 class="mt"><a href="/tasks/${t.id}">${esc(t.title)}</a></h3>
|
||||
<p class="muted">${esc((t.description || '').slice(0, 180))}</p>
|
||||
<p class="muted">${(t.acceptanceCriteria || []).length} acceptance criteria</p>
|
||||
<div class="spread">
|
||||
<span class="muted">
|
||||
${myClaim ? '<span class="badge" style="color:var(--ok)">requested by you</span>' : ''}
|
||||
${others ? `<span class="badge">${others} other request(s)</span>` : ''}
|
||||
</span>
|
||||
${myClaim
|
||||
? '<button class="btn small" data-act="withdraw">Withdraw</button>'
|
||||
: '<button class="btn primary small" data-act="claim">Request assignment</button>'}
|
||||
</div>`;
|
||||
const claimBtn = card.querySelector('[data-act=claim]');
|
||||
if (claimBtn) claimBtn.addEventListener('click', () => openClaim(t));
|
||||
const wBtn = card.querySelector('[data-act=withdraw]');
|
||||
if (wBtn) {
|
||||
wBtn.addEventListener('click', async () => {
|
||||
try { await api('POST', `/api/v1/tasks/${t.id}/claim/withdraw`, {}); load(); }
|
||||
catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
const dlg = document.getElementById('claim-dialog');
|
||||
let claimTask = null;
|
||||
function openClaim(t) {
|
||||
claimTask = t;
|
||||
document.getElementById('claim-task-title').textContent = `${t.title} — bounty ${t.bounty}`;
|
||||
dlg.showModal();
|
||||
}
|
||||
document.getElementById('claim-cancel').addEventListener('click', () => dlg.close());
|
||||
document.getElementById('claim-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api('POST', `/api/v1/tasks/${claimTask.id}/claim`, {
|
||||
note: document.getElementById('claim-note').value.trim(),
|
||||
});
|
||||
dlg.close();
|
||||
document.getElementById('claim-note').value = '';
|
||||
toast('Assignment requested — the consultant will review it.', 'ok');
|
||||
load();
|
||||
} catch (err) { toast(err.message, 'err'); }
|
||||
});
|
||||
|
||||
// saved filters (§11.8)
|
||||
document.getElementById('f-save').addEventListener('click', async () => {
|
||||
try {
|
||||
const me = await api('GET', '/api/v1/auth/me');
|
||||
const extra = me.user.extra || {};
|
||||
extra.savedBoardFilters = JSON.stringify(filters());
|
||||
await api('PATCH', '/api/v1/profile', { extra });
|
||||
toast('Filters saved as your default.', 'ok');
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
|
||||
async function restoreFilters() {
|
||||
try {
|
||||
const me = await api('GET', '/api/v1/auth/me');
|
||||
meId = me.user.id;
|
||||
const saved = me.user.extra && me.user.extra.savedBoardFilters;
|
||||
if (saved) {
|
||||
const f = JSON.parse(saved);
|
||||
document.getElementById('f-q').value = f.q || '';
|
||||
document.getElementById('f-min').value = f.minBounty > 0 ? f.minBounty : '';
|
||||
document.getElementById('f-sort').value = f.sort || '';
|
||||
}
|
||||
} catch (e) { /* anonymous — page guard handles it */ }
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
['f-q', 'f-min'].forEach((id) => {
|
||||
document.getElementById(id).addEventListener('input', () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(load, 300);
|
||||
});
|
||||
});
|
||||
['f-customer', 'f-sort'].forEach((id) => {
|
||||
document.getElementById(id).addEventListener('change', load);
|
||||
});
|
||||
|
||||
subscribe('board', () => { clearTimeout(timer); timer = setTimeout(load, 400); });
|
||||
onPollFallback(load);
|
||||
restoreFilters().then(load);
|
||||
@@ -0,0 +1,54 @@
|
||||
import { api, toast } from '/static/js/api.js';
|
||||
import { subscribe, onPollFallback } from '/static/js/ws.js';
|
||||
|
||||
const errorBox = document.getElementById('error');
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
|
||||
// changes_requested renders in the In-progress column with a badge
|
||||
const colFor = (s) => (s === 'changes_requested' ? 'in_progress' : s);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await api('GET', '/api/v1/my-tasks');
|
||||
document.querySelectorAll('.kanban-col .stack').forEach((el) => el.replaceChildren());
|
||||
res.tasks.forEach((t) => {
|
||||
const col = document.querySelector(`[data-col="${colFor(t.status)}"] .stack`);
|
||||
if (col) col.appendChild(card(t));
|
||||
});
|
||||
} catch (e) { errorBox.textContent = e.message; }
|
||||
}
|
||||
|
||||
function card(t) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'card';
|
||||
el.style.padding = '12px';
|
||||
const badges = [];
|
||||
if (t.status === 'changes_requested') badges.push('<span class="badge" style="color:var(--warn)">changes requested</span>');
|
||||
el.innerHTML = `
|
||||
<a href="/tasks/${t.id}"><strong>${esc(t.title)}</strong></a> ${badges.join(' ')}
|
||||
<div class="spread mt">
|
||||
<span class="badge accent">◈ ${t.bounty}</span>
|
||||
<span data-actions></span>
|
||||
</div>`;
|
||||
const actions = el.querySelector('[data-actions]');
|
||||
const act = (label, path, primary) => {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'btn small' + (primary ? ' primary' : '');
|
||||
b.textContent = label;
|
||||
b.addEventListener('click', async () => {
|
||||
try { await api('POST', `/api/v1/tasks/${t.id}/${path}`, {}); load(); }
|
||||
catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
actions.appendChild(b);
|
||||
};
|
||||
if (t.status === 'assigned' || t.status === 'changes_requested') act('Start', 'start', true);
|
||||
if (t.status === 'in_progress') act('Submit for review', 'submit-review', true);
|
||||
if (['assigned', 'in_progress'].includes(t.status)) act('Abandon', 'abandon');
|
||||
return el;
|
||||
}
|
||||
|
||||
subscribe('board', () => setTimeout(load, 300));
|
||||
onPollFallback(load);
|
||||
load();
|
||||
@@ -0,0 +1,41 @@
|
||||
import { api, toast } from '/static/js/api.js';
|
||||
|
||||
const host = document.getElementById('notification-page-list');
|
||||
const errorBox = document.getElementById('error');
|
||||
let cursor = '';
|
||||
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
|
||||
async function load(reset) {
|
||||
try {
|
||||
if (reset) { cursor = ''; host.replaceChildren(); }
|
||||
const res = await api('GET', `/api/v1/notifications?limit=30&cursor=${cursor}`);
|
||||
const items = res.notifications;
|
||||
cursor = items.length ? items[items.length - 1].id : cursor;
|
||||
host.append(...items.map((n) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'card';
|
||||
div.style.padding = '12px';
|
||||
div.innerHTML = `
|
||||
<div class="spread">
|
||||
<a href="${esc(n.link || '#')}"><strong>${esc(n.title)}</strong></a>
|
||||
<span class="muted">${new Date(n.createdAt).toLocaleString()} ${n.readAt ? '' : '· <span class="badge accent">new</span>'}</span>
|
||||
</div>
|
||||
${n.body ? `<p class="muted" style="margin:4px 0 0">${esc(n.body)}</p>` : ''}`;
|
||||
return div;
|
||||
}));
|
||||
document.getElementById('notif-more').hidden = items.length < 30;
|
||||
} catch (e) { errorBox.textContent = e.message; }
|
||||
}
|
||||
|
||||
document.getElementById('mark-all').addEventListener('click', async () => {
|
||||
try {
|
||||
await api('POST', '/api/v1/notifications/read', { ids: [] });
|
||||
toast('All notifications marked read.', 'ok');
|
||||
load(true);
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
document.getElementById('notif-more').addEventListener('click', () => load(false));
|
||||
load(true);
|
||||
@@ -0,0 +1,55 @@
|
||||
// Global notifications: bell with unread badge, dropdown, and WS toasts for
|
||||
// every lifecycle transition (§11.1).
|
||||
import { api, toast } from '/static/js/api.js';
|
||||
import { subscribe, onPollFallback } from '/static/js/ws.js';
|
||||
|
||||
if (document.body.dataset.loggedIn === '1') {
|
||||
const bell = document.getElementById('nav-bell');
|
||||
const badge = document.getElementById('nav-bell-count');
|
||||
const panel = document.getElementById('notif-panel');
|
||||
const list = document.getElementById('notif-list');
|
||||
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const res = await api('GET', '/api/v1/notifications?limit=15');
|
||||
badge.textContent = res.unread > 0 ? String(res.unread) : '';
|
||||
badge.hidden = res.unread === 0;
|
||||
list.replaceChildren(...res.notifications.map((n) => {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `<a href="${esc(n.link || '#')}">
|
||||
<strong>${esc(n.title)}</strong>${n.readAt ? '' : ' <span class="badge accent">new</span>'}<br>
|
||||
<span class="muted">${esc(n.body || '')}</span></a>`;
|
||||
return li;
|
||||
}));
|
||||
if (!res.notifications.length) {
|
||||
list.innerHTML = '<li class="muted">No notifications.</li>';
|
||||
}
|
||||
} catch (e) { /* signed out or transient */ }
|
||||
}
|
||||
|
||||
bell.addEventListener('click', async () => {
|
||||
panel.hidden = !panel.hidden;
|
||||
if (!panel.hidden) {
|
||||
await refresh();
|
||||
try { await api('POST', '/api/v1/notifications/read', { ids: [] }); } catch (e) { /* ignore */ }
|
||||
badge.hidden = true;
|
||||
badge.textContent = '';
|
||||
}
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!panel.hidden && !panel.contains(e.target) && e.target !== bell) panel.hidden = true;
|
||||
});
|
||||
|
||||
subscribe('notifications', (event, n) => {
|
||||
if (event === 'notification' && n) {
|
||||
toast(n.title, 'ok');
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
onPollFallback(refresh);
|
||||
refresh();
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { api, toast } from '/static/js/api.js';
|
||||
|
||||
const errorBox = document.getElementById('error');
|
||||
let developers = [];
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await api('GET', '/api/v1/consultant/pool');
|
||||
developers = res.developers;
|
||||
render();
|
||||
} catch (e) { errorBox.textContent = e.message; }
|
||||
}
|
||||
|
||||
function render() {
|
||||
const filter = document.getElementById('pool-search').value.trim().toLowerCase();
|
||||
const tbody = document.querySelector('#pool-table tbody');
|
||||
tbody.replaceChildren(...developers
|
||||
.filter((d) => !filter || d.name.toLowerCase().includes(filter) || d.email.toLowerCase().includes(filter))
|
||||
.map((d) => {
|
||||
const tr = document.createElement('tr');
|
||||
const avatar = document.createElement('td');
|
||||
if (d.avatarFileId) {
|
||||
const img = document.createElement('img');
|
||||
img.className = 'avatar';
|
||||
img.src = `/files/${d.avatarFileId}`;
|
||||
img.alt = '';
|
||||
avatar.appendChild(img);
|
||||
}
|
||||
const name = document.createElement('td');
|
||||
name.textContent = d.name;
|
||||
name.dataset.userCard = d.id;
|
||||
const email = document.createElement('td');
|
||||
email.textContent = d.email;
|
||||
const bio = document.createElement('td');
|
||||
bio.textContent = (d.bio || '').slice(0, 80);
|
||||
bio.className = 'muted';
|
||||
const action = document.createElement('td');
|
||||
const btn = document.createElement('button');
|
||||
btn.className = d.inPool ? 'btn small' : 'btn small primary';
|
||||
btn.textContent = d.inPool ? 'Remove from pool' : 'Add to pool';
|
||||
btn.addEventListener('click', async () => {
|
||||
try {
|
||||
if (d.inPool) await api('DELETE', `/api/v1/consultant/pool/${d.id}`);
|
||||
else await api('POST', '/api/v1/consultant/pool', { developerId: d.id });
|
||||
d.inPool = !d.inPool;
|
||||
render();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
action.appendChild(btn);
|
||||
tr.append(avatar, name, email, bio, action);
|
||||
return tr;
|
||||
}));
|
||||
}
|
||||
|
||||
document.getElementById('pool-search').addEventListener('input', render);
|
||||
load();
|
||||
@@ -0,0 +1,36 @@
|
||||
import { api } from '/static/js/api.js';
|
||||
import { subscribe, onPollFallback } from '/static/js/ws.js';
|
||||
|
||||
const host = document.getElementById('review-list');
|
||||
const errorBox = document.getElementById('error');
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await api('GET', '/api/v1/consultant/reviews');
|
||||
host.replaceChildren(...res.tasks.map((t) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
const who = t.assignee
|
||||
? (t.assignee.kind === 'ai' ? 'AI work performer' : 'developer')
|
||||
: 'unknown';
|
||||
card.innerHTML = `
|
||||
<div class="spread">
|
||||
<h3><a href="/tasks/${t.id}">${esc(t.title)}</a></h3>
|
||||
<span class="badge accent">◈ ${t.bounty}</span>
|
||||
</div>
|
||||
<p class="muted">submitted by ${who} · updated ${new Date(t.updatedAt).toLocaleString()}</p>
|
||||
<a class="btn primary" href="/tasks/${t.id}">Open review</a>`;
|
||||
return card;
|
||||
}));
|
||||
if (!res.tasks.length) {
|
||||
host.innerHTML = '<p class="muted">Nothing waiting for review. 🎉</p>';
|
||||
}
|
||||
} catch (e) { errorBox.textContent = e.message; }
|
||||
}
|
||||
|
||||
subscribe('board', () => setTimeout(load, 300));
|
||||
onPollFallback(load);
|
||||
load();
|
||||
@@ -0,0 +1,258 @@
|
||||
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
|
||||
? '<span class="badge" style="color:var(--err)">orphaned upstream</span>' : '';
|
||||
|
||||
const claims = await Promise.all((t.claimRequests || []).map(async (c) => `
|
||||
<li>${esc(await userName(c.developerId))}${c.note ? ' — ' + esc(c.note) : ''}
|
||||
${isConsultant ? `
|
||||
<button class="btn small primary" data-approve="${esc(c.developerId)}">Approve</button>
|
||||
<button class="btn small" data-decline="${esc(c.developerId)}">Decline</button>` : ''}
|
||||
</li>`));
|
||||
|
||||
const comments = await Promise.all((t.comments || []).map(async (c) => `
|
||||
<div class="card" style="padding:12px">
|
||||
<p class="muted" style="margin:0 0 8px">${esc(await userName(c.authorId))} · ${new Date(c.at).toLocaleString()}</p>
|
||||
<div>${c.body}</div>
|
||||
</div>`));
|
||||
|
||||
const timeline = await Promise.all((t.timeline || []).slice().reverse().map(async (e) => `
|
||||
<tr><td>${new Date(e.at).toLocaleString()}</td>
|
||||
<td>${esc(await userName(e.actorId))}</td>
|
||||
<td>${esc(e.event)}${e.data && e.data.note ? ' — ' + esc(e.data.note) : ''}</td></tr>`));
|
||||
|
||||
const timeRows = (t.timeLog || []).map((e) =>
|
||||
`<tr><td>${new Date(e.at).toLocaleString()}</td><td>${e.minutes} min</td><td>${esc(e.note)}</td></tr>`).join('');
|
||||
const totalMin = (t.timeLog || []).reduce((a, e) => a + e.minutes, 0);
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="spread">
|
||||
<h1>${esc(t.title)}</h1>
|
||||
<span class="badge accent" style="font-size:1.1rem">◈ ${t.bounty}</span>
|
||||
</div>
|
||||
<p class="muted">
|
||||
<span class="badge">${esc(t.status)}</span> ${orphan}
|
||||
${t.external ? `· <a href="${esc(t.external.url)}" target="_blank" rel="noopener">${esc(t.external.key)} ↗</a>` : ''}
|
||||
· assignee: ${assigneeName}
|
||||
${totalMin ? `· time logged: ${Math.floor(totalMin / 60)}h ${totalMin % 60}m` : ''}
|
||||
</p>
|
||||
<div id="actions" class="mt"></div>
|
||||
<div class="card mt">
|
||||
<h2>Description</h2>
|
||||
<p style="white-space:pre-wrap">${esc(t.description)}</p>
|
||||
${(t.acceptanceCriteria || []).length ? `
|
||||
<h3>Acceptance criteria</h3>
|
||||
<ul>${t.acceptanceCriteria.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>` : ''}
|
||||
${(t.attachments || []).length ? `
|
||||
<h3>Attachments</h3>
|
||||
<ul>${t.attachments.map((a) => `<li><a href="${a.fileId ? '/files/' + a.fileId : esc(a.url)}" target="_blank" rel="noopener">${esc(a.name)}</a></li>`).join('')}</ul>` : ''}
|
||||
${(t.links || []).length ? `
|
||||
<h3>Links</h3>
|
||||
<ul>${t.links.map((l) => `<li><a href="${esc(l)}" target="_blank" rel="noopener">${esc(l)}</a></li>`).join('')}</ul>` : ''}
|
||||
</div>
|
||||
${(t.claimRequests || []).length ? `
|
||||
<div class="card mt"><h2>Assignment requests</h2><ul>${claims.join('')}</ul></div>` : ''}
|
||||
<div class="card mt">
|
||||
<h2>Comments</h2>
|
||||
<div class="stack">${comments.join('') || '<p class="muted">No comments yet.</p>'}</div>
|
||||
<form id="comment-form" class="mt">
|
||||
<div class="field">
|
||||
<label for="comment-body">Add comment (mention with @name or @email)</label>
|
||||
<textarea id="comment-body" required></textarea>
|
||||
</div>
|
||||
<button class="btn primary" type="submit">Comment</button>
|
||||
</form>
|
||||
</div>
|
||||
${mine() ? `
|
||||
<div class="card mt">
|
||||
<h2>Log time</h2>
|
||||
<form id="time-form" class="row">
|
||||
<div class="field"><label for="time-minutes">Minutes</label>
|
||||
<input type="number" id="time-minutes" min="1" max="1440" required></div>
|
||||
<div class="field"><label for="time-note">Note</label>
|
||||
<input type="text" id="time-note"></div>
|
||||
<div class="field" style="flex:0;align-self:flex-end">
|
||||
<button class="btn primary" type="submit">Log</button></div>
|
||||
</form>
|
||||
${timeRows ? `<table class="list"><thead><tr><th>When</th><th>Time</th><th>Note</th></tr></thead><tbody>${timeRows}</tbody></table>` : ''}
|
||||
</div>` : ''}
|
||||
<div class="card mt">
|
||||
<h2>Timeline</h2>
|
||||
<table class="list"><thead><tr><th>When</th><th>Who</th><th>Event</th></tr></thead>
|
||||
<tbody>${timeline.join('')}</tbody></table>
|
||||
</div>`;
|
||||
|
||||
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: '<p>' + esc(body).replace(/\n/g, '<br>') + '</p>' });
|
||||
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 = `<label style="font-weight:normal">
|
||||
<input type="checkbox" data-ac="${i}" checked> ${esc(c)}</label>`;
|
||||
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();
|
||||
Reference in New Issue
Block a user