// Content plan: what each client will post and when, before it exists as finished work. // Flow per item: idea → brief → production → ready. Once scheduled it lives in the queue (schedule.js); // the calendar view merges plan items, library items with a date, and queued/published posts. // Shared by the dashboard (calendar UI) and the MCP connector (weekly-plan skill adds items). import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import { PROJECT_ROOT } from "./env.js"; import * as sched from "./schedule.js"; import * as lib from "./library.js"; const FILE = process.env.QUORLA_PLAN_FILE || path.join(PROJECT_ROOT, "data", "plan.json"); const CLIENTS = path.join(PROJECT_ROOT, "clients"); fs.mkdirSync(path.dirname(FILE), { recursive: true }); const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return []; } }; const write = (items) => { const tmp = FILE + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(items, null, 2)); fs.renameSync(tmp, FILE); }; const err = (msg, status = 400) => Object.assign(new Error(msg), { status }); const now = () => new Date().toISOString(); export const STAGES = ["idea", "brief", "production", "ready"]; export const FORMATS = ["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"]; const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; function checkClient(slug) { if (!/^[a-z0-9][a-z0-9-]*$/.test(String(slug || "")) || !fs.existsSync(path.join(CLIENTS, slug))) throw err("Unknown client.", 404); return slug; } function clean(patch) { const out = {}; if ("title" in patch) { out.title = String(patch.title || "").trim().slice(0, 140); if (!out.title) throw err("Give the item a title."); } if ("date" in patch) { if (!DATE_RE.test(String(patch.date))) throw err("Date must be YYYY-MM-DD."); out.date = patch.date; } if ("time" in patch) { const t = String(patch.time || ""); if (t && !/^\d{2}:\d{2}$/.test(t)) throw err("Time must be HH:MM."); out.time = t; } if ("status" in patch) { if (!STAGES.includes(patch.status)) throw err(`Status must be one of: ${STAGES.join(", ")}.`); out.status = patch.status; } if ("format" in patch) { if (!FORMATS.includes(patch.format)) throw err(`Format must be one of: ${FORMATS.join(", ")}.`); out.format = patch.format; } if ("platforms" in patch) out.platforms = (patch.platforms || []).map(String).slice(0, 10); for (const k of ["brief", "caption", "pillar", "assignee", "notes", "taskRef", "libraryItem"]) if (k in patch) out[k] = String(patch[k] ?? "").slice(0, 5000); return out; } export function listItems({ client, from, to } = {}) { return read().filter((i) => (!client || i.client === client) && (!from || i.date >= from) && (!to || i.date <= to)); } export function createItem(data, by = "team") { const client = checkClient(data.client); const fields = clean({ title: data.title, date: data.date, status: data.status || "idea", format: data.format || "IMAGE", platforms: data.platforms || [], time: data.time || "", brief: data.brief || "", caption: data.caption || "", pillar: data.pillar || "", assignee: data.assignee || "", notes: data.notes || "" }); const item = { id: crypto.randomUUID().slice(0, 8), client, ...fields, createdBy: by, createdAt: now(), updatedAt: now() }; const items = read(); items.push(item); write(items); return item; } export function updateItem(id, patch, by = "team") { const items = read(); const i = items.find((x) => x.id === id); if (!i) throw err("Plan item not found.", 404); Object.assign(i, clean(patch), { updatedBy: by, updatedAt: now() }); write(items); return i; } export function removeItem(id) { const items = read(); const n = items.length; const kept = items.filter((x) => x.id !== id); if (kept.length === n) throw err("Plan item not found.", 404); write(kept); } /** Local calendar date (YYYY-MM-DD) and time of an instant in a time zone. */ function localParts(iso, tz) { const p = new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }) .formatToParts(new Date(iso)).reduce((o, x) => ((o[x.type] = x.value), o), {}); return { date: `${p.year}-${p.month}-${p.day}`, time: `${p.hour === "24" ? "00" : p.hour}:${p.minute}` }; } /** * Everything on a client's calendar between two dates (inclusive, client's local dates): * plan items (kind "plan"), library items with a target date ("library"), and queued/published posts ("post"). * Each entry has a unified `stage`: idea | brief | production | ready | scheduled | published | failed. */ export function calendar({ client, from, to }) { checkClient(client); if (!DATE_RE.test(from) || !DATE_RE.test(to)) throw err("from and to must be YYYY-MM-DD."); const tz = sched.clientInfo(client).timezone; const libItems = lib.listItems({ client }); const activeLib = new Set(libItems.filter((l) => l.status !== "archived").map((l) => l.id)); // A plan item whose work was uploaded shows as the library card (with the plan's title) instead of twice. const plans = listItems({ client, from, to }); const planById = Object.fromEntries(listItems({ client }).map((i) => [i.id, i])); const out = plans.filter((i) => !(i.libraryItem && activeLib.has(i.libraryItem))).map((i) => ({ ...i, kind: "plan", stage: i.status, editable: true })); for (const l of libItems) { if (!l.forDate || l.forDate < from || l.forDate > to || ["scheduled", "archived"].includes(l.status)) continue; out.push({ id: l.id, kind: "library", client, title: planById[l.planItem]?.title || l.title, date: l.forDate, time: planById[l.planItem]?.time || "", format: l.format, platforms: planById[l.planItem]?.platforms || [], assignee: planById[l.planItem]?.assignee || l.uploadedBy, planItem: l.planItem || null, stage: l.status === "approved" ? "ready" : "production", detail: l.status === "changes" ? "Changes requested" : l.status === "submitted" ? "Waiting for review" : "Approved in the library", caption: l.captionIdea || "", files: l.files || [], editable: true, }); } for (const p of sched.listPosts({ client })) { if (p.status === "cancelled") continue; const at = localParts(p.publishAt, tz); if (at.date < from || at.date > to) continue; const stage = p.status === "published" || p.status === "partial" ? "published" : p.status === "failed" ? "failed" : "scheduled"; out.push({ id: p.id, kind: "post", client, title: (p.options?.youtube?.title || p.caption || "").split("\n")[0].slice(0, 90) || "Untitled post", date: at.date, time: at.time, format: p.type, platforms: p.platforms, stage, detail: p.status === "draft" ? "Draft, waiting for approval" : p.status === "approved" ? "Approved, will publish" : p.status === "partial" ? "Partly published" : null, caption: p.caption || "", files: p.media || [], editable: ["draft", "failed"].includes(p.status), postStatus: p.status, }); } const order = { published: 0, failed: 1, scheduled: 2, ready: 3, production: 4, brief: 5, idea: 6 }; return { timezone: tz, items: out.sort((a, b) => a.date.localeCompare(b.date) || (a.time || "99").localeCompare(b.time || "99") || order[a.stage] - order[b.stage]) }; } /** Move anything on the calendar to another day (keeps the time). Posts can only move while they're drafts. */ export function moveEntry(kind, id, date, by = "team") { if (!DATE_RE.test(String(date))) throw err("Date must be YYYY-MM-DD."); if (kind === "plan") return updateItem(id, { date }, by); if (kind === "library") return lib.updateItem(id, { forDate: date }, by); if (kind === "post") { const p = sched.getPost(id); if (!["draft", "failed"].includes(p.status)) throw err("Only drafts can be moved. Cancel the approval first."); const { time } = localParts(p.publishAt, sched.clientInfo(p.client).timezone); return sched.updatePost(id, { localTime: `${date} ${time}` }, by); } throw err("Unknown calendar item."); }