← back to Marketing Command Center

modules/calendar/store.js

107 lines

// Shared marketing-calendar store — curated retail/holiday/awareness dates +
// the lightweight campaign-schedule persistence. Extracted from calendar/index.js
// so the unified "All Calendars" module (calendars/) can read the SAME data
// without duplicating the date math or the JSON store. No sends here — planning only.
const fs = require('fs');
const path = require('path');

const DATA_DIR = path.join(__dirname, '..', '..', 'data');
const DATES_FILE = path.join(DATA_DIR, 'calendar-dates.json');
const SCHEDULE_FILE = path.join(DATA_DIR, 'calendar-schedule.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 loadSchedule() {
  const v = readJson(SCHEDULE_FILE, null);
  return Array.isArray(v) ? v : [];
}
function saveSchedule(list) { writeJson(SCHEDULE_FILE, list); }

// ── date math ─────────────────────────────────────────────────────────────────
const pad = n => String(n).padStart(2, '0');
const iso = (y, m, d) => `${y}-${pad(m)}-${pad(d)}`;

// nth occurrence (ordinal, 1-based; -1 = last) of weekday (0=Sun..6=Sat) in a month
function nthWeekday(year, month, weekday, ordinal) {
  if (ordinal === -1) {
    const last = new Date(year, month, 0).getDate(); // last day of month (month is 1-based here)
    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;
}

// Resolve every curated date for a given year+month → concrete entries.
function datesForMonth(year, month) {
  const ds = readJson(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;
}

// Campaign upsert used by both the campaign editor (calendar) and drag-move (calendars).
const STATUSES = ['planned', 'drafted', 'scheduled', 'sent'];
const CHANNELS = ['email', 'instagram', 'tiktok', 'other'];
function upsertCampaign(body) {
  const list = loadSchedule();
  const b = body || {};
  if (!b.date || !b.title) return { ok: false, error: 'date and title are required' };
  const entry = {
    id: b.id && list.some(c => c.id === b.id) ? String(b.id)
      : 'cmp_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
    date: String(b.date),
    title: String(b.title).slice(0, 160),
    channel: CHANNELS.includes(b.channel) ? b.channel : 'email',
    status: STATUSES.includes(b.status) ? b.status : 'planned',
    notes: String(b.notes || '').slice(0, 1000),
    updatedAt: new Date().toISOString(),
  };
  const i = list.findIndex(c => c.id === entry.id);
  if (i >= 0) list[i] = { ...list[i], ...entry };
  else list.push(entry);
  saveSchedule(list);
  return { ok: true, entry, schedule: list };
}

module.exports = {
  pad, iso, nthWeekday, datesForMonth,
  readJson, writeJson, loadSchedule, saveSchedule, upsertCampaign,
  STATUSES, CHANNELS,
};