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