← 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 =>
      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 &lt; ${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();
  },
};