b1c9a42810
- 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>
93 lines
3.9 KiB
JavaScript
93 lines
3.9 KiB
JavaScript
// 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);
|
|
});
|
|
}
|