← back to Marketing Command Center
public/panels/segment-perf.js
295 lines
// Segment Performance panel — fetches /api/segment-perf/summary, renders one
// card per segment with KPI tiles colour-coded by health band, then fetches a
// 90-day per-segment trend (currently-selected metric) and draws a hand-rolled
// SVG bar chart inside each card. No external charting lib.
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['segment-perf'] = {
init(root) {
const $ = s => root.querySelector(s);
const $$ = s => Array.from(root.querySelectorAll(s));
const api = (p) => fetch(`/api/segment-perf${p}`).then(r => r.json());
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
// ── formatting helpers ───────────────────────────────────────────────────
const fmtPct = v => (v * 100).toFixed(1) + '%';
const fmtMoney = v => '$' + (v >= 10
? v.toFixed(2)
: v >= 1 ? v.toFixed(2) : v.toFixed(3));
const fmtNum = v => Number(v || 0).toLocaleString();
const fmtTrend = v => {
if (v == null) return '';
const sign = v > 0 ? '▲' : v < 0 ? '▼' : '·';
return `${sign} ${Math.abs(v).toFixed(1)}%`;
};
const fmtValue = (metric, v) =>
metric === 'revPerRecipient' ? fmtMoney(v) : fmtPct(v);
const METRIC_LABELS = {
openRate: { short: 'Open rate', hint: 'opens / sends' },
ctor: { short: 'CTOR', hint: 'clicks / opens' },
clickRate: { short: 'Click rate', hint: 'clicks / sends' },
revPerRecipient:{ short: '$ / recip.', hint: 'orders × AOV / sends' },
};
// Colour mapping reuses the existing CSS palette tokens so segment perf
// tiles share the look of the rest of the Command Center.
// good = sage (calm green-grey)
// ok = gold (warm hold)
// bad = accent (the brick / clay accent used for gated buttons)
const BAND = {
good: { bg: 'rgba(111,122,99,0.12)', fg: 'var(--sage)', label: 'Healthy' },
ok: { bg: 'rgba(184,146,90,0.16)', fg: 'var(--gold)', label: 'Watch' },
bad: { bg: 'rgba(138,90,68,0.16)', fg: 'var(--accent)', label: 'At risk' },
unknown: { bg: 'var(--cream)', fg: 'var(--mut)', label: '—' },
};
let benchmarks = null;
let summaryCache = null;
let anyMock = false;
function banner() {
$('#sp-banner').innerHTML = anyMock
? `<div class="muted-banner">Running on <b>deterministic mock data</b> (CTCT
reporting + Shopify revenue attribution not yet wired in). Numbers are stable
so screenshots stay reproducible.</div>`
: '';
}
function renderLegend() {
const target = $('#sp-legend');
if (!benchmarks) { target.innerHTML = ''; return; }
const metric = $('#sp-metric').value;
const b = benchmarks[metric];
const isMoney = metric === 'revPerRecipient';
const fmt = isMoney ? fmtMoney : fmtPct;
target.innerHTML = `
<span class="muted">Benchmarks (${esc(METRIC_LABELS[metric].short)}):</span>
<span style="display:inline-flex;gap:6px;align-items:center">
<span style="width:10px;height:10px;border-radius:50%;background:var(--accent)"></span>
at risk < ${fmt(b.bad)}
</span>
<span style="display:inline-flex;gap:6px;align-items:center">
<span style="width:10px;height:10px;border-radius:50%;background:var(--gold)"></span>
watch ${fmt(b.bad)}–${fmt(b.good)}
</span>
<span style="display:inline-flex;gap:6px;align-items:center">
<span style="width:10px;height:10px;border-radius:50%;background:var(--sage)"></span>
healthy ≥ ${fmt(b.good)}
</span>
`;
}
// ── SVG bar chart (hand-rolled) ──────────────────────────────────────────
// Renders a 90-day trend as vertical bars with three reference bands
// (bad / ok / good) drawn as horizontal stripes underneath, plus the mean
// line. Campaign send days are drawn slightly taller / saturated.
function renderTrendSvg(trend, metric) {
const W = 360, H = 110;
const padL = 4, padR = 4, padT = 10, padB = 14;
const innerW = W - padL - padR;
const innerH = H - padT - padB;
const series = trend.series || [];
if (!series.length) return '<div class="muted">No data</div>';
// y-domain: stretch from 0 to max(value, benchmark.good × 1.15)
const benchmark = trend.benchmark || { bad: 0, good: 0 };
let maxV = 0;
for (const p of series) if (p.value > maxV) maxV = p.value;
const yMax = Math.max(maxV, benchmark.good * 1.15, 0.0001);
const yToPx = v => padT + innerH - (v / yMax) * innerH;
const barW = innerW / series.length;
const drawW = Math.max(1.5, barW - 0.6);
// colour each bar by which band its value falls in (gives instant visual
// health signal across the timeline, not just at the latest point)
const colorFor = v => {
if (v < benchmark.bad) return 'var(--accent)';
if (v < benchmark.good) return 'var(--gold)';
return 'var(--sage)';
};
// health bands as stacked translucent stripes
const badTop = yToPx(benchmark.bad);
const goodTop = yToPx(benchmark.good);
const baseY = padT + innerH;
const bands = `
<rect x="${padL}" y="${badTop}" width="${innerW}" height="${baseY - badTop}"
fill="rgba(138,90,68,0.06)" />
<rect x="${padL}" y="${goodTop}" width="${innerW}" height="${badTop - goodTop}"
fill="rgba(184,146,90,0.07)" />
<rect x="${padL}" y="${padT}" width="${innerW}" height="${goodTop - padT}"
fill="rgba(111,122,99,0.07)" />
`;
const bars = series.map((p, i) => {
const x = padL + i * barW;
const y = yToPx(p.value);
const h = baseY - y;
const fill = colorFor(p.value);
const opacity = p.isCampaign ? 1 : 0.55;
return `<rect x="${x.toFixed(2)}" y="${y.toFixed(2)}" width="${drawW.toFixed(2)}"
height="${Math.max(0.5, h).toFixed(2)}" fill="${fill}" opacity="${opacity}">
<title>${esc(p.date)} — ${metric === 'revPerRecipient' ? fmtMoney(p.value) : fmtPct(p.value)}${p.isCampaign ? ' (campaign day)' : ''}</title>
</rect>`;
}).join('');
// mean line
const meanY = yToPx(trend.mean || 0);
const meanLine = `
<line x1="${padL}" x2="${W - padR}" y1="${meanY}" y2="${meanY}"
stroke="var(--ink)" stroke-width="0.7" stroke-dasharray="2 3" opacity="0.5" />
<text x="${W - padR - 2}" y="${Math.max(8, meanY - 2)}" text-anchor="end"
font-size="9" fill="var(--mut)">mean ${metric === 'revPerRecipient' ? fmtMoney(trend.mean) : fmtPct(trend.mean)}</text>
`;
// x-axis: tick at first / mid / last date
const firstDate = series[0].date;
const midDate = series[Math.floor(series.length / 2)].date;
const lastDate = series[series.length - 1].date;
const tickStyle = 'font-size:9px;fill:var(--mut)';
const axis = `
<text x="${padL}" y="${H - 2}" style="${tickStyle}">${esc(firstDate)}</text>
<text x="${W / 2}" y="${H - 2}" text-anchor="middle" style="${tickStyle}">${esc(midDate)}</text>
<text x="${W - padR}" y="${H - 2}" text-anchor="end" style="${tickStyle}">${esc(lastDate)}</text>
`;
return `<svg viewBox="0 0 ${W} ${H}" width="100%" height="${H}" preserveAspectRatio="none"
style="display:block;background:#fff;border:1px solid var(--line);border-radius:8px">
${bands}
${bars}
${meanLine}
${axis}
</svg>`;
}
// ── KPI tile ─────────────────────────────────────────────────────────────
function kpiTile(metric, kpi, isSelected) {
const band = BAND[kpi.band] || BAND.unknown;
const label = METRIC_LABELS[metric];
const selectedRing = isSelected
? 'box-shadow:0 0 0 2px var(--ink)'
: '';
const trendColor = kpi.trend > 0 ? 'var(--sage)' : kpi.trend < 0 ? 'var(--accent)' : 'var(--mut)';
return `
<div data-metric="${esc(metric)}" class="sp-tile" role="button" tabindex="0"
style="background:${band.bg};border-radius:10px;padding:10px 12px;cursor:pointer;
transition:transform .12s ease;${selectedRing}">
<div class="row" style="justify-content:space-between;align-items:baseline">
<span style="font-size:10.5px;letter-spacing:.5px;text-transform:uppercase;
color:${band.fg};font-weight:700">${esc(label.short)}</span>
<span style="font-size:10px;color:${trendColor};font-weight:700">${esc(fmtTrend(kpi.trend))}</span>
</div>
<div style="font:600 22px/1.1 'Cormorant Garamond',Georgia,serif;color:var(--ink);margin-top:2px">
${esc(fmtValue(metric, kpi.value))}
</div>
<div class="muted" style="font-size:10.5px;margin-top:1px">${esc(label.hint)}</div>
</div>`;
}
function badge(seg) {
const b = BAND[seg.overallBand] || BAND.unknown;
return `<span class="pill" style="background:${b.bg};color:${b.fg}">${esc(b.label)}</span>`;
}
// ── render one segment card ──────────────────────────────────────────────
function renderCard(seg, trend, selectedMetric) {
const tiles = ['openRate', 'ctor', 'clickRate', 'revPerRecipient']
.map(m => kpiTile(m, seg.kpis[m], m === selectedMetric))
.join('');
const trendChart = trend
? renderTrendSvg(trend, selectedMetric)
: '<div class="loading" style="padding:30px">Loading trend…</div>';
const trendLabel = METRIC_LABELS[selectedMetric].short;
const trendDelta = trend ? fmtTrend(trend.pctChange) : '';
const trendDeltaColor = trend && trend.pctChange > 0 ? 'var(--sage)'
: trend && trend.pctChange < 0 ? 'var(--accent)' : 'var(--mut)';
return `
<div class="card" data-segment="${esc(seg.id)}">
<div class="row" style="justify-content:space-between;align-items:baseline">
<div>
<h2 style="display:flex;gap:8px;align-items:center">
<span style="font-size:18px">${esc(seg.icon || '◆')}</span>
${esc(seg.name)}
</h2>
<div class="muted" style="font-size:11.5px">${esc(seg.description)}</div>
</div>
${badge(seg)}
</div>
<div class="row" style="gap:14px;margin:10px 0 0;font-size:11.5px;color:var(--mut)">
<span>List: <b style="color:var(--ink)">${fmtNum(seg.listSize)}</b></span>
<span>Sends / campaign: <b style="color:var(--ink)">${fmtNum(seg.sendsPerCampaign)}</b></span>
</div>
<div class="grid sp-tiles" style="grid-template-columns:repeat(2,1fr);gap:8px;margin-top:12px">
${tiles}
</div>
<div style="margin-top:14px">
<div class="row" style="justify-content:space-between;align-items:baseline">
<label style="margin:0">90-day trend · ${esc(trendLabel)}</label>
<span style="font-size:11px;color:${trendDeltaColor};font-weight:700">${esc(trendDelta)} 30d vs 30d</span>
</div>
<div class="sp-chart" style="margin-top:6px">${trendChart}</div>
</div>
</div>`;
}
// ── orchestration ────────────────────────────────────────────────────────
async function fetchAllTrends(segments, metric) {
const out = {};
await Promise.all(segments.map(async s => {
const t = await api(`/trend?segmentId=${encodeURIComponent(s.id)}&metric=${encodeURIComponent(metric)}&days=90`);
if (t.mock) anyMock = true;
out[s.id] = t;
}));
return out;
}
async function render() {
const metric = $('#sp-metric').value;
const target = $('#sp-cards');
target.innerHTML = '<div class="loading" style="grid-column:1 / -1">Loading segments…</div>';
const summary = await api('/summary');
summaryCache = summary;
if (summary.mock) anyMock = true;
benchmarks = summary.benchmarks;
renderLegend();
banner();
// paint cards first with no chart, then fill in trends as they arrive
target.innerHTML = summary.segments.map(s => renderCard(s, null, metric)).join('');
wireTileClicks();
const trends = await fetchAllTrends(summary.segments, metric);
banner();
target.innerHTML = summary.segments
.map(s => renderCard(s, trends[s.id], metric))
.join('');
wireTileClicks();
}
// Click a KPI tile in any card → switch the selected trend metric and
// re-render. Saves the user from hunting for the dropdown.
function wireTileClicks() {
$$('.sp-tile').forEach(el => {
el.onclick = () => {
const m = el.getAttribute('data-metric');
if (!m) return;
$('#sp-metric').value = m;
render();
};
el.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); el.click(); } };
});
}
$('#sp-metric').onchange = render;
$('#sp-refresh').onclick = render;
render();
},
};