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,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();
|
||||
})();
|
||||
Reference in New Issue
Block a user