← back to Marketing Command Center

public/panels/ab-tests.js

288 lines

// A/B Tests panel — author two variants, set a holdout %, stage the test,
// and review predicted opens/clicks + a winner call. STAGED only — no send
// action is exposed in the UI.
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['ab-tests'] = {
  init(root) {
    const $ = s => root.querySelector(s);
    const $$ = s => Array.from(root.querySelectorAll(s));
    const api = (p, opts) => fetch(`/api/ab-tests${p}`, opts).then(r => r.json());
    const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
    const pct = (n, d = 1) => (n == null ? '—' : (n * 100).toFixed(d) + '%');
    const fmtDate = d => d ? new Date(d).toLocaleString(undefined,
      { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—';

    let META = null;
    let SELECTED = null;

    function banner() {
      $('#ab-banner').innerHTML = `<div class="muted-banner">
        <b>STAGED only.</b> This module plans and tracks A/B tests with predicted
        (mock) open/click rates. It does <b>not</b> send email. Approved variants
        ship through Constant Contact, where Steve gates the live send.
      </div>`;
    }

    function fillAngles() {
      const angles = (META && META.angles) || [];
      const options = ['<option value="">— angle (optional) —</option>']
        .concat(angles.map(a => `<option value="${esc(a)}">${esc(a)}</option>`)).join('');
      $('#ab-a-angle').innerHTML = options;
      $('#ab-b-angle').innerHTML = options;
    }

    // ── list ─────────────────────────────────────────────────────────────
    async function loadList() {
      $('#ab-list').classList.add('loading');
      $('#ab-list').textContent = 'Loading…';
      const data = await api('/tests');
      $('#ab-count').textContent = `${data.count} test${data.count === 1 ? '' : 's'}`;
      const target = $('#ab-list');
      target.classList.remove('loading');
      if (!data.tests || !data.tests.length) {
        target.innerHTML = '<div class="muted">No tests staged yet. Use the form to draft variant A and variant B.</div>';
        return;
      }
      target.innerHTML = `
        <table style="border-collapse:collapse;width:100%;font-size:13px">
          <thead>
            <tr style="text-align:left;font-size:11px;color:var(--mut)">
              <th style="padding:6px 8px">Name</th>
              <th style="padding:6px 8px">Status</th>
              <th style="padding:6px 8px">Audience</th>
              <th style="padding:6px 8px">Hold</th>
              <th style="padding:6px 8px">Winner</th>
              <th style="padding:6px 8px">Created</th>
              <th></th>
            </tr>
          </thead>
          <tbody>
            ${data.tests.map(t => `
              <tr data-id="${esc(t.id)}" style="border-top:1px solid var(--line);cursor:pointer"
                  class="ab-row${SELECTED === t.id ? ' ab-row-active' : ''}">
                <td style="padding:7px 8px">
                  <div style="font-weight:600">${esc(t.name)}</div>
                  <div class="muted" style="font-size:11px">A: ${esc((t.subjectA || '').slice(0, 40))}${t.subjectA && t.subjectA.length > 40 ? '…' : ''}</div>
                </td>
                <td style="padding:7px 8px">
                  <span class="pill" style="background:${t.status === 'concluded' ? 'var(--cream)' : '#efe7d6'};
                    color:${t.status === 'concluded' ? 'var(--sage)' : 'var(--accent)'}">${esc(t.status)}</span>
                </td>
                <td style="padding:7px 8px">${Number(t.audienceSize || 0).toLocaleString()}</td>
                <td style="padding:7px 8px">${esc(String(t.holdoutPct))}%</td>
                <td style="padding:7px 8px">${t.winner ? `<b>${esc(t.winner)}</b>` : '<span class="muted">—</span>'}</td>
                <td style="padding:7px 8px" class="muted" style="font-size:11px">${esc(fmtDate(t.createdAt))}</td>
                <td style="padding:7px 8px;text-align:right">
                  <button class="btn ghost ab-view" data-id="${esc(t.id)}" style="padding:4px 10px;font-size:12px">View</button>
                </td>
              </tr>
            `).join('')}
          </tbody>
        </table>
      `;
      target.querySelectorAll('.ab-view').forEach(b => b.onclick = (e) => {
        e.stopPropagation();
        loadDetail(b.dataset.id);
      });
      target.querySelectorAll('.ab-row').forEach(r => r.onclick = () => loadDetail(r.dataset.id));
    }

    // ── detail ───────────────────────────────────────────────────────────
    async function loadDetail(id) {
      SELECTED = id;
      const data = await api(`/tests/${encodeURIComponent(id)}`);
      if (data.error) {
        $('#ab-detail-card').style.display = 'none';
        return;
      }
      const t = data.test;
      $('#ab-detail-card').style.display = '';

      const r = t.results || {};
      const w = t.winner || {};
      const a = r.variant_a || {};
      const b = r.variant_b || {};

      const variantCard = (v, k, res, isWinner) => `
        <div class="card" style="margin:0;padding:14px;
             background:${isWinner ? 'var(--cream)' : '#fff'};
             border-color:${isWinner ? 'var(--gold)' : 'var(--line)'}">
          <div class="row" style="justify-content:space-between;align-items:baseline">
            <h2 style="margin:0;font-size:15px">Variant ${esc(k)}</h2>
            ${isWinner ? `<span class="pill" style="background:var(--gold);color:var(--ink)">winner</span>`
                       : `<span class="pill">${esc(v.angle || '—')}</span>`}
          </div>
          <div style="margin-top:8px">
            <div class="muted" style="font-size:11px">Subject</div>
            <div style="font-weight:600">${esc(v.subject)}</div>
          </div>
          <div style="margin-top:8px">
            <div class="muted" style="font-size:11px">Body / angle</div>
            <div style="font-size:13px;color:var(--ink)">${esc(v.body)}</div>
          </div>
          <div class="row" style="margin-top:12px;gap:18px">
            <div><div class="muted" style="font-size:11px">Sent</div>
              <div style="font-weight:700;font-size:18px">${Number(res.sent || 0).toLocaleString()}</div></div>
            <div><div class="muted" style="font-size:11px">Opens</div>
              <div style="font-weight:700;font-size:18px">${Number(res.opens || 0).toLocaleString()}
                <span style="font-size:12px;color:var(--mut);font-weight:400">(${pct(res.openRate, 2)})</span></div></div>
            <div><div class="muted" style="font-size:11px">Clicks</div>
              <div style="font-weight:700;font-size:18px">${Number(res.clicks || 0).toLocaleString()}
                <span style="font-size:12px;color:var(--mut);font-weight:400">(${pct(res.clickRate, 2)})</span></div></div>
          </div>
          ${(res.factors || []).length ? `
            <div style="margin-top:10px">
              <div class="muted" style="font-size:11px">Copy-quality factors</div>
              <div style="margin-top:4px">${
                res.factors.map(f => `<span class="pill" style="margin:2px 4px 0 0;background:#fbf9f5;border:1px solid var(--line);color:var(--ink)">${esc(f.name)}</span>`).join('')
              }</div>
            </div>` : ''}
        </div>`;

      const isAWin = w.winner === 'A';
      const isBWin = w.winner === 'B';

      const winnerCallout = w.winner
        ? `<div style="background:#fff;border:1px solid var(--line);border-radius:10px;padding:14px;margin-top:14px">
            <div class="row" style="justify-content:space-between;align-items:baseline">
              <div>
                <div class="muted" style="font-size:11px">Winner call</div>
                <div style="font:600 22px/1.2 'Cormorant Garamond',Georgia,serif;color:var(--accent)">
                  Variant ${esc(w.winner)} wins on ${esc(w.metric)}
                </div>
              </div>
              <span class="pill" style="background:${w.confidence === 'high' ? 'var(--sage)' : w.confidence === 'med' ? 'var(--gold)' : 'var(--cream)'};
                                       color:${w.confidence === 'high' || w.confidence === 'med' ? 'var(--ink)' : 'var(--mut)'}">
                ${esc(w.confidence)} confidence
              </span>
            </div>
            <div class="row" style="gap:18px;margin-top:10px;font-size:13px">
              <div><span class="muted">Lift abs:</span> <b>${pct(w.liftAbsolute, 2)}</b></div>
              <div><span class="muted">Lift rel:</span> <b>${(w.liftRelative * 100).toFixed(1)}%</b></div>
              <div><span class="muted">z-score:</span> <b>${esc(String(w.z))}</b></div>
              <div><span class="muted">Composite A/B:</span> <b>${esc(String(w.compositeA))}</b> / <b>${esc(String(w.compositeB))}</b></div>
            </div>
            <div class="muted" style="font-size:11px;margin-top:8px">
              Winner is chosen by a blended 60/40 open-rate × click-rate composite; confidence
              comes from a two-proportion z-test on open-rate.
            </div>
          </div>`
        : '<div class="muted" style="margin-top:12px">No winner — variants tied or audience empty.</div>';

      $('#ab-detail').innerHTML = `
        <div class="row" style="justify-content:space-between;align-items:baseline">
          <div>
            <h2 style="margin:0">${esc(t.name)}</h2>
            <div class="muted" style="font-size:12px">
              ${esc(t.audienceSize.toLocaleString())} contacts · ${esc(String(t.holdoutPct))}% holdout
              (${Number(r.holdout && r.holdout.held || 0).toLocaleString()} held) ·
              status <b>${esc(t.status)}</b> · simulated ${esc(fmtDate(r.simulatedAt))}
            </div>
          </div>
          <div class="row" style="gap:8px">
            <button class="btn ghost" data-act="simulate" data-id="${esc(t.id)}"
                    ${t.status === 'concluded' ? 'disabled style="opacity:.5;cursor:not-allowed"' : ''}>Re-simulate</button>
            <button class="btn" data-act="conclude" data-id="${esc(t.id)}"
                    ${t.status === 'concluded' ? 'disabled style="opacity:.5;cursor:not-allowed"' : ''}>Freeze winner</button>
            <button class="btn ghost" data-act="delete" data-id="${esc(t.id)}"
                    style="color:var(--accent)">Delete</button>
          </div>
        </div>

        ${winnerCallout}

        <div class="grid" style="grid-template-columns:1fr 1fr;margin-top:14px">
          ${variantCard(t.variants[0], 'A', a, isAWin)}
          ${variantCard(t.variants[1], 'B', b, isBWin)}
        </div>

        <div class="muted" style="font-size:11px;margin-top:12px">
          Mock numbers — predicted using a deterministic copy-quality heuristic
          over DW historical baselines (open ${pct((META && META.baseline.openRate) || 0.22)},
          click ${pct((META && META.baseline.clickRate) || 0.032)}). Real reporting
          can flow back through <code>PATCH /api/ab-tests/tests/${esc(t.id)}/results</code> later.
        </div>
      `;

      $('#ab-detail').querySelectorAll('button[data-act]').forEach(btn => {
        btn.onclick = async () => {
          const act = btn.dataset.act;
          if (act === 'delete') {
            if (!confirm('Delete this staged A/B test? This cannot be undone.')) return;
            await api(`/tests/${encodeURIComponent(btn.dataset.id)}`, { method: 'DELETE' });
            $('#ab-detail-card').style.display = 'none';
            SELECTED = null;
            await loadList();
            return;
          }
          if (act === 'conclude') {
            if (!confirm('Freeze this test? Status becomes "concluded" and results stop changing.')) return;
          }
          const url = `/tests/${encodeURIComponent(btn.dataset.id)}/${act}`;
          const res = await api(url, { method: 'POST' });
          if (res.error) { alert(res.error); return; }
          await loadList();
          await loadDetail(btn.dataset.id);
        };
      });
    }

    // ── form ─────────────────────────────────────────────────────────────
    $('#ab-create').onclick = async () => {
      const body = {
        name: $('#ab-name').value.trim(),
        audienceSize: Number($('#ab-audience').value),
        holdoutPct: Number($('#ab-holdout').value),
        variants: [
          {
            subject: $('#ab-a-subject').value.trim(),
            body:    $('#ab-a-body').value.trim(),
            angle:   $('#ab-a-angle').value,
          },
          {
            subject: $('#ab-b-subject').value.trim(),
            body:    $('#ab-b-body').value.trim(),
            angle:   $('#ab-b-angle').value,
          },
        ],
      };
      $('#ab-form-msg').textContent = 'Staging…';
      $('#ab-form-msg').style.color = 'var(--mut)';

      const res = await api('/tests', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
      });
      if (res.error) {
        $('#ab-form-msg').style.color = 'var(--accent)';
        $('#ab-form-msg').textContent = res.error + (res.issues ? ' — ' + res.issues.join(', ') : '');
        return;
      }
      $('#ab-form-msg').style.color = 'var(--sage)';
      $('#ab-form-msg').textContent = `Staged: ${res.test.id}`;
      // soft reset
      ['ab-name', 'ab-a-subject', 'ab-a-body', 'ab-b-subject', 'ab-b-body'].forEach(id => $('#' + id).value = '');
      await loadList();
      await loadDetail(res.test.id);
    };

    $('#ab-refresh').onclick = loadList;

    (async () => {
      banner();
      META = await api('/meta');
      fillAngles();
      // Sensible defaults that show off the heuristic on first render.
      $('#ab-a-subject').value = 'Discover the grasscloth archive — designer';
      $('#ab-a-body').value = 'A fresh memo of Phillip Jeffries & Innovations grasscloths from the new archive. Request memo samples to feel the texture.';
      $('#ab-a-angle').value = 'editorial';
      $('#ab-b-subject').value = 'FREE samples!!! click here';
      $('#ab-b-body').value = 'Get free wallpaper now act fast last chance.';
      $('#ab-b-angle').value = 'promo';
      await loadList();
    })();
  },
};