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
+92
View File
@@ -0,0 +1,92 @@
// Dependency-free SVG charts (§6.2): a line chart for time series and a
// horizontal bar chart for grouped totals. ~150 lines, no library.
const NS = 'http://www.w3.org/2000/svg';
function el(name, attrs, parent) {
const node = document.createElementNS(NS, name);
Object.entries(attrs || {}).forEach(([k, v]) => node.setAttribute(k, v));
if (parent) parent.appendChild(node);
return node;
}
const fmt = (v) => (Math.abs(v) >= 1000 ? (v / 1000).toFixed(1) + 'k' : String(Math.round(v * 100) / 100));
// lineChart(host, points) — points: [{label: string, value: number}]
export function lineChart(host, points, opts = {}) {
host.replaceChildren();
const W = opts.width || host.clientWidth || 560;
const H = opts.height || 220;
const pad = { l: 48, r: 12, t: 12, b: 28 };
const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: '100%', role: 'img',
'aria-label': opts.label || 'line chart' }, host);
if (!points.length) {
const t = el('text', { x: W / 2, y: H / 2, 'text-anchor': 'middle', fill: 'var(--muted)' }, svg);
t.textContent = 'No data in this period';
return;
}
const max = Math.max(...points.map((p) => p.value), 1);
const x = (i) => pad.l + (i * (W - pad.l - pad.r)) / Math.max(points.length - 1, 1);
const y = (v) => H - pad.b - (v / max) * (H - pad.t - pad.b);
// gridlines + y labels
for (let g = 0; g <= 4; g++) {
const v = (max * g) / 4;
el('line', { x1: pad.l, x2: W - pad.r, y1: y(v), y2: y(v),
stroke: 'var(--border)', 'stroke-width': 1 }, svg);
const t = el('text', { x: pad.l - 6, y: y(v) + 4, 'text-anchor': 'end',
'font-size': 11, fill: 'var(--muted)' }, svg);
t.textContent = fmt(v);
}
// x labels (sparse)
const step = Math.ceil(points.length / 8);
points.forEach((p, i) => {
if (i % step !== 0 && i !== points.length - 1) return;
const t = el('text', { x: x(i), y: H - 8, 'text-anchor': 'middle',
'font-size': 11, fill: 'var(--muted)' }, svg);
t.textContent = p.label;
});
const d = points.map((p, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)},${y(p.value).toFixed(1)}`).join(' ');
// area fill
el('path', {
d: `${d} L${x(points.length - 1)},${y(0)} L${x(0)},${y(0)} Z`,
fill: 'var(--accent)', opacity: 0.15,
}, svg);
el('path', { d, fill: 'none', stroke: 'var(--accent)', 'stroke-width': 2 }, svg);
points.forEach((p, i) => {
const c = el('circle', { cx: x(i), cy: y(p.value), r: 3, fill: 'var(--accent)' }, svg);
const title = el('title', {}, c);
title.textContent = `${p.label}: ${p.value}`;
});
}
// barChart(host, rows) — rows: [{label, value}], horizontal bars
export function barChart(host, rows, opts = {}) {
host.replaceChildren();
const W = opts.width || host.clientWidth || 560;
const rowH = 26;
const pad = { l: 140, r: 48, t: 6, b: 6 };
const H = pad.t + pad.b + Math.max(rows.length, 1) * rowH;
const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: '100%', role: 'img',
'aria-label': opts.label || 'bar chart' }, host);
if (!rows.length) {
const t = el('text', { x: W / 2, y: H / 2 + 4, 'text-anchor': 'middle', fill: 'var(--muted)' }, svg);
t.textContent = 'No data in this period';
return;
}
const max = Math.max(...rows.map((r) => r.value), 1);
rows.forEach((r, i) => {
const yPos = pad.t + i * rowH;
const label = el('text', { x: pad.l - 8, y: yPos + rowH / 2 + 4,
'text-anchor': 'end', 'font-size': 12, fill: 'var(--text)' }, svg);
label.textContent = r.label.length > 18 ? r.label.slice(0, 17) + '…' : r.label;
const wBar = ((W - pad.l - pad.r) * r.value) / max;
const rect = el('rect', { x: pad.l, y: yPos + 4, width: Math.max(wBar, 2),
height: rowH - 8, fill: 'var(--accent)', rx: 2 }, svg);
const title = el('title', {}, rect);
title.textContent = `${r.label}: ${r.value}`;
const val = el('text', { x: pad.l + Math.max(wBar, 2) + 6, y: yPos + rowH / 2 + 4,
'font-size': 12, fill: 'var(--muted)' }, svg);
val.textContent = fmt(r.value);
});
}
+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();
})();
+45
View File
@@ -0,0 +1,45 @@
{{define "content"}}
<div class="spread">
<h1>Metrics</h1>
<span>
<label class="muted" style="display:inline">From <input type="date" id="m-from" style="width:auto"></label>
<label class="muted" style="display:inline">To <input type="date" id="m-to" style="width:auto"></label>
<button class="btn small" id="m-apply">Apply</button>
<a class="btn small" id="m-csv" href="#">Export CSV</a>
</span>
</div>
<div class="error-box" id="error" role="alert"></div>
<section id="dev-metrics" hidden>
<div class="grid mt" id="dev-cards"></div>
<div class="card mt"><h2>Earnings over time (weekly)</h2><div id="dev-weekly"></div></div>
<div class="card mt"><h2>Per customer</h2><div id="dev-customers"></div></div>
</section>
<section id="cons-metrics" hidden>
<div class="spread mt">
<span>
<label class="muted" style="display:inline">Customer
<select id="m-customer" style="width:auto"><option value="">All</option></select>
</label>
</span>
</div>
<div class="grid mt" id="cons-cards"></div>
<div class="card mt"><h2>Awarded over time (weekly)</h2><div id="cons-weekly"></div></div>
<div class="row mt">
<div class="card"><h2>Per developer</h2><div id="cons-devs"></div></div>
<div class="card"><h2>Per customer</h2><div id="cons-customers"></div></div>
</div>
</section>
<div class="card mt">
<div class="spread">
<h2>Leaderboard — top developers</h2>
<label class="muted"><input type="checkbox" id="lb-optout"> Hide me from the leaderboard</label>
</div>
<table class="list" id="leaderboard">
<thead><tr><th>#</th><th>Developer</th><th>Tasks</th><th>Bounty</th></tr></thead>
<tbody></tbody>
</table>
</div>
{{end}}