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);
});
}