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,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();
|
||||
Reference in New Issue
Block a user