← back to Marketing Command Center
modules/playbook/index.js
615 lines
// playbook — Monthly Marketing Playbook generator for the Command Center.
//
// Pulls three live-ish signals from sibling modules:
// 1. Calendar dates — curated retail/holiday/awareness/trade moments for
// the four weeks ahead (modules/calendar + dates file).
// 2. Segment health — per-segment open / CTOR / click / rev-per-recipient
// bands from segment-perf, so weak cohorts surface
// as winback / re-engagement plays.
// 3. Performance gaps — list growth + recent open-rate trend (modules/
// performance helpers reused indirectly).
//
// Then it produces 4–6 suggested campaigns/actions for the next 4 weeks, each
// with title, audience, channel, date, rationale, source signal. Gemini is used
// when GEMINI_API_KEY is set; otherwise a deterministic template generator runs
// against the same signal set. Output is persisted to data/playbook.json and is
// editable from the panel. SUGGESTIONS ONLY — nothing here sends or schedules a
// live campaign. The calendar module is the canonical home for scheduled work;
// the panel offers a one-click "Add to calendar" handoff that POSTs to its
// /api/calendar/schedule endpoint.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const brand = require('../../lib/brand.js');
const segmentPerf = require('../segment-perf');
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
const STORE_FILE = path.join(DATA_DIR, 'playbook.json');
const CAL_DATES_FILE = path.join(DATA_DIR, 'calendar-dates.json');
// ── tiny JSON persistence helpers ───────────────────────────────────────────
function readJson(file, fallback) {
try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
catch { return fallback; }
}
function writeJson(file, val) {
try { fs.mkdirSync(path.dirname(file), { recursive: true }); } catch {}
fs.writeFileSync(file, JSON.stringify(val, null, 2));
}
function readStore() {
const v = readJson(STORE_FILE, null);
if (v && typeof v === 'object' && v.playbooks) return v;
const def = { playbooks: {}, updatedAt: new Date().toISOString() };
writeJson(STORE_FILE, def);
return def;
}
function writeStore(s) { s.updatedAt = new Date().toISOString(); writeJson(STORE_FILE, s); }
// ── date helpers (lifted verbatim from the calendar module so we don't have
// to cross-require its private helpers; the curated dates file is the source) ─
const pad = n => String(n).padStart(2, '0');
const iso = (y, m, d) => `${y}-${pad(m)}-${pad(d)}`;
function nthWeekday(year, month, weekday, ordinal) {
if (ordinal === -1) {
const last = new Date(year, month, 0).getDate();
for (let d = last; d >= 1; d--) {
if (new Date(year, month - 1, d).getDay() === weekday) return d;
}
return last;
}
let count = 0;
const daysInMonth = new Date(year, month, 0).getDate();
for (let d = 1; d <= daysInMonth; d++) {
if (new Date(year, month - 1, d).getDay() === weekday) {
if (++count === ordinal) return d;
}
}
return null;
}
function datesForMonth(year, month) {
const ds = readJson(CAL_DATES_FILE, { fixed: [], floating: [] });
const out = [];
for (const f of ds.fixed || []) {
if (f.month !== month) continue;
const daysInMonth = new Date(year, month, 0).getDate();
const day = Math.min(f.day, daysInMonth);
out.push({ date: iso(year, month, day), title: f.title, type: f.type, suggestion: f.suggestion });
}
for (const f of ds.floating || []) {
if (f.month !== month) continue;
const base = nthWeekday(year, month, f.weekday, f.ordinal);
if (base == null) continue;
let dt = new Date(year, month - 1, base);
if (f.offset) dt = new Date(dt.getTime() + f.offset * 86400000);
out.push({
date: iso(dt.getFullYear(), dt.getMonth() + 1, dt.getDate()),
title: f.title, type: f.type, suggestion: f.suggestion,
});
}
out.sort((a, b) => a.date.localeCompare(b.date));
return out;
}
// Curated dates that fall inside the 4-week window starting from `startIso`.
function datesInWindow(startIso, days = 28) {
const start = new Date(startIso + 'T00:00:00');
const end = new Date(start.getTime() + (days - 1) * 86400000);
const months = new Set();
for (let d = new Date(start); d <= end; d = new Date(d.getTime() + 86400000)) {
months.add(`${d.getFullYear()}-${pad(d.getMonth() + 1)}`);
}
const all = [];
for (const ym of months) {
const [y, m] = ym.split('-').map(Number);
for (const e of datesForMonth(y, m)) all.push(e);
}
const startKey = iso(start.getFullYear(), start.getMonth() + 1, start.getDate());
const endKey = iso(end.getFullYear(), end.getMonth() + 1, end.getDate());
return all
.filter(d => d.date >= startKey && d.date <= endKey)
.sort((a, b) => a.date.localeCompare(b.date));
}
// ── segment health snapshot ─────────────────────────────────────────────────
// Reuse the segment-perf helpers to read each segment's 30d open-rate trend
// and produce a health verdict: which cohorts are healthy vs. need rescuing.
function segmentSignals() {
const segs = segmentPerf._segments || [];
const health = segmentPerf._health || {};
const out = [];
for (const s of segs) {
const openTrend = segmentPerf._trendFor(s, 'openRate', 90);
const last30 = openTrend.series.slice(-30).map(p => p.value);
const meanOpen = last30.reduce((a, b) => a + b, 0) / last30.length;
let band = 'unknown';
if (health.openRate) {
band = meanOpen < health.openRate.bad ? 'bad'
: meanOpen < health.openRate.good ? 'ok' : 'good';
}
out.push({
id: s.id, name: s.name, icon: s.icon,
listSize: s.listSize, sendsPerCampaign: s.sendsPerCampaign,
openRate: +meanOpen.toFixed(4),
pctChange30d: openTrend.pctChange,
band,
});
}
return out;
}
// Performance gap heuristic — derive from segment signals so we don't duplicate
// the performance module's mock. Surfaces the segment that's leaking the most
// engagement (largest negative trend), which becomes a recommended winback.
function performanceGaps(segments) {
if (!segments.length) return [];
const declining = segments.filter(s => s.pctChange30d < -5);
const worstBand = segments
.filter(s => s.band === 'bad' || s.band === 'ok')
.sort((a, b) => a.openRate - b.openRate);
const gaps = [];
for (const s of declining) {
gaps.push({
segmentId: s.id, segment: s.name,
kind: 'declining',
note: `${s.name} 30-day open rate is down ${s.pctChange30d}% — schedule a winback or refresh creative.`,
});
}
for (const s of worstBand.slice(0, 1)) {
gaps.push({
segmentId: s.id, segment: s.name,
kind: 'underperforming',
note: `${s.name} sits in the ${s.band.toUpperCase()} band on open rate (${(s.openRate * 100).toFixed(1)}%) — try a high-relevance moment or list hygiene pass.`,
});
}
return gaps;
}
// ── month + week helpers ────────────────────────────────────────────────────
function monthIdToWindow(monthId) {
// monthId: 'YYYY-MM' — we treat the window as the FIRST 28 days of that month
// so "4 weeks" is exact. If the caller wants a rolling window, they can pass
// ?start=YYYY-MM-DD on the route instead.
const [y, m] = monthId.split('-').map(Number);
return iso(y, m, 1);
}
function thisMonthId(now = new Date()) {
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}`;
}
function weekOf(startIso, dateIso) {
const a = new Date(startIso + 'T00:00:00').getTime();
const b = new Date(dateIso + 'T00:00:00').getTime();
const dDay = Math.floor((b - a) / 86400000);
return Math.max(1, Math.min(4, Math.floor(dDay / 7) + 1));
}
// ── signal aggregation ──────────────────────────────────────────────────────
function gatherSignals(startIso, days = 28) {
const dates = datesInWindow(startIso, days);
const segments = segmentSignals();
const gaps = performanceGaps(segments);
return { startIso, days, dates, segments, gaps };
}
// ── template generator (fallback when Gemini isn't available) ───────────────
const AUDIENCES = ['designers', 'commercial', 'retail', 'at-risk'];
const AUDIENCE_LABEL = {
designers: 'Interior Designers',
commercial: 'Commercial / Hospitality',
retail: 'Retail',
'at-risk': 'At-Risk Winback',
};
function pickAudienceForDate(d) {
if (d.type === 'trade') return 'designers';
if (d.type === 'retail') return 'retail';
if (d.type === 'holiday') return 'retail';
return 'designers';
}
function templateEntryFromCalendar(d, startIso) {
const aud = pickAudienceForDate(d);
return {
week: weekOf(startIso, d.date),
date: d.date,
title: d.title,
audience: aud,
audienceLabel: AUDIENCE_LABEL[aud] || aud,
channel: d.type === 'holiday' || d.type === 'retail' ? 'email + instagram' : 'email',
type: d.type,
rationale: d.suggestion || `Curated marketing moment (${d.type}). Lean into the brand voice — ${brand.voice.slice(0, 3).join(', ')}.`,
source: 'calendar',
};
}
function templateEntryFromGap(g, startIso) {
// Place gap-driven plays evenly across the 4 weeks based on hash so it's
// deterministic between regenerations of the same signal set.
const seed = (g.segmentId + g.kind).split('').reduce((a, c) => a + c.charCodeAt(0), 0);
const dayOffset = (seed % 4) * 7 + 2; // mid-week
const dt = new Date(startIso + 'T00:00:00');
dt.setDate(dt.getDate() + dayOffset);
const dateIso = iso(dt.getFullYear(), dt.getMonth() + 1, dt.getDate());
return {
week: weekOf(startIso, dateIso),
date: dateIso,
title: g.kind === 'declining'
? `${g.segment} — Winback editorial`
: `${g.segment} — High-relevance refresh`,
audience: g.segmentId,
audienceLabel: g.segment,
channel: 'email',
type: g.kind,
rationale: g.note,
source: 'performance-gap',
};
}
function templateEntryFromSegmentHealth(s, startIso) {
// For segments that look healthy, lean in — schedule one positive-reinforcement
// campaign tied to a luxury product line so the cohort keeps engaging.
const line = brand.productLines[Math.abs(s.id.charCodeAt(0)) % brand.productLines.length];
const seed = s.id.charCodeAt(0) + s.id.length;
const dayOffset = (seed % 3) * 7 + 5;
const dt = new Date(startIso + 'T00:00:00');
dt.setDate(dt.getDate() + dayOffset);
const dateIso = iso(dt.getFullYear(), dt.getMonth() + 1, dt.getDate());
return {
week: weekOf(startIso, dateIso),
date: dateIso,
title: `${s.name} — ${line.charAt(0).toUpperCase() + line.slice(1)} editorial`,
audience: s.id,
audienceLabel: s.name,
channel: 'email',
type: 'segment-feature',
rationale: `${s.name} 30d open rate ${(s.openRate * 100).toFixed(1)}% (${s.band}). Feed momentum with a ${line}-led editorial — keep voice ${brand.voice.slice(0, 2).join(' / ')}.`,
source: 'segment-health',
};
}
function templateGenerate(signals) {
const { startIso, dates, segments, gaps } = signals;
const entries = [];
// Up to 2 calendar-driven picks (best of curated moments).
const tradeAndAware = dates.filter(d => ['trade', 'awareness', 'retail'].includes(d.type));
for (const d of tradeAndAware.slice(0, 2)) entries.push(templateEntryFromCalendar(d, startIso));
// 1 gap-driven pick if we have one.
for (const g of gaps.slice(0, 2)) entries.push(templateEntryFromGap(g, startIso));
// 1-2 healthy-segment leans.
const healthy = segments.filter(s => s.band === 'good').slice(0, 2);
for (const s of healthy) entries.push(templateEntryFromSegmentHealth(s, startIso));
// If we're short of 4 (sparse calendar month), fall back to a holiday/holiday-
// adjacent calendar pick or a generic editorial on the dominant line.
if (entries.length < 4) {
const remaining = dates.filter(d => !entries.find(e => e.date === d.date && e.title === d.title));
for (const d of remaining) {
if (entries.length >= 5) break;
entries.push(templateEntryFromCalendar(d, startIso));
}
}
if (entries.length < 4) {
// Synthesize a brand-tone editorial campaign per remaining week.
for (let w = 1; w <= 4 && entries.length < 4; w++) {
if (entries.find(e => e.week === w)) continue;
const dt = new Date(startIso + 'T00:00:00');
dt.setDate(dt.getDate() + (w - 1) * 7 + 3);
const dateIso = iso(dt.getFullYear(), dt.getMonth() + 1, dt.getDate());
const line = brand.productLines[(w - 1) % brand.productLines.length];
entries.push({
week: w,
date: dateIso,
title: `${line.charAt(0).toUpperCase() + line.slice(1)} editorial — Week ${w}`,
audience: 'designers',
audienceLabel: AUDIENCE_LABEL.designers,
channel: 'email + instagram',
type: 'editorial',
rationale: `Quiet calendar week — fall back on a tactile ${line} editorial. Voice: ${brand.voice.slice(0, 3).join(', ')}.`,
source: 'fallback-editorial',
});
}
}
// Trim to 6 and sort by date.
entries.sort((a, b) => (a.date || '').localeCompare(b.date || ''));
return entries.slice(0, 6);
}
// ── Gemini generator ────────────────────────────────────────────────────────
const GEMINI_MODEL = 'gemini-2.0-flash';
function geminiKey() {
return process.env.GEMINI_API_KEY || process.env.GEMINI_KEY || '';
}
function geminiPrompt(signals) {
return [
'You are the marketing planner for Designer Wallcoverings, a luxury wallcoverings house.',
`Brand voice: ${brand.voice.join(', ')}. Tagline: "${brand.tagline}".`,
`Audiences: ${brand.audiences.join('; ')}.`,
`Product lines: ${brand.productLines.join(', ')}.`,
'',
'Build a 4-WEEK marketing playbook starting ' + signals.startIso + '.',
'Output 4 to 6 distinct campaigns or actions. Each must reference at least one signal below.',
'Spread the dates across the 4 weeks. No two entries on the same day.',
'',
'SIGNAL — Curated marketing moments in the window:',
...signals.dates.slice(0, 12).map(d => ` • ${d.date} (${d.type}) ${d.title} — ${d.suggestion || ''}`),
'',
'SIGNAL — Segment health (30-day open rate, trend % vs prior 30d):',
...signals.segments.map(s => ` • ${s.name}: ${(s.openRate * 100).toFixed(1)}% open · trend ${s.pctChange30d}% · band ${s.band} · list ${s.listSize}`),
'',
'SIGNAL — Performance gaps:',
...signals.gaps.map(g => ` • ${g.kind.toUpperCase()} — ${g.note}`),
'',
'Return ONLY a JSON array. No prose, no markdown fence. Schema for each entry:',
'{',
' "date": "YYYY-MM-DD", // must fall inside the 28-day window',
' "week": 1|2|3|4,',
' "title": "...",',
' "audience": "designers"|"commercial"|"retail"|"at-risk",',
' "channel": "email"|"instagram"|"email + instagram"|"tiktok",',
' "type": "campaign"|"editorial"|"winback"|"trade"|"holiday"|"retail",',
' "rationale": "1-2 sentence justification referencing the signal",',
' "source": "calendar"|"segment-health"|"performance-gap"',
'}',
].join('\n');
}
async function geminiGenerate(signals) {
const key = geminiKey();
if (!key) throw new Error('no_gemini_key');
if (typeof fetch !== 'function') throw new Error('fetch_unavailable');
const url = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${encodeURIComponent(key)}`;
const body = {
contents: [{ role: 'user', parts: [{ text: geminiPrompt(signals) }] }],
generationConfig: { temperature: 0.55, maxOutputTokens: 1400, responseMimeType: 'application/json' },
};
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const text = await r.text();
let json; try { json = JSON.parse(text); } catch { json = null; }
if (!r.ok || !json) {
const err = new Error(`gemini_http_${r.status}`);
err.detail = json || text;
throw err;
}
const out = json.candidates && json.candidates[0] && json.candidates[0].content
&& json.candidates[0].content.parts && json.candidates[0].content.parts[0]
&& json.candidates[0].content.parts[0].text;
if (!out) throw new Error('gemini_empty');
// out should be a JSON array per the prompt; tolerate stray fence anyway.
let arr;
try { arr = JSON.parse(out); }
catch {
const m = out.match(/\[[\s\S]*\]/);
if (!m) throw new Error('gemini_unparseable');
arr = JSON.parse(m[0]);
}
if (!Array.isArray(arr)) throw new Error('gemini_not_array');
return arr;
}
// ── entry normalization (Gemini output + edits both pass through here) ─────
const VALID_AUDIENCES = new Set(AUDIENCES);
const VALID_CHANNELS = new Set(['email', 'instagram', 'tiktok', 'email + instagram', 'social', 'other']);
const VALID_STATUSES = new Set(['suggested', 'approved', 'scheduled', 'skipped', 'done']);
function normalizeEntry(raw, startIso) {
const e = raw || {};
const id = e.id && /^play_/.test(e.id) ? e.id : 'play_' + crypto.randomBytes(4).toString('hex');
const dateOk = typeof e.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(e.date);
const date = dateOk ? e.date : startIso;
const audience = VALID_AUDIENCES.has(e.audience) ? e.audience : 'designers';
return {
id,
week: Number.isInteger(e.week) && e.week >= 1 && e.week <= 4 ? e.week : weekOf(startIso, date),
date,
title: String(e.title || 'Untitled campaign').trim().slice(0, 140),
audience,
audienceLabel: AUDIENCE_LABEL[audience] || audience,
channel: VALID_CHANNELS.has(e.channel) ? e.channel : 'email',
type: String(e.type || 'campaign').trim().slice(0, 40),
rationale: String(e.rationale || '').trim().slice(0, 800),
source: ['calendar', 'segment-health', 'performance-gap', 'fallback-editorial', 'manual', 'gemini'].includes(e.source) ? e.source : 'gemini',
status: VALID_STATUSES.has(e.status) ? e.status : 'suggested',
notes: String(e.notes || '').trim().slice(0, 1000),
updatedAt: new Date().toISOString(),
};
}
// ── unified generate ────────────────────────────────────────────────────────
async function generatePlaybook(signals) {
let raw, mode = 'gemini', error = null;
if (geminiKey()) {
try { raw = await geminiGenerate(signals); }
catch (e) {
error = e.message;
raw = templateGenerate(signals);
mode = 'template-fallback';
}
} else {
raw = templateGenerate(signals);
mode = 'template';
}
const entries = (Array.isArray(raw) ? raw : [])
.slice(0, 6)
.map(r => normalizeEntry(r, signals.startIso));
// Re-sort + de-dup same-date+title in case Gemini repeated itself.
const seen = new Set();
const unique = [];
for (const e of entries.sort((a, b) => a.date.localeCompare(b.date))) {
const k = `${e.date}|${e.title.toLowerCase()}`;
if (seen.has(k)) continue;
seen.add(k); unique.push(e);
}
// If Gemini returned too few, top up with template entries.
if (unique.length < 4) {
const filler = templateGenerate(signals)
.map(r => normalizeEntry(r, signals.startIso))
.filter(t => !seen.has(`${t.date}|${t.title.toLowerCase()}`));
for (const t of filler) {
if (unique.length >= 6) break;
unique.push(t);
}
unique.sort((a, b) => a.date.localeCompare(b.date));
}
return { mode, error, entries: unique };
}
// ── module ──────────────────────────────────────────────────────────────────
module.exports = {
id: 'playbook',
title: 'Monthly Playbook',
icon: '📅',
// exposed for tests / sibling reuse
_gatherSignals: gatherSignals,
_templateGenerate: templateGenerate,
_normalizeEntry: normalizeEntry,
mount(router) {
const guard = handler => async (req, res) => {
try { await handler(req, res); }
catch (e) { res.status(500).json({ ok: false, error: e.message }); }
};
readStore(); // touch the file so a fresh deploy has shape
// GET /meta — module description + whether Gemini is keyed
router.get('/meta', guard(async (_req, res) => {
res.json({
staged: true,
mock: !geminiKey(),
geminiKeyed: !!geminiKey(),
geminiModel: GEMINI_MODEL,
audiences: AUDIENCES.map(id => ({ id, label: AUDIENCE_LABEL[id] })),
statuses: [...VALID_STATUSES],
channels: [...VALID_CHANNELS],
sources: ['calendar', 'segment-health', 'performance-gap', 'fallback-editorial', 'manual', 'gemini'],
gateMessage:
'Monthly Playbook is SUGGESTIONS ONLY. It generates 4–6 campaign/action ideas for the next 4 weeks ' +
'from calendar moments, segment health, and performance gaps. Entries are editable and persistent. ' +
'Nothing here schedules or sends anything — promoting a suggestion to a real plan still goes through ' +
'the Calendar module (and any actual send is gated separately in Constant Contact / Channels).',
brand: { voice: brand.voice, tagline: brand.tagline },
});
}));
// GET /signals?start=YYYY-MM-DD — what we'd feed the generator right now
router.get('/signals', guard(async (req, res) => {
const start = typeof req.query.start === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(req.query.start)
? req.query.start
: monthIdToWindow(typeof req.query.month === 'string' ? req.query.month : thisMonthId());
const days = Math.max(7, Math.min(56, Number(req.query.days) || 28));
res.json({ staged: true, signals: gatherSignals(start, days) });
}));
// GET /playbook?month=YYYY-MM — read the persisted playbook for a month
router.get('/playbook', guard(async (req, res) => {
const monthId = typeof req.query.month === 'string' && /^\d{4}-\d{2}$/.test(req.query.month)
? req.query.month
: thisMonthId();
const store = readStore();
const pb = store.playbooks[monthId] || null;
res.json({ staged: true, monthId, playbook: pb });
}));
// GET /full — full store (for export / debugging)
router.get('/full', guard(async (_req, res) => {
res.json({ staged: true, store: readStore() });
}));
// POST /generate { month?: 'YYYY-MM', start?: 'YYYY-MM-DD' } — regenerate
router.post('/generate', guard(async (req, res) => {
const body = req.body || {};
const monthId = typeof body.month === 'string' && /^\d{4}-\d{2}$/.test(body.month)
? body.month
: thisMonthId();
const start = typeof body.start === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(body.start)
? body.start
: monthIdToWindow(monthId);
const signals = gatherSignals(start, 28);
const { mode, error, entries } = await generatePlaybook(signals);
const pb = {
monthId,
startIso: start,
generatedAt: new Date().toISOString(),
mode, // 'gemini' | 'template' | 'template-fallback'
generatorError: error || null, // populated when Gemini failed → template kicked in
signals,
entries,
};
const store = readStore();
store.playbooks[monthId] = pb;
writeStore(store);
res.json({ staged: true, mock: !geminiKey() || mode !== 'gemini', monthId, playbook: pb });
}));
// POST /entry { month, ...entry } — add or upsert a single entry
router.post('/entry', guard(async (req, res) => {
const body = req.body || {};
const monthId = typeof body.month === 'string' && /^\d{4}-\d{2}$/.test(body.month)
? body.month
: thisMonthId();
const store = readStore();
const pb = store.playbooks[monthId] || {
monthId, startIso: monthIdToWindow(monthId), generatedAt: new Date().toISOString(),
mode: 'manual', generatorError: null,
signals: gatherSignals(monthIdToWindow(monthId), 28),
entries: [],
};
const entry = normalizeEntry({ ...body, source: body.source || 'manual' }, pb.startIso);
const i = pb.entries.findIndex(e => e.id === entry.id);
if (i >= 0) pb.entries[i] = { ...pb.entries[i], ...entry };
else pb.entries.push(entry);
pb.entries.sort((a, b) => a.date.localeCompare(b.date));
store.playbooks[monthId] = pb;
writeStore(store);
res.json({ staged: true, monthId, entry, playbook: pb });
}));
// PUT /entry/:id?month=YYYY-MM — edit one entry's fields
router.put('/entry/:id', guard(async (req, res) => {
const monthId = typeof req.query.month === 'string' && /^\d{4}-\d{2}$/.test(req.query.month)
? req.query.month
: thisMonthId();
const store = readStore();
const pb = store.playbooks[monthId];
if (!pb) return res.status(404).json({ ok: false, error: 'no playbook for that month — generate one first' });
const i = pb.entries.findIndex(e => e.id === req.params.id);
if (i < 0) return res.status(404).json({ ok: false, error: 'entry not found' });
const merged = normalizeEntry({ ...pb.entries[i], ...(req.body || {}), id: pb.entries[i].id }, pb.startIso);
pb.entries[i] = merged;
pb.entries.sort((a, b) => a.date.localeCompare(b.date));
writeStore(store);
res.json({ staged: true, monthId, entry: merged, playbook: pb });
}));
// POST /delete?month=YYYY-MM&id=... — remove an entry
router.post('/delete', guard(async (req, res) => {
const monthId = typeof req.query.month === 'string' && /^\d{4}-\d{2}$/.test(req.query.month)
? req.query.month
: thisMonthId();
const id = String(req.query.id || (req.body && req.body.id) || '');
const store = readStore();
const pb = store.playbooks[monthId];
if (!pb) return res.status(404).json({ ok: false, error: 'no playbook for that month' });
const before = pb.entries.length;
pb.entries = pb.entries.filter(e => e.id !== id);
if (pb.entries.length === before) return res.status(404).json({ ok: false, error: 'entry not found' });
writeStore(store);
res.json({ staged: true, monthId, deletedId: id, playbook: pb });
}));
},
};