feat: metrics dashboards with SVG charts, CSV export, leaderboard (phase 10)

- aggregations over the immutable bountyAwards ledger: totals, weekly
  buckets ($dateTrunc), per-developer and per-customer groupings
- timeline-derived: approval rate, time logged, assigned→approved lead
  time, imported→published atomization lead time, open board depth
- developer + consultant dashboards (admin = global consultant view),
  date-range filters, CSV export of the ledger
- leaderboard (top 10 by bounty) honoring the new leaderboardOptOut
  profile setting
- hand-rolled SVG line and bar charts (~150 lines, no chart library)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
etalon
2026-06-12 20:19:57 +02:00
parent 7d3140334e
commit b1c9a42810
11 changed files with 938 additions and 14 deletions
+113
View File
@@ -0,0 +1,113 @@
import { api, toast } from '/static/js/api.js';
import { lineChart, barChart } from '/static/js/charts.js';
const errorBox = document.getElementById('error');
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
let me = null;
function rangeQS() {
const from = document.getElementById('m-from').value;
const to = document.getElementById('m-to').value;
const p = new URLSearchParams();
if (from) p.set('from', from);
if (to) p.set('to', to);
return p;
}
function card(label, value, hint) {
return `<div class="card"><p class="muted" style="margin:0">${esc(label)}</p>
<p style="font-size:1.6rem;font-weight:700;margin:4px 0">${esc(value)}</p>
${hint ? `<p class="hint">${esc(hint)}</p>` : ''}</div>`;
}
const weekLabel = (iso) => {
const d = new Date(iso);
return `${d.getMonth() + 1}/${d.getDate()}`;
};
async function loadDeveloper() {
const res = await api('GET', '/api/v1/developer/metrics?' + rangeQS());
document.getElementById('dev-metrics').hidden = false;
document.getElementById('dev-cards').innerHTML =
card('Total bounty earned', res.totalBounty) +
card('Tasks completed', res.tasksCompleted) +
card('Approval rate', `${Math.round(res.approvalRate * 100)}%`,
`${res.approved} approved · ${res.changesRequested} change requests`) +
card('Avg assigned → approved', `${res.avgAssignToApproveHours.toFixed(1)} h`) +
card('Time logged', `${Math.floor(res.timeLoggedMinutes / 60)}h ${res.timeLoggedMinutes % 60}m`);
lineChart(document.getElementById('dev-weekly'),
res.weekly.map((p) => ({ label: weekLabel(p.week), value: p.amount })),
{ label: 'weekly earnings' });
barChart(document.getElementById('dev-customers'),
res.perCustomer.map((g) => ({ label: g.key, value: g.amount })),
{ label: 'earnings per customer' });
document.getElementById('m-csv').href = '/api/v1/developer/metrics?format=csv&' + rangeQS();
}
async function loadConsultant() {
const qs = rangeQS();
const cust = document.getElementById('m-customer').value;
if (cust) qs.set('customerId', cust);
const res = await api('GET', '/api/v1/consultant/metrics?' + qs);
document.getElementById('cons-metrics').hidden = false;
const sel = document.getElementById('m-customer');
if (sel.options.length === 1) {
res.customers.forEach((c) => sel.add(new Option(c.name, c.id)));
}
document.getElementById('cons-cards').innerHTML =
card('Bounty awarded', res.totalBounty) +
card('Tasks completed', res.tasksCompleted) +
card('Atomization lead time', `${res.atomizationLeadHours.toFixed(1)} h`, 'imported → published') +
card('Open board depth', res.openBoardDepth, 'published, unclaimed tasks');
lineChart(document.getElementById('cons-weekly'),
res.weekly.map((p) => ({ label: weekLabel(p.week), value: p.amount })),
{ label: 'weekly awards' });
barChart(document.getElementById('cons-devs'),
res.perDeveloper.map((g) => ({ label: g.key, value: g.amount })),
{ label: 'per developer' });
barChart(document.getElementById('cons-customers'),
res.perCustomer.map((g) => ({ label: g.key, value: g.amount })),
{ label: 'per customer' });
document.getElementById('m-csv').href = '/api/v1/consultant/metrics?format=csv&' + qs;
}
async function loadLeaderboard() {
const res = await api('GET', '/api/v1/leaderboard?' + rangeQS());
const tbody = document.querySelector('#leaderboard tbody');
tbody.replaceChildren(...res.leaderboard.map((row, i) => {
const tr = document.createElement('tr');
tr.innerHTML = `<td>${i + 1}</td><td>${esc(row.name)}</td><td>${row.tasks}</td><td>◈ ${row.amount}</td>`;
return tr;
}));
if (!res.leaderboard.length) {
tbody.innerHTML = '<tr><td colspan="4" class="muted">No bounties awarded yet.</td></tr>';
}
}
async function loadAll() {
errorBox.textContent = '';
try {
if (me.user.roles.developer) await loadDeveloper();
if (me.user.roles.consultant || me.user.roles.admin) await loadConsultant();
await loadLeaderboard();
} catch (e) { errorBox.textContent = e.message; }
}
document.getElementById('m-apply').addEventListener('click', loadAll);
document.getElementById('m-customer').addEventListener('change', loadConsultant);
document.getElementById('lb-optout').addEventListener('change', async (e) => {
try {
await api('PATCH', '/api/v1/profile', { settings: { leaderboardOptOut: e.target.checked } });
toast(e.target.checked ? 'You are hidden from the leaderboard.' : 'You are visible on the leaderboard.', 'ok');
loadLeaderboard();
} catch (err) { toast(err.message, 'err'); }
});
(async () => {
me = await api('GET', '/api/v1/auth/me');
document.getElementById('lb-optout').checked = !!me.user.settings.leaderboardOptOut;
loadAll();
})();