← back to Marketing Command Center
public/panels/send-times.js
209 lines
// Send-Time Optimization panel — picks a segment, fetches a day×hour heatmap of
// historical opens, and renders: (1) the heatmap, (2) the recommended send
// window pill + backup slots, (3) a per-contact table of predicted peak windows.
// Always read-only; no send action lives here.
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['send-times'] = {
init(root) {
const $ = s => root.querySelector(s);
const api = (p) => fetch(`/api/send-times${p}`).then(r => r.json());
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const fmtHour = h => {
if (h == null) return '—';
const am = h < 12, h12 = h % 12 || 12;
return `${h12}:00 ${am ? 'AM' : 'PM'}`;
};
const fmtDate = d => d ? new Date(d).toLocaleDateString(undefined,
{ year: 'numeric', month: 'short', day: 'numeric' }) : '—';
let anyMock = false;
function banner() {
$('#st-banner').innerHTML = anyMock
? `<div class="muted-banner">Running on a <b>mock open-history pool</b> (~200 contacts,
12–48 synthetic opens each, persona-shaped distributions). Wire up a CTCT reporting
token or GA4 events to mine real open timestamps.</div>`
: '';
}
// ── segment picker ─────────────────────────────────────────────────────
async function loadSegments() {
const data = await api('/segments');
if (data.mock) { anyMock = true; banner(); }
const sel = $('#st-segment');
sel.innerHTML = (data.segments || []).map(s =>
`<option value="${esc(s.id)}">${esc(s.name)} (${Number(s.estimatedSize || 0).toLocaleString()})</option>`
).join('');
sel.onchange = () => render();
}
// ── heatmap rendering ──────────────────────────────────────────────────
function renderHeatmap(data) {
const card = $('#st-heatmap');
const M = data.matrix || [];
const days = data.dayLabels || ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
// find max for normalization
let max = 0;
for (const row of M) for (const v of row) if (v > max) max = v;
const scale = max || 1;
// recommended cell highlight
const recDay = data.recommendation ? data.recommendation.day : null;
const recHour = data.recommendation ? data.recommendation.hour : null;
// table-based heatmap (7 rows × 24 cols)
const hourHeader = Array.from({ length: 24 }, (_, h) =>
`<th style="font-weight:500;color:var(--mut);font-size:10px;padding:2px 0;text-align:center">${h}</th>`).join('');
const rows = M.map((row, d) => {
const cells = row.map((v, h) => {
const t = v / scale;
// gold gradient over the cream background
const alpha = v === 0 ? 0 : 0.10 + 0.85 * t;
const isRec = (d === recDay && h === recHour);
const ring = isRec ? 'outline:2px solid var(--accent);outline-offset:-2px' : '';
return `<td title="${esc(days[d])} ${fmtHour(h)} — ${v} open${v === 1 ? '' : 's'}"
style="background:rgba(184,146,90,${alpha.toFixed(3)});width:22px;height:22px;
border:1px solid #f0eadf;padding:0;${ring}"></td>`;
}).join('');
return `<tr>
<th style="font-weight:600;color:var(--ink);font-size:11px;text-align:right;padding-right:8px;width:34px">${esc(days[d])}</th>
${cells}
</tr>`;
}).join('');
card.classList.remove('loading');
card.innerHTML = `
<div style="overflow-x:auto">
<table style="border-collapse:collapse;font-size:11px">
<thead><tr><th></th>${hourHeader}</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
<div class="row" style="gap:14px;margin-top:10px;align-items:center;font-size:11px;color:var(--mut)">
<span>Hour of day (UTC, 0–23)</span>
<span class="row" style="gap:4px;align-items:center;margin-left:auto">
<span>fewer</span>
<span style="width:90px;height:10px;border:1px solid var(--line);border-radius:4px;
background:linear-gradient(to right, rgba(184,146,90,0.1), rgba(184,146,90,0.95))"></span>
<span>more</span>
</span>
</div>`;
}
function renderRecommendation(data) {
const rec = data.recommendation;
const target = $('#st-rec');
if (!rec) {
target.classList.remove('loading');
target.innerHTML = '<div class="muted">No open history in this audience yet.</div>';
return;
}
const backups = (data.backups || []).map(b =>
`<li><b>${esc(b.dayLabel)}</b> · ${esc(b.hourLabel)} <span class="muted">(${b.count} opens)</span></li>`
).join('');
target.classList.remove('loading');
target.innerHTML = `
<div style="background:var(--cream);border:1px solid var(--line);border-radius:10px;padding:14px;text-align:center">
<div style="font:600 28px/1.1 'Cormorant Garamond',Georgia,serif;color:var(--accent)">
${esc(rec.dayLabel)} · ${esc(rec.hourLabel)}
</div>
<div class="muted" style="font-size:12px;margin-top:4px">${rec.count} opens in this window</div>
</div>
${backups ? `<div style="margin-top:10px"><label>Backup slots</label>
<ul style="margin:6px 0 0;padding-left:18px;font-size:13px">${backups}</ul></div>` : ''}`;
}
function renderPerDay(data) {
const target = $('#st-perday');
const rows = (data.bestHourPerDay || []).filter(r => r.count > 0);
if (!rows.length) { target.textContent = '—'; return; }
target.style.fontSize = '12.5px';
target.style.color = 'var(--ink)';
target.innerHTML = rows.map(r =>
`<div class="row" style="justify-content:space-between;border-top:1px solid var(--line);padding:4px 0">
<span style="font-weight:600">${esc(r.dayLabel)}</span>
<span>${esc(fmtHour(r.hour))} <span class="muted" style="font-size:11px">(${r.count})</span></span>
</div>`).join('');
}
// ── per-contact table ──────────────────────────────────────────────────
function renderContacts(data) {
const target = $('#st-contacts');
$('#st-contact-count').textContent =
`${data.returned}/${data.total} contacts`;
if (!data.contacts || !data.contacts.length) {
target.classList.remove('loading');
target.innerHTML = '<div class="muted">No contacts in this audience.</div>';
return;
}
const confColor = c => c === 'high' ? 'var(--sage)' : c === 'med' ? 'var(--gold)' : 'var(--mut)';
const rows = data.contacts.map(c => `
<tr style="border-top:1px solid var(--line)">
<td style="padding:7px 8px;font-size:12.5px"><code style="font-size:12px">${esc(c.email)}</code></td>
<td style="padding:7px 8px"><span class="pill" style="background:${c.type === 'trade' ? '#efe7d6' : 'var(--cream)'}">${esc(c.type)}</span></td>
<td style="padding:7px 8px;font-size:12.5px">
${c.peak.dayLabel ? `<b>${esc(c.peak.dayLabel)}</b> · ${esc(c.peak.hourLabel)}` : '<span class="muted">insufficient data</span>'}
</td>
<td style="padding:7px 8px;font-size:11.5px;color:${confColor(c.peak.confidence)};font-weight:600;text-transform:uppercase">
${esc(c.peak.confidence)}${c.peak.share != null ? ` <span class="muted" style="font-weight:400;text-transform:none">(${Math.round(c.peak.share * 100)}%)</span>` : ''}
</td>
<td style="padding:7px 8px;font-size:12px;text-align:right">${c.peak.samples}</td>
<td style="padding:7px 8px;font-size:12px;text-align:right" class="muted">${esc(fmtDate(c.lastOpen))}</td>
</tr>`).join('');
target.classList.remove('loading');
target.innerHTML = `
<div style="overflow-x:auto">
<table style="border-collapse:collapse;width:100%">
<thead>
<tr style="text-align:left;font-size:11px;color:var(--mut)">
<th style="padding:6px 8px">Email</th>
<th style="padding:6px 8px">Type</th>
<th style="padding:6px 8px">Predicted peak</th>
<th style="padding:6px 8px">Confidence</th>
<th style="padding:6px 8px;text-align:right">Opens</th>
<th style="padding:6px 8px;text-align:right">Last open</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
// ── orchestration ──────────────────────────────────────────────────────
async function render() {
const segId = $('#st-segment').value || '__all__';
$('#st-heatmap').classList.add('loading');
$('#st-heatmap').textContent = 'Loading heatmap…';
$('#st-rec').classList.add('loading');
$('#st-rec').textContent = 'Loading…';
$('#st-contacts').classList.add('loading');
$('#st-contacts').textContent = 'Loading contacts…';
const [heat, cts] = await Promise.all([
api(`/heatmap?segmentId=${encodeURIComponent(segId)}`),
api(`/contacts?segmentId=${encodeURIComponent(segId)}&limit=50`),
]);
if (heat.mock || cts.mock) { anyMock = true; banner(); }
const segLabel = heat.segment ? heat.segment.name : 'All contacts';
$('#st-heatmap-title').textContent = `Open heatmap · ${segLabel}`;
$('#st-heatmap-sub').textContent =
`${heat.contactCount} contacts · ${heat.contactsWithHistory} with open history · ${heat.totalOpens.toLocaleString()} opens`;
$('#st-total').textContent = `${heat.totalOpens.toLocaleString()} opens`;
renderHeatmap(heat);
renderRecommendation(heat);
renderPerDay(heat);
renderContacts(cts);
}
$('#st-refresh').onclick = render;
(async () => {
await loadSegments();
await render();
})();
},
};