← back to Marketing Command Center

public/panels/playbook.js

311 lines

// playbook panel — Monthly Marketing Playbook viewer/editor.
// Suggestions only — no live sends. Talks to /api/playbook.
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['playbook'] = {
  async init(root) {
    const $  = s => root.querySelector(s);
    const api = (p, opts) => fetch(`/api/playbook${p}`, opts).then(r => r.json());
    const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c =>
      ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
    const fmtDate = d => d ? new Date(d + 'T00:00:00').toLocaleDateString(undefined,
      { weekday:'short', month:'short', day:'numeric' }) : '—';
    const pad = n => String(n).padStart(2,'0');

    let META = null;
    let MONTH = null;
    let PB = null;
    let SIGNALS = null;

    function bandClass(b){ return b === 'good' ? 'sage' : b === 'ok' ? 'gold' : b === 'bad' ? 'accent' : 'mut'; }
    function bandLabel(b){ return b === 'good' ? 'HEALTHY' : b === 'ok' ? 'WATCH' : b === 'bad' ? 'NEEDS HELP' : '—'; }
    function sourcePill(src){
      const map = { calendar:['var(--cream)','var(--accent)','calendar'],
                    'segment-health':['#eef4ec','var(--sage)','segment'],
                    'performance-gap':['#fff0e6','var(--accent)','gap'],
                    'fallback-editorial':['var(--paper)','var(--mut)','editorial'],
                    manual:['#f0ece4','var(--ink)','manual'],
                    gemini:['#eaf0fb','#3556a5','gemini'] };
      const [bg, fg, label] = map[src] || map.manual;
      return `<span class="pill" style="background:${bg};color:${fg}">${esc(label)}</span>`;
    }

    function thisMonthId(){ const d = new Date(); return `${d.getFullYear()}-${pad(d.getMonth()+1)}`; }

    function banner(){
      const live = META && META.geminiKeyed;
      $('#pb-banner').innerHTML = `<div class="muted-banner">
        📅 <b>SUGGESTIONS ONLY.</b> ${esc(META && META.gateMessage || '')}
        ${live ? '' : '<br><b>Mock mode:</b> Gemini key absent — using deterministic template generator.'}
      </div>`;
    }

    // ── load ────────────────────────────────────────────────────────────────
    try { META = await api('/meta'); }
    catch { $('#pb-list').innerHTML = '<div class="muted-banner">Could not reach the playbook service.</div>'; return; }

    MONTH = thisMonthId();
    $('#pb-month').value = MONTH;
    banner();

    async function loadAll(){
      $('#pb-list').classList.add('loading'); $('#pb-list').textContent = 'Loading…';
      $('#pb-signals-cal').classList.add('loading'); $('#pb-signals-cal').textContent = 'Loading…';
      $('#pb-signals-seg').classList.add('loading'); $('#pb-signals-seg').textContent = 'Loading…';

      const [pbRes, sigRes] = await Promise.all([
        api(`/playbook?month=${encodeURIComponent(MONTH)}`),
        api(`/signals?month=${encodeURIComponent(MONTH)}`),
      ]);
      SIGNALS = sigRes.signals || null;
      PB = pbRes.playbook || null;
      $('#pb-month-pill').textContent = MONTH;
      renderSignals();
      renderStats();
      renderList();
      renderGenMeta();
    }

    function renderGenMeta(){
      const m = $('#pb-mode-pill');
      if (!PB){ m.textContent = 'not generated'; m.style.background = 'var(--paper)'; m.style.color = 'var(--mut)'; }
      else if (PB.mode === 'gemini'){ m.textContent = 'gemini'; m.style.background = '#eaf0fb'; m.style.color = '#3556a5'; }
      else if (PB.mode === 'template'){ m.textContent = 'template'; m.style.background = 'var(--cream)'; m.style.color = 'var(--accent)'; }
      else if (PB.mode === 'template-fallback'){ m.textContent = 'template (gemini failed)'; m.style.background = '#fff0e6'; m.style.color = 'var(--accent)'; }
      else { m.textContent = PB.mode || '—'; m.style.background = 'var(--paper)'; m.style.color = 'var(--mut)'; }

      const meta = $('#pb-gen-meta');
      if (!PB) meta.textContent = 'No playbook yet for this month — hit Regenerate to seed one from current signals.';
      else {
        const when = PB.generatedAt ? new Date(PB.generatedAt).toLocaleString() : '—';
        const err = PB.generatorError ? ` · <span style="color:var(--accent)">gemini error: ${esc(PB.generatorError)}</span>` : '';
        meta.innerHTML = `${esc(PB.entries.length)} ideas · generated ${esc(when)} · window starts ${esc(PB.startIso || '—')}${err}`;
      }
    }

    function renderStats(){
      $('#pb-stat-count').textContent    = PB ? PB.entries.length : '—';
      $('#pb-stat-calendar').textContent = SIGNALS ? SIGNALS.dates.length : '—';
      $('#pb-stat-gaps').textContent     = SIGNALS ? SIGNALS.gaps.length  : '—';
      $('#pb-stat-healthy').textContent  = SIGNALS ? SIGNALS.segments.filter(s => s.band === 'good').length : '—';
    }

    function renderSignals(){
      const cal = $('#pb-signals-cal'); cal.classList.remove('loading');
      const seg = $('#pb-signals-seg'); seg.classList.remove('loading');

      if (!SIGNALS){ cal.innerHTML = '<div class="muted">No signal data.</div>'; seg.innerHTML = '<div class="muted">No signal data.</div>'; return; }

      const dates = SIGNALS.dates || [];
      if (!dates.length){
        cal.innerHTML = '<div class="muted">No curated marketing moments in this window.</div>';
      } else {
        cal.innerHTML = `<div style="display:flex;flex-direction:column;gap:6px">` + dates.slice(0, 10).map(d => `
          <div style="display:grid;grid-template-columns:88px 1fr auto;gap:10px;padding:7px 0;border-bottom:1px dashed var(--line)">
            <div class="muted" style="font-size:11.5px">${esc(fmtDate(d.date))}</div>
            <div>
              <div style="font-weight:600;font-size:13px">${esc(d.title)}</div>
              <div class="muted" style="font-size:11.5px;margin-top:2px">${esc(d.suggestion || '')}</div>
            </div>
            <span class="pill" style="background:var(--cream);align-self:start">${esc(d.type)}</span>
          </div>
        `).join('') + `</div>`;
      }

      const segs = SIGNALS.segments || [];
      seg.innerHTML = `<div style="display:flex;flex-direction:column;gap:6px">` + segs.map(s => `
        <div style="display:grid;grid-template-columns:1fr auto auto;gap:10px;padding:7px 0;border-bottom:1px dashed var(--line);align-items:center">
          <div>
            <div style="font-weight:600;font-size:13px">${esc(s.icon || '')} ${esc(s.name)}</div>
            <div class="muted" style="font-size:11.5px">${(s.openRate*100).toFixed(1)}% open · trend ${s.pctChange30d}%</div>
          </div>
          <span class="pill" style="background:var(--paper);color:var(--${bandClass(s.band)})">${bandLabel(s.band)}</span>
          <span class="muted" style="font-size:11.5px">${esc((s.listSize||0).toLocaleString())} contacts</span>
        </div>
      `).join('') +
      (SIGNALS.gaps && SIGNALS.gaps.length
        ? `<div style="margin-top:10px;padding-top:8px;border-top:1px solid var(--line)">
             <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.6px;margin-bottom:5px">Performance gaps</div>
             ${SIGNALS.gaps.map(g => `<div style="font-size:12px;margin-bottom:4px">⚠ ${esc(g.note)}</div>`).join('')}
           </div>`
        : '') + `</div>`;
    }

    function renderList(){
      const list = $('#pb-list'); list.classList.remove('loading');
      if (!PB || !PB.entries.length){
        list.innerHTML = `<div class="muted-banner">
          No playbook yet for <b>${esc(MONTH)}</b>. Press <b>↻ Regenerate</b> above to draft one from current signals.
        </div>`;
        return;
      }
      // group by week
      const byWeek = {1:[],2:[],3:[],4:[]};
      for (const e of PB.entries){ const w = e.week || 1; (byWeek[w] || byWeek[1]).push(e); }

      const html = [1,2,3,4].map(w => {
        const items = byWeek[w];
        if (!items.length) return `
          <div style="margin-bottom:14px">
            <div class="muted" style="font-size:11.5px;text-transform:uppercase;letter-spacing:.6px;margin-bottom:6px">Week ${w}</div>
            <div class="muted" style="font-size:12.5px;padding:8px 12px;border:1px dashed var(--line);border-radius:9px">Open slot — no plays scheduled.</div>
          </div>`;
        return `
          <div style="margin-bottom:14px">
            <div class="muted" style="font-size:11.5px;text-transform:uppercase;letter-spacing:.6px;margin-bottom:6px">Week ${w}</div>
            <div style="display:flex;flex-direction:column;gap:8px">
              ${items.map(e => `
                <div style="border:1px solid var(--line);border-radius:11px;padding:11px 14px;background:#fff">
                  <div class="row" style="justify-content:space-between;align-items:baseline;gap:10px">
                    <div style="min-width:0">
                      <div style="font:600 15px/1.25 'Cormorant Garamond',Georgia,serif;color:var(--ink)">${esc(e.title)}</div>
                      <div class="muted" style="font-size:11.5px;margin-top:2px">
                        ${esc(fmtDate(e.date))} · ${esc(e.audienceLabel || e.audience)} · ${esc(e.channel)} · <span style="text-transform:uppercase;letter-spacing:.4px">${esc(e.type)}</span>
                      </div>
                    </div>
                    <div class="row" style="gap:6px;flex-shrink:0">
                      ${sourcePill(e.source)}
                      <span class="pill" style="background:var(--paper);color:var(--mut)">${esc(e.status || 'suggested')}</span>
                    </div>
                  </div>
                  <div style="font-size:12.5px;margin-top:7px;color:var(--ink)">${esc(e.rationale || '')}</div>
                  ${e.notes ? `<div class="muted" style="font-size:11.5px;margin-top:6px"><b>Notes:</b> ${esc(e.notes)}</div>` : ''}
                  <div class="row" style="gap:6px;margin-top:10px;justify-content:flex-end">
                    <button class="btn ghost" data-act="edit" data-id="${esc(e.id)}" style="padding:5px 10px;font-size:11.5px">Edit</button>
                    <button class="btn ghost" data-act="calendar" data-id="${esc(e.id)}" style="padding:5px 10px;font-size:11.5px">→ Add to Calendar</button>
                    <button class="btn ghost" data-act="delete" data-id="${esc(e.id)}" style="padding:5px 10px;font-size:11.5px;color:var(--accent)">Delete</button>
                  </div>
                </div>
              `).join('')}
            </div>
          </div>`;
      }).join('');
      list.innerHTML = html;
    }

    function renderEdit(entry){
      const card = $('#pb-edit-card'); card.style.display = 'block';
      $('#pb-edit-title').textContent = entry ? 'Edit entry' : 'Add entry';
      const e = entry || {
        date: PB ? PB.startIso : MONTH + '-01', title: '', audience: 'designers',
        channel: 'email', type: 'campaign', rationale: '', notes: '', status: 'suggested', source: 'manual',
      };
      const audOpts = (META.audiences || []).map(a =>
        `<option value="${esc(a.id)}" ${a.id === e.audience ? 'selected' : ''}>${esc(a.label)}</option>`).join('');
      const chOpts = (META.channels || ['email']).map(c =>
        `<option value="${esc(c)}" ${c === e.channel ? 'selected' : ''}>${esc(c)}</option>`).join('');
      const stOpts = (META.statuses || ['suggested']).map(s =>
        `<option value="${esc(s)}" ${s === e.status ? 'selected' : ''}>${esc(s)}</option>`).join('');

      $('#pb-edit').innerHTML = `
        <div class="grid" style="grid-template-columns:1fr 1fr;gap:10px">
          <div><label>Title</label><input id="pe-title" value="${esc(e.title)}" /></div>
          <div><label>Date</label><input id="pe-date" type="date" value="${esc(e.date)}" /></div>
          <div><label>Audience</label><select id="pe-audience">${audOpts}</select></div>
          <div><label>Channel</label><select id="pe-channel">${chOpts}</select></div>
          <div><label>Type</label><input id="pe-type" value="${esc(e.type)}" /></div>
          <div><label>Status</label><select id="pe-status">${stOpts}</select></div>
        </div>
        <div style="margin-top:8px"><label>Rationale</label><textarea id="pe-rationale" rows="3">${esc(e.rationale)}</textarea></div>
        <div style="margin-top:8px"><label>Notes</label><textarea id="pe-notes" rows="2">${esc(e.notes)}</textarea></div>
        <div class="row" style="justify-content:flex-end;gap:8px;margin-top:12px">
          <span class="muted" id="pe-msg" style="font-size:12px;align-self:center;flex:1">&nbsp;</span>
          <button class="btn ghost" id="pe-cancel" style="padding:7px 12px">Cancel</button>
          <button class="btn" id="pe-save" style="padding:7px 12px" data-id="${esc(e.id || '')}">${entry ? 'Save changes' : 'Add to playbook'}</button>
        </div>
      `;

      $('#pe-cancel').onclick = () => { card.style.display = 'none'; };
      $('#pe-save').onclick = async () => {
        const body = {
          month: MONTH,
          id: entry ? entry.id : undefined,
          title: $('#pe-title').value.trim(),
          date: $('#pe-date').value,
          audience: $('#pe-audience').value,
          channel: $('#pe-channel').value,
          type: $('#pe-type').value.trim(),
          status: $('#pe-status').value,
          rationale: $('#pe-rationale').value.trim(),
          notes: $('#pe-notes').value.trim(),
        };
        const msg = $('#pe-msg');
        if (!body.title) { msg.textContent = 'Title is required.'; return; }
        if (!body.date)  { msg.textContent = 'Date is required.'; return; }
        msg.textContent = 'Saving…';
        try {
          let res;
          if (entry) {
            res = await api(`/entry/${encodeURIComponent(entry.id)}?month=${encodeURIComponent(MONTH)}`, {
              method:'PUT', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(body),
            });
          } else {
            res = await api(`/entry`, {
              method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(body),
            });
          }
          if (res && res.playbook) { PB = res.playbook; renderList(); renderStats(); renderGenMeta(); }
          card.style.display = 'none';
        } catch (e) { msg.textContent = 'Save failed: ' + e.message; }
      };
    }

    // ── event wiring ────────────────────────────────────────────────────────
    $('#pb-month').addEventListener('change', e => { MONTH = e.target.value; loadAll(); });

    $('#pb-regen').addEventListener('click', async () => {
      const btn = $('#pb-regen'); const orig = btn.textContent;
      btn.disabled = true; btn.textContent = '… generating';
      try {
        const res = await api('/generate', {
          method:'POST', headers:{ 'Content-Type':'application/json' },
          body: JSON.stringify({ month: MONTH }),
        });
        if (res && res.playbook) { PB = res.playbook; SIGNALS = PB.signals || SIGNALS; }
        renderSignals(); renderStats(); renderList(); renderGenMeta();
      } catch (e) { alert('Regenerate failed: ' + e.message); }
      finally { btn.disabled = false; btn.textContent = orig; }
    });

    $('#pb-add').addEventListener('click', () => renderEdit(null));
    $('#pb-edit-close').addEventListener('click', () => { $('#pb-edit-card').style.display = 'none'; });

    $('#pb-list').addEventListener('click', async ev => {
      const btn = ev.target.closest('button[data-act]');
      if (!btn) return;
      const id = btn.getAttribute('data-id');
      const entry = PB && PB.entries.find(e => e.id === id);
      const act = btn.getAttribute('data-act');
      if (act === 'edit') return renderEdit(entry);
      if (act === 'delete') {
        if (!confirm(`Delete "${entry && entry.title}" from the playbook? This does not affect the calendar.`)) return;
        const res = await api(`/delete?month=${encodeURIComponent(MONTH)}&id=${encodeURIComponent(id)}`, { method:'POST' });
        if (res && res.playbook) { PB = res.playbook; renderList(); renderStats(); renderGenMeta(); }
        return;
      }
      if (act === 'calendar') {
        if (!entry) return;
        if (!confirm(`Add "${entry.title}" to the marketing Calendar as a planned campaign on ${entry.date}? It will appear in /calendar — no email is sent.`)) return;
        try {
          const r = await fetch('/api/calendar/schedule', {
            method:'POST', headers:{ 'Content-Type':'application/json' },
            body: JSON.stringify({
              date: entry.date, title: entry.title,
              channel: (entry.channel || '').includes('instagram') ? 'instagram' : 'email',
              status: 'planned',
              notes: `From playbook · audience: ${entry.audienceLabel || entry.audience} · ${entry.rationale || ''}`,
            }),
          }).then(r => r.json());
          if (r && r.ok) {
            btn.textContent = '✓ Added to calendar';
            btn.disabled = true;
          } else {
            alert('Could not add to calendar: ' + (r && r.error || 'unknown error'));
          }
        } catch (e) { alert('Could not add to calendar: ' + e.message); }
      }
    });

    await loadAll();
  },
};