← back to Marketing Command Center
public/panels/performance.js
264 lines
// Performance panel — KPI cards, a hand-rolled SVG trend chart (no chart libs),
// and a top-campaigns table. Registers into the shell's MCC_PANELS map.
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['performance'] = {
init(root) {
const $ = s => root.querySelector(s);
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const SVGNS = 'http://www.w3.org/2000/svg';
const state = { range: '30d', metric: 'opens', overview: null, series: null };
// ── formatters ──────────────────────────────────────────────────────────────
const nf = new Intl.NumberFormat();
const fmtInt = n => nf.format(Math.round(n || 0));
const fmtPct = r => (r == null ? '—' : (r * 100).toFixed(1) + '%');
const fmtTrend = t => (t == null ? '' : (t > 0 ? '+' : '') + t.toFixed(1) + '%');
function shortDate(iso) {
return new Date(iso + 'T00:00:00')
.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
// ── KPI cards ────────────────────────────────────────────────────────────────
function trendClass(t) { return t > 0.05 ? 'up' : t < -0.05 ? 'down' : 'flat'; }
function trendArrow(t) { return t > 0.05 ? '▲' : t < -0.05 ? '▼' : '–'; }
function kpiCard(label, valHtml, trend, sub) {
const t = trend == null ? null : trend;
const trendHtml = t == null ? '' :
`<span class="k-trend ${trendClass(t)}">${trendArrow(t)} ${esc(fmtTrend(t))}</span>`;
const subHtml = sub ? `<span class="k-sub">${esc(sub)}</span>` : '';
return `<div class="kpi">
<div class="k-label">${esc(label)}</div>
<div class="k-val">${valHtml}</div>
${trendHtml}${subHtml}
</div>`;
}
function renderKpis() {
const o = state.overview;
const box = $('#perf-kpis');
if (!o) { box.innerHTML = '<div class="perf-empty" style="grid-column:1/-1">Loading…</div>'; return; }
const k = o.kpis;
const webNote = o.web && o.web.live ? '' : ' (mock)';
box.innerHTML = [
kpiCard('Email sends', fmtInt(k.sends.value), k.sends.trend),
kpiCard('Opens', fmtInt(k.opens.value), k.opens.trend),
kpiCard('Open rate', fmtPct(k.openRate.value), k.openRate.trend),
kpiCard('Clicks', fmtInt(k.clicks.value), k.clicks.trend),
kpiCard('Click rate', fmtPct(k.clickRate.value), k.clickRate.trend),
kpiCard('List size', fmtInt(k.listSize.value), k.listSize.trend,
'+' + fmtInt(k.netNewContacts.value) + ' net'),
kpiCard('Sessions' + webNote, fmtInt(k.sessions.value), k.sessions.trend),
kpiCard('Conversions' + webNote, fmtInt(k.conversions.value), k.conversions.trend),
].join('');
}
// ── campaigns table ──────────────────────────────────────────────────────────
function renderCampaigns() {
const tbody = $('#perf-campaigns tbody');
const rows = (state.overview && state.overview.topCampaigns) || [];
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="6" class="perf-empty">No campaigns in this window.</td></tr>';
return;
}
tbody.innerHTML = rows.map(c => `<tr>
<td class="c-name">${esc(c.name)}</td>
<td>${esc(c.list || '—')}</td>
<td class="num">${fmtInt(c.sends)}</td>
<td class="num">${fmtInt(c.opens)}</td>
<td class="num c-rate">${fmtPct(c.open_rate)}</td>
<td class="num">${fmtPct(c.click_rate)}</td>
</tr>`).join('');
}
// ── hand-rolled SVG chart ────────────────────────────────────────────────────
// Line+area for continuous metrics; bars for sparse/event metrics (sends).
const METRIC_LABELS = {
opens: 'Opens', clicks: 'Clicks', sends: 'Sends', signups: 'Signups',
sessions: 'Sessions', users: 'Users', conversions: 'Conversions',
};
const el = (name, attrs) => {
const n = document.createElementNS(SVGNS, name);
for (const k in attrs) n.setAttribute(k, attrs[k]);
return n;
};
function renderChart() {
const svg = $('#perf-chart');
const tip = $('#perf-tip');
tip.hidden = true;
while (svg.firstChild) svg.removeChild(svg.firstChild);
const data = state.series;
const meta = $('#perf-chart-meta');
if (!data || !data.series || !data.series.length) {
meta.innerHTML = '<span class="cm-label">No data.</span>';
return;
}
const pts = data.series;
const label = METRIC_LABELS[state.metric] || state.metric;
meta.innerHTML =
`<span class="cm-total">${fmtInt(data.total)}</span>` +
`<span class="cm-label">total ${esc(label.toLowerCase())} · ${esc(data.range)}` +
(data.trend != null ? ` · ${esc(fmtTrend(data.trend))} vs prior half` : '') + `</span>`;
// viewBox coordinate space (chart auto-scales to the wrapper via CSS)
const W = 800, H = 240;
const padL = 44, padR = 12, padT = 14, padB = 26;
const plotW = W - padL - padR, plotH = H - padT - padB;
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
const max = Math.max(1, ...pts.map(p => p.value));
// "nice" rounded top so gridlines read cleanly
const niceMax = niceCeil(max);
const x = i => padL + (pts.length === 1 ? plotW / 2 : (i / (pts.length - 1)) * plotW);
const y = v => padT + plotH - (v / niceMax) * plotH;
// gridlines + y labels (4 bands)
for (let g = 0; g <= 4; g++) {
const val = (niceMax / 4) * g;
const gy = y(val);
svg.appendChild(el('line', { class: 'grid-line', x1: padL, x2: W - padR, y1: gy, y2: gy }));
const lbl = el('text', { class: 'axis-lbl', x: padL - 8, y: gy + 3, 'text-anchor': 'end' });
lbl.textContent = fmtAxis(val);
svg.appendChild(lbl);
}
// x labels (first / mid / last)
const xticks = pts.length <= 2 ? [0, pts.length - 1] : [0, Math.floor((pts.length - 1) / 2), pts.length - 1];
xticks.forEach(i => {
const t = el('text', { class: 'axis-lbl', x: x(i), y: H - 8,
'text-anchor': i === 0 ? 'start' : i === pts.length - 1 ? 'end' : 'middle' });
t.textContent = shortDate(pts[i].date);
svg.appendChild(t);
});
const asBars = state.metric === 'sends';
if (asBars) {
const slot = plotW / pts.length;
const bw = Math.max(2, Math.min(22, slot * 0.6));
pts.forEach((p, i) => {
const cx = padL + (i + 0.5) * slot;
const by = y(p.value);
svg.appendChild(el('rect', { class: 'bar', x: cx - bw / 2, y: by, width: bw, height: Math.max(0, padT + plotH - by) }));
});
} else {
// area + line
let d = '', area = `M ${x(0)} ${padT + plotH} `;
pts.forEach((p, i) => {
const px = x(i), py = y(p.value);
d += (i ? 'L' : 'M') + ` ${px.toFixed(1)} ${py.toFixed(1)} `;
area += `L ${px.toFixed(1)} ${py.toFixed(1)} `;
});
area += `L ${x(pts.length - 1)} ${padT + plotH} Z`;
svg.appendChild(el('path', { class: 'area', d: area }));
svg.appendChild(el('path', { class: 'line', d }));
}
// invisible hit-targets for the tooltip (one column per point)
const slot = plotW / pts.length;
pts.forEach((p, i) => {
const cx = asBars ? padL + (i + 0.5) * slot : x(i);
const hx = padL + i * slot;
const hit = el('rect', { class: 'hit', x: hx, y: padT, width: slot, height: plotH });
const show = () => {
if (!asBars) {
const dot = el('circle', { class: 'dot active-dot', cx, cy: y(p.value), r: 4 });
const prev = svg.querySelector('.active-dot'); if (prev) prev.remove();
svg.appendChild(dot);
}
// position tooltip relative to the wrapper using the rendered px geometry
const rect = svg.getBoundingClientRect();
const wrap = svg.parentElement.getBoundingClientRect();
const sx = rect.left - wrap.left + (cx / W) * rect.width;
const sy = rect.top - wrap.top + (y(p.value) / H) * rect.height;
tip.style.left = sx + 'px';
tip.style.top = sy + 'px';
tip.innerHTML = `<b>${fmtInt(p.value)}</b> · ${esc(shortDate(p.date))}`;
tip.hidden = false;
};
hit.addEventListener('mouseenter', show);
hit.addEventListener('mousemove', show);
hit.addEventListener('mouseleave', () => {
tip.hidden = true;
const d = svg.querySelector('.active-dot'); if (d) d.remove();
});
svg.appendChild(hit);
});
}
function niceCeil(v) {
if (v <= 0) return 1;
const mag = Math.pow(10, Math.floor(Math.log10(v)));
const n = v / mag;
const step = n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10;
return step * mag;
}
function fmtAxis(v) {
if (v >= 1000) return (v / 1000).toFixed(v % 1000 === 0 ? 0 : 1) + 'k';
return String(Math.round(v));
}
// ── data loading ─────────────────────────────────────────────────────────────
async function loadOverview() {
try {
const r = await fetch(`/api/performance/overview?range=${state.range}`);
state.overview = await r.json();
} catch (e) { state.overview = null; }
renderBanner();
renderKpis();
renderCampaigns();
}
async function loadSeries() {
try {
const r = await fetch(`/api/performance/timeseries?metric=${state.metric}&range=${state.range}`);
state.series = await r.json();
} catch (e) { state.series = null; }
renderChart();
}
function renderBanner() {
const o = state.overview;
const box = $('#perf-banner');
const webMock = !o || !o.web || o.web.mock;
if (o && o.mock === false && !webMock) { box.innerHTML = ''; return; }
const bits = [];
if (!o || o.mock !== false) bits.push('email metrics are sample data (Constant Contact not wired)');
if (webMock) bits.push('web traffic is sample data (set GA4_PROPERTY_ID + GA4_ACCESS_TOKEN for live Analytics)');
box.innerHTML = bits.length
? `<div class="muted-banner">Showing a fully-usable demo: ${esc(bits.join('; '))}.</div>`
: '';
}
// ── controls ─────────────────────────────────────────────────────────────────
root.querySelectorAll('.perf-range').forEach(b => {
b.addEventListener('click', () => {
if (b.dataset.range === state.range) return;
state.range = b.dataset.range;
root.querySelectorAll('.perf-range').forEach(x =>
x.classList.toggle('active', x.dataset.range === state.range));
loadOverview();
loadSeries();
});
});
const metricSel = $('#perf-metric');
metricSel.value = state.metric;
metricSel.addEventListener('change', () => {
state.metric = metricSel.value;
loadSeries();
});
// redraw chart on resize (geometry is viewBox-based but tooltip px math isn't)
let rt;
const onResize = () => { clearTimeout(rt); rt = setTimeout(renderChart, 120); };
window.addEventListener('resize', onResize);
loadOverview();
loadSeries();
},
};