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