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