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:
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[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();
|
||||
})();
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user