Files
BountyBoard/web/static/js/board.js
T
etalon 70a813edfa 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>
2026-06-12 20:11:05 +02:00

145 lines
5.2 KiB
JavaScript

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) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[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);