diff --git a/.claude/skills/weekly-plan/SKILL.md b/.claude/skills/weekly-plan/SKILL.md index 35a47ba..adfbebe 100644 --- a/.claude/skills/weekly-plan/SKILL.md +++ b/.claude/skills/weekly-plan/SKILL.md @@ -12,4 +12,9 @@ For each client requested: - At least one post that tests something new; say what it's testing. 5. For every post that needs creative work, write a brief using `playbooks/team-briefs.md`. 6. Save to `clients//calendar/YYYY-Www.md` (ISO week of the week being planned). -7. Give the user a short summary and ask for approval before anything goes to the team or the client. +7. Put the week on the dashboard's Content calendar: first call `content_plan` for that week so nothing is doubled + (approved library items and queued posts already show there), then `plan_add` one item per new post with date, + optional time, a short title, format, platforms, status `brief` (or `idea` if it still needs a concept), the brief + and the draft caption. Items with finished, approved library work should be scheduled with `schedule_from_library` + instead of added to the plan. +8. Give the user a short summary and ask for approval before anything goes to the team or the client. diff --git a/connector/src/plan.js b/connector/src/plan.js new file mode 100644 index 0000000..98b4eea --- /dev/null +++ b/connector/src/plan.js @@ -0,0 +1,122 @@ +// 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"]) 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 out = listItems({ client, from, to }).map((i) => ({ ...i, kind: "plan", stage: i.status, editable: true })); + + for (const l of lib.listItems({ client })) { + if (!l.forDate || l.forDate < from || l.forDate > to || ["scheduled", "archived"].includes(l.status)) continue; + out.push({ + id: l.id, kind: "library", client, title: l.title, date: l.forDate, time: "", format: l.format, platforms: [], + 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."); +} diff --git a/connector/src/server.js b/connector/src/server.js index 5358acd..9eb3885 100644 --- a/connector/src/server.js +++ b/connector/src/server.js @@ -10,6 +10,7 @@ import { PROJECT_ROOT } from "./env.js"; import * as meta from "./meta.js"; import * as sched from "./schedule.js"; import * as lib from "./library.js"; +import * as plan from "./plan.js"; import * as extra from "./platforms/index.js"; const server = new McpServer({ name: "social-manager", version: "0.1.0" }); @@ -184,4 +185,24 @@ server.tool("schedule_from_library", "Turn an APPROVED library item into a DRAFT caption: z.string().optional(), fb_caption: z.string().optional(), collaborators: z.array(z.string()).default([]), options }, wrap(async (a) => lib.scheduleItem(a.id, { platforms: a.platforms, localTime: a.local_time, caption: a.caption, fbCaption: a.fb_caption, collaborators: a.collaborators, options: a.options }, "agent"))); +server.tool("content_plan", "A client's content calendar between two dates: plan items (idea/brief/production/ready), library items with a target date, and queued/published posts. Check this before planning so nothing is doubled.", + { client: z.string(), from: z.string().describe("YYYY-MM-DD"), to: z.string().describe("YYYY-MM-DD") }, + wrap(async ({ client, from, to }) => plan.calendar({ client, from, to }))); + +server.tool("plan_add", "Add items to a client's content calendar (the weekly-plan skill uses this). Each item is a planned post, not a scheduled one: it starts as an idea or brief for the team to produce. Never publishes.", + { client: z.string(), items: z.array(z.object({ + date: z.string().describe("YYYY-MM-DD in the client's time zone"), time: z.string().optional().describe("HH:MM, optional"), + title: z.string().describe("Short working title shown on the calendar card"), + format: z.enum(["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"]).default("IMAGE"), + platforms: z.array(z.string()).default([]), status: z.enum(["idea", "brief", "production", "ready"]).default("brief"), + brief: z.string().default("").describe("Creative brief for the designer/editor"), caption: z.string().default("").describe("Draft caption"), + pillar: z.string().default(""), assignee: z.string().default(""), + })).min(1).max(30) }, + wrap(async ({ client, items }) => items.map((i) => plan.createItem({ client, ...i }, "agent")))); + +server.tool("plan_update", "Update a content-plan item (status, date, brief, caption, assignee...).", + { id: z.string(), patch: z.object({ date: z.string().optional(), time: z.string().optional(), title: z.string().optional(), status: z.enum(["idea", "brief", "production", "ready"]).optional(), + format: z.enum(["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"]).optional(), platforms: z.array(z.string()).optional(), brief: z.string().optional(), caption: z.string().optional(), assignee: z.string().optional() }) }, + wrap(async ({ id, patch }) => plan.updateItem(id, patch, "agent"))); + await server.connect(new StdioServerTransport()); diff --git a/connector/test/plan.test.mjs b/connector/test/plan.test.mjs new file mode 100644 index 0000000..5b7260e --- /dev/null +++ b/connector/test/plan.test.mjs @@ -0,0 +1,37 @@ +// Content plan: create, validate, show on the calendar, move between days, remove. Uses temp files only. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "quorla-plan-")); +process.env.QUORLA_PLAN_FILE = path.join(TMP, "plan.json"); +process.env.QUORLA_SCHEDULE_DIR = path.join(TMP, "schedule"); + +const plan = await import("../src/plan.js"); +const { PROJECT_ROOT } = await import("../src/env.js"); +const client = fs.readdirSync(path.join(PROJECT_ROOT, "clients")).find((d) => !d.startsWith("_") && fs.existsSync(path.join(PROJECT_ROOT, "clients", d, "client.json"))); + +test("plan items: create, validate, calendar, move, remove", { skip: !client && "no client folder" }, () => { + const a = plan.createItem({ client, title: "Behind the counter reel", date: "2031-03-04", format: "REELS", status: "brief", brief: "15s, morning prep" }, "t"); + assert.equal(a.status, "brief"); + assert.throws(() => plan.createItem({ client, title: "", date: "2031-03-04" }), /title/); + assert.throws(() => plan.createItem({ client, title: "x", date: "4 March" }), /YYYY-MM-DD/); + assert.throws(() => plan.updateItem(a.id, { status: "published" }), /Status must be/); + assert.throws(() => plan.createItem({ client: "no-such-client", title: "x", date: "2031-03-04" }), /Unknown client/); + + const cal = plan.calendar({ client, from: "2031-03-01", to: "2031-03-31" }); + const mine = cal.items.find((i) => i.id === a.id); + assert.equal(mine.kind, "plan"); + assert.equal(mine.stage, "brief"); + + plan.moveEntry("plan", a.id, "2031-03-09"); + assert.equal(plan.calendar({ client, from: "2031-03-09", to: "2031-03-09" }).items.find((i) => i.id === a.id).date, "2031-03-09"); + assert.equal(plan.calendar({ client, from: "2031-03-01", to: "2031-03-08" }).items.some((i) => i.id === a.id), false); + + plan.updateItem(a.id, { status: "ready", assignee: "Elena" }); + assert.equal(plan.listItems({ client }).find((i) => i.id === a.id).assignee, "Elena"); + plan.removeItem(a.id); + assert.equal(plan.listItems({ client }).some((i) => i.id === a.id), false); +}); diff --git a/dashboard/server/index.js b/dashboard/server/index.js index 083aef1..1492ac2 100644 --- a/dashboard/server/index.js +++ b/dashboard/server/index.js @@ -15,6 +15,7 @@ import * as docs from "./docs.js"; import * as meta from "../../connector/src/meta.js"; import * as sched from "../../connector/src/schedule.js"; import * as lib from "../../connector/src/library.js"; +import * as plan from "../../connector/src/plan.js"; import * as extra from "../../connector/src/platforms/index.js"; import { publicBaseUrl, resolveMediaToken } from "../../connector/src/platforms/media.js"; import { DATA_DIR as _DD } from "./config.js"; @@ -289,6 +290,13 @@ app.get("/api/schedule/media", h(async (req, res) => { })); +// ---------- content calendar: plan items + library items with a date + queued/published posts ---------- +app.get("/api/calendar", h(async (req) => plan.calendar({ client: String(req.query.client || ""), from: String(req.query.from || ""), to: String(req.query.to || "") }))); +app.post("/api/calendar/move", h(async (req) => ({ item: plan.moveEntry(String(req.body?.kind || ""), String(req.body?.id || ""), String(req.body?.date || ""), who(req)) }))); +app.post("/api/plan", h(async (req) => ({ item: plan.createItem(req.body || {}, who(req)) }))); +app.patch("/api/plan/:id", h(async (req) => ({ item: plan.updateItem(req.params.id, req.body || {}, who(req)) }))); +app.delete("/api/plan/:id", h(async (req) => { plan.removeItem(req.params.id); return { ok: true }; })); + // ---------- published posts and their numbers ---------- app.get("/api/published", h(async (req) => published.feed({ client: req.query.client ? (clients.safeSlug(req.query.client), String(req.query.client)) : undefined, platform: req.query.platform ? String(req.query.platform) : undefined, days: Number(req.query.days) || 30 }))); app.post("/api/published/refresh", h(async () => published.refreshStats())); diff --git a/dashboard/web/src/App.jsx b/dashboard/web/src/App.jsx index 05d6d0f..ee639c9 100644 --- a/dashboard/web/src/App.jsx +++ b/dashboard/web/src/App.jsx @@ -1,6 +1,6 @@ import { createContext, useCallback, useContext, useEffect, useState } from "react"; import { BrowserRouter, NavLink, Navigate, Route, Routes } from "react-router-dom"; -import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug, BarChart3 } from "lucide-react"; +import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug, BarChart3, CalendarDays } from "lucide-react"; import { api } from "./api.js"; import { cx, Spinner } from "./ui.jsx"; import Login from "./pages/Login.jsx"; @@ -11,6 +11,7 @@ import Runs from "./pages/Runs.jsx"; import Settings from "./pages/Settings.jsx"; import Publishing from "./pages/Publishing.jsx"; import Published from "./pages/Published.jsx"; +import Calendar from "./pages/Calendar.jsx"; import Inbox from "./pages/Inbox.jsx"; import Library from "./pages/Library.jsx"; import Connections from "./pages/Connections.jsx"; @@ -20,6 +21,7 @@ export const useAuth = () => useContext(AuthCtx); const NAV = [ { to: "/", label: "Clients", icon: LayoutGrid, end: true }, + { to: "/calendar", label: "Content calendar", icon: CalendarDays }, { to: "/library", label: "Content library", icon: FolderUp }, { to: "/publishing", label: "Publishing", icon: CalendarClock }, { to: "/published", label: "Published", icon: BarChart3 }, @@ -119,6 +121,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/dashboard/web/src/pages/Calendar.jsx b/dashboard/web/src/pages/Calendar.jsx new file mode 100644 index 0000000..692b47c --- /dev/null +++ b/dashboard/web/src/pages/Calendar.jsx @@ -0,0 +1,264 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Link, useSearchParams } from "react-router-dom"; +import { ChevronLeft, ChevronRight, Plus, Trash2 } from "lucide-react"; +import { api } from "../api.js"; +import { Badge, Button, cx, Dialog, ErrorNote, Field, Spinner } from "../ui.jsx"; + +// Stage → colour and label. Stages before "scheduled" are the team's plan; the rest come from the queue. +const STAGES = { + idea: { label: "Idea", color: "#9a9da6" }, + brief: { label: "Brief", color: "#6c63b5" }, + production: { label: "In production", color: "#a86a12" }, + ready: { label: "Ready", color: "#2f7d4f" }, + scheduled: { label: "Scheduled", color: "#3b3778" }, + published: { label: "Published", color: "#c2461a" }, + failed: { label: "Failed", color: "#b4372b" }, +}; +const PLAN_STAGES = ["idea", "brief", "production", "ready"]; +const FORMATS = { IMAGE: "Image", REELS: "Reel / video", CAROUSEL: "Carousel", STORY: "Story", TEXT: "Text" }; +const PLATFORMS = { instagram: "Instagram", facebook: "Facebook", tiktok: "TikTok", youtube: "YouTube", gbp: "Google Business", linkedin: "LinkedIn", pinterest: "Pinterest", threads: "Threads" }; +const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + +const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +const parse = (s) => { const [y, m, d] = s.split("-").map(Number); return new Date(y, m - 1, d); }; +const addDays = (d, n) => { const x = new Date(d); x.setDate(x.getDate() + n); return x; }; +const mondayOf = (d) => addDays(d, -((d.getDay() + 6) % 7)); +const todayIso = () => iso(new Date()); + +function range(view, anchor) { + if (view === "week") { const start = mondayOf(anchor); return { start, days: 7 }; } + const first = new Date(anchor.getFullYear(), anchor.getMonth(), 1); + const start = mondayOf(first); + const last = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0); + const days = Math.ceil((last - start) / 86400000 / 7 + 1 / 7) * 7; + return { start, days: Math.max(35, days) }; +} + +function Card({ item, onOpen, compact }) { + const st = STAGES[item.stage] || STAGES.idea; + return ( + + ); +} + +function ItemDialog({ item, client, onClose, onSaved }) { + const isNew = !item.id; + const isPlan = isNew || item.kind === "plan"; + const [f, setF] = useState({ + title: item.title || "", date: item.date, time: item.time || "", status: item.stage && PLAN_STAGES.includes(item.stage) ? item.stage : "idea", + format: item.format || "IMAGE", platforms: item.platforms || [], brief: item.brief || "", caption: item.caption || "", assignee: item.assignee || "", pillar: item.pillar || "", + }); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + const set = (k, v) => setF((s) => ({ ...s, [k]: v })); + const run = async (what, fn) => { setBusy(what); setError(null); try { await fn(); onSaved(); } catch (e) { setError(e); setBusy(null); } }; + + const save = () => run("save", () => (isPlan + ? (isNew ? api("/api/plan", { method: "POST", body: { client, ...f } }) : api(`/api/plan/${item.id}`, { method: "PATCH", body: f })) + : api("/api/calendar/move", { method: "POST", body: { kind: item.kind, id: item.id, date: f.date } }))); + const remove = () => run("remove", () => api(`/api/plan/${item.id}`, { method: "DELETE" })); + const st = STAGES[item.stage]; + + return ( + + {isPlan && !isNew && } + + {(isPlan || item.editable) && } + }> +
+ {!isPlan && ( +
+
{st?.label}{item.detail && {item.detail}}
+ {item.caption &&

{item.caption}

} +

+ {item.kind === "library" ? <>Edit the work itself in the Content library. + : <>Edit or approve it in Publishing.} + {!item.editable && " Approved and published posts can't be moved here."} +

+
+ )} + {isPlan && ( + + set("title", e.target.value)} placeholder="e.g. Saturday brunch reel" autoFocus={isNew} /> + + )} +
+ + set("date", e.target.value)} /> + + {isPlan && <> + set("time", e.target.value)} /> + + + + + + + } +
+ {isPlan && <> + +
+ {Object.entries(PLATFORMS).map(([id, name]) => ( + + ))} +
+
+ +