c59aea42ef
- theme rework (user request): white paper bg, brown ink, ordered-dither halftone textures, hard offset Y2K shadows, dithered masthead bands; dark theme matched; token test + DECISIONS updated - fix: .card + .card stacking margin leaked into grid layouts, shifting every card except the first (the 'always the first item' reports) - fix: CSP style-src 'self' silently dropped every inline style attribute (misaligned save button, stat values, editor attach button, bell badge); styles now allow inline, scripts remain strict per §12 - fix: [hidden] is now display:none !important so flex containers cannot defeat it (chat panel/footers) - board + metrics filters live in boxed .toolbar rows with baseline-aligned controls; metric stat cards use a uniform .stat layout - new-conversation dialog: fixed 440px width and 180px results list (no more resizing while searching), full-width result rows, picked people drop out of the list - floating messages bubble bottom-right on all pages (except /messages): unread badge, mini panel with conversation list, thread view, quick composer, live WS updates; toasts moved up to clear it Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
114 lines
4.7 KiB
JavaScript
114 lines
4.7 KiB
JavaScript
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 stat"><p class="stat-label">${esc(label)}</p>
|
|
<p class="stat-value">${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();
|
|
})();
|