← 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 });
    }));
  },
};