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