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