// Live dashboard client. Consumes /stream SSE and renders everything.
const $ = (id) => document.getElementById(id);
const css = (v) => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
const fmt = (n) => n >= 1000 ? (n / 1000).toFixed(n >= 10000 ? 0 : 1) + 'k' : String(n);
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
let state = null;
// ---- KPI tiles ----
const KPI = [
{ k: 'total', l: 'Total Messages', s: (d) => `${fmt(d.kpi.sent)} sent · ${fmt(d.kpi.recv)} recv` },
{ k: 'perMin', l: 'Msgs / min', s: (d) => `${d.kpi.lastMin} in last min`, v: (d) => d.kpi.perMin },
{ k: 'sessionsActive', l: 'Active Chats (1h)', s: (d) => `${d.kpi.sessions} total` },
{ k: 'last5min', l: 'Last 5 min', s: () => 'live velocity' },
{ k: 'analyzed', l: 'AI Analyzed', s: (d) => `${d.kpi.total ? Math.round(d.kpi.analyzed / d.kpi.total * 100) : 0}% coverage` },
{ k: 'avgScore', l: 'Mood Score', s: (d) => d.kpi.avgScore > .15 ? '↑ positive' : d.kpi.avgScore < -.15 ? '↓ negative' : '→ neutral', color: (d) => d.kpi.avgScore > .15 ? css('--pos') : d.kpi.avgScore < -.15 ? css('--neg') : css('--neu') },
];
function renderKpis(d) {
$('kpis').innerHTML = KPI.map((x) => {
const val = x.v ? x.v(d) : d.kpi[x.k];
const col = x.color ? `style="color:${x.color(d)}"` : '';
return `
${typeof val === 'number' ? fmt(val) : val}
${x.l}
${x.s(d)}
`;
}).join('');
}
// ---- sentiment donut ----
function donut(d) {
const c = $('donut'), g = c.getContext('2d'), S = d.sentiment;
const tot = Math.max(1, S.pos + S.neu + S.neg);
const segs = [[S.pos, css('--pos')], [S.neu, css('--neu')], [S.neg, css('--neg')]];
g.clearRect(0, 0, 120, 120); let a = -Math.PI / 2;
for (const [v, col] of segs) {
const ang = v / tot * Math.PI * 2;
g.beginPath(); g.arc(60, 60, 52, a, a + ang); g.lineWidth = 15; g.strokeStyle = col; g.stroke(); a += ang;
}
$('avgScore').textContent = (d.kpi.avgScore > 0 ? '+' : '') + d.kpi.avgScore;
$('sPos').textContent = S.pos; $('sNeu').textContent = S.neu; $('sNeg').textContent = S.neg;
const em = d.emotions || [];
const mx = Math.max(1, ...em.map((e) => e.value));
$('emotions').innerHTML = em.length ? em.map((e) =>
`${esc(e.label)}
${e.value}
`
).join('') : 'analyzing…
';
}
// ---- bar chart on canvas (timeline) ----
function bars(id, data, color, opts = {}) {
const c = $(id), g = c.getContext('2d');
const w = c.clientWidth, h = c.height; c.width = w;
g.clearRect(0, 0, w, h);
const mx = Math.max(1, ...data);
const n = data.length, bw = w / n;
g.fillStyle = color;
for (let i = 0; i < n; i++) {
const bh = data[i] / mx * (h - 18);
g.globalAlpha = data[i] ? 1 : .25;
g.fillRect(i * bw + 1, h - bh - 14, Math.max(1, bw - 2), bh || 1);
}
g.globalAlpha = 1; g.fillStyle = css('--dim'); g.font = '10px sans-serif';
if (opts.labels) opts.labels.forEach((t, i) => { g.fillText(t, i * (w / opts.labels.length) + 2, h - 2); });
g.textAlign = 'right'; g.fillText('max ' + mx, w - 2, 10); g.textAlign = 'left';
}
// ---- heatmap ----
function heatmap(d) {
const c = $('heat'), g = c.getContext('2d');
const w = c.clientWidth; c.width = w; const h = c.height;
g.clearRect(0, 0, w, h);
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const padL = 34, padT = 4, padB = 14;
const cw = (w - padL) / 24, ch = (h - padT - padB) / 7;
let mx = 1; d.heatmap.forEach((r) => r.forEach((v) => mx = Math.max(mx, v)));
for (let dy = 0; dy < 7; dy++) {
g.fillStyle = css('--mut'); g.font = '10px sans-serif'; g.fillText(days[dy], 2, padT + dy * ch + ch / 2 + 3);
for (let hr = 0; hr < 24; hr++) {
const v = d.heatmap[dy][hr]; const t = v / mx;
g.fillStyle = v ? `rgba(91,140,255,${.15 + t * .85})` : css('--panel2');
g.fillRect(padL + hr * cw + 1, padT + dy * ch + 1, cw - 2, ch - 2);
}
}
g.fillStyle = css('--dim'); g.font = '9px sans-serif';
for (let hr = 0; hr < 24; hr += 3) g.fillText(hr, padL + hr * cw, h - 3);
}
// ---- top sessions ----
function topSessions(d) {
const list = d.topSessions || [];
const mx = Math.max(1, ...list.map((s) => s.count));
$('topSessions').innerHTML = list.length ? list.map((s) => {
const sw = s.sent / mx * 100, rw = s.recv / mx * 100;
return `${esc(s.name)}
${s.count}
`;
}).join('') : '—
';
}
// ---- topics / keywords ----
function topics(d) {
const t = d.topics || [];
$('topics').innerHTML = t.length ? t.map((x) => `${esc(x.label)} ${x.value}`).join('') : 'analyzing…';
$('keywords').innerHTML = (d.keywords || []).map((x) => `${esc(x.label)}`).join('');
}
// ---- AI briefing ----
function renderBrief(ins, at) {
if (!ins) return;
const alerts = (ins.alerts || []).filter(Boolean);
$('brief').innerHTML =
`${esc(ins.headline || '')}
${esc(ins.summary || '')}
${ins.mood ? `mood · ${esc(ins.mood)}` : ''}
${(ins.activeTopics || []).map((t) => `${esc(t)}`).join('')}
${alerts.length ? `${alerts.map((a) => `
⚠ ${esc(a)}
`).join('')}
` : ''}`;
if (at) $('briefAge').textContent = 'updated ' + ago(at);
}
// ---- reminders ----
function renderReminders(r) {
const open = r.open || [], done = r.done || [];
$('remCnt').textContent = r.counts ? r.counts.open : open.length;
const item = (x) => `
${esc(x.text)}
${x.kind}
${x.who === 'me' ? '➡ you owe' : '⬅ owed to you'}${x.urgency === 'high' ? ' · urgent' : ''}
${x.session ? `· ${esc(x.session)}` : ''}${x.due ? `· ⏱ ${esc(x.due)}` : ''}
`;
const html = open.map(item).join('') + done.map(item).join('');
$('reminders').innerHTML = html || 'No open promises or requests detected yet.
';
document.querySelectorAll('.rem .chk').forEach((el) => el.onclick = () => {
const id = el.parentElement.dataset.id;
fetch(`/api/reminders/${id}/toggle`, { method: 'POST' });
});
}
$('clearDone').onclick = (e) => { e.preventDefault(); fetch('/api/reminders/clear-done', { method: 'POST' }); };
// ---- live feed ----
function pushFeed(m, prepend = true) {
const f = $('feed');
const el = document.createElement('div');
el.className = 'msg' + (m.isSend ? ' me' : '');
const col = m.analysis ? (m.analysis.sentiment === 'positive' ? css('--pos') : m.analysis.sentiment === 'negative' ? css('--neg') : css('--neu')) : css('--dim');
el.innerHTML = `
${esc(m.sessionName || '')}${m.isSend ? 'you' : esc(m.senderName || 'them')}
${time(m.ts)}
${esc(m.content)}
`;
if (prepend) f.insertBefore(el, f.firstChild); else f.appendChild(el);
while (f.children.length > 60) f.removeChild(f.lastChild);
}
// ---- full render ----
function render(d) {
state = d;
renderKpis(d); donut(d); topSessions(d); topics(d); heatmap(d);
bars('timeline', d.timeline.minute, css('--c1'));
bars('day', d.timeline.hour24, css('--c6'), { labels: ['-24h', '-18h', '-12h', '-6h', 'now'] });
$('tlNow').textContent = d.kpi.lastMin + '/min';
$('feedCnt').textContent = fmt(d.kpi.total);
if (d.ai && d.ai.insight) renderBrief(d.ai.insight, d.ai.insightAt);
updateStatus(d.push);
$('uptime').textContent = 'uptime ' + dur(d.uptimeSec);
if (!$('feed').children.length && d.recent) d.recent.slice().reverse().forEach((m) => pushFeed(m, false));
}
function updateStatus(p) {
if (!p) return;
$('pushDot').className = 'dot' + (p.connected ? ' on' : '');
$('pushTxt').textContent = p.connected ? `live · ${p.events} events` : `reconnecting (${p.info || ''})`.slice(0, 40);
}
// ---- SSE wiring ----
function connect() {
const es = new EventSource('/stream');
es.addEventListener('stats', (e) => render(JSON.parse(e.data)));
es.addEventListener('message', (e) => { const m = JSON.parse(e.data); pushFeed(m); if (state) { state.kpi.total++; $('feedCnt').textContent = fmt(state.kpi.total); } });
es.addEventListener('insight', (e) => { const d = JSON.parse(e.data); renderBrief(d.insight, d.at); });
es.addEventListener('reminders', (e) => renderReminders(JSON.parse(e.data)));
es.addEventListener('status', (e) => updateStatus(JSON.parse(e.data)));
es.onerror = () => { $('pushTxt').textContent = 'stream lost — retrying'; };
}
connect();
fetch('/api/ai').then((r) => r.json()).then((a) => {
$('aiTxt').textContent = a.enabled ? a.model : 'AI off';
}).catch(() => {});
setInterval(() => {
fetch('/api/ai').then((r) => r.json()).then((a) => {
$('aiUsage').textContent = `AI: ${a.calls} calls · ${fmt(a.tokensIn + a.tokensOut)} tokens${a.errors ? ` · ${a.errors} errors` : ''}`;
}).catch(() => {});
}, 5000);
// ---- clock ----
setInterval(() => { $('clock').textContent = new Date().toLocaleTimeString(); }, 1000);
window.addEventListener('resize', () => state && render(state));
// ---- time helpers ----
function time(ts) { const d = new Date(ts); return `${p2(d.getHours())}:${p2(d.getMinutes())}`; }
function p2(n) { return String(n).padStart(2, '0'); }
function dur(s) { const h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60); return h ? `${h}h ${m}m` : `${m}m`; }
function ago(t) { const s = Math.floor((Date.now() - t) / 1000); return s < 60 ? s + 's ago' : Math.floor(s / 60) + 'm ago'; }