From aa56d2b531b8e400eb65731ff5704a5a624645b3 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Fri, 23 Oct 2026 10:11:04 -0400 Subject: [PATCH] Monthly client report from real numbers, emailed or automatic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Posts and results for the month, follower change, best posts, formats that worked, every post, competitor comparison and next month's plan; branded PDF via tools/report-pdf (no AI key needed) - Client page → Documents: create for any month, email the PDF to the client's reviewer - Optional per client: create on the 1st of each month and email it; admins get a bell notification - Documents list groups monthly reports separately Co-Authored-By: Claude Opus 5.5 --- dashboard/server/clients.js | 2 +- dashboard/server/index.js | 8 + dashboard/server/monthly.js | 193 ++++++++++++++++++++++ dashboard/server/notify.js | 3 +- dashboard/web/src/pages/Client.jsx | 5 +- dashboard/web/src/pages/MonthlyReport.jsx | 44 +++++ 6 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 dashboard/server/monthly.js create mode 100644 dashboard/web/src/pages/MonthlyReport.jsx diff --git a/dashboard/server/clients.js b/dashboard/server/clients.js index b37919c..3911c68 100644 --- a/dashboard/server/clients.js +++ b/dashboard/server/clients.js @@ -65,7 +65,7 @@ export function listDocs(slug) { for (const [kind, sub] of Object.entries(DOC_DIRS)) { const d = path.join(dir, sub); if (!fs.existsSync(d)) continue; - for (const f of fs.readdirSync(d)) if (f.endsWith(".md")) add(path.join(sub, f), kind === "reports" ? "report" : "calendar"); + for (const f of fs.readdirSync(d)) if (f.endsWith(".md")) add(path.join(sub, f), kind === "reports" ? (/-monthly\.md$/.test(f) ? "monthly" : "report") : "calendar"); } for (const f of ["brand.md", "goals.md", "constraints.md", "history.md"]) if (fs.existsSync(path.join(dir, f))) add(f, "setup"); return docs.sort((a, b) => (a.kind === "setup") - (b.kind === "setup") || b.updatedAt.localeCompare(a.updatedAt)); diff --git a/dashboard/server/index.js b/dashboard/server/index.js index 1cd06ed..f4862c0 100644 --- a/dashboard/server/index.js +++ b/dashboard/server/index.js @@ -12,6 +12,7 @@ import * as published from "./published.js"; import * as portal from "./portal.js"; import * as notify from "./notify.js"; import * as notifyEvents from "./notify-events.js"; +import * as monthly from "./monthly.js"; import * as odoo from "./odoo.js"; import * as jobs from "./jobs.js"; import * as docs from "./docs.js"; @@ -322,6 +323,12 @@ app.get("/api/schedule/media", h(async (req, res) => { })); +// ---------- monthly client report ---------- +app.get("/api/clients/:slug/monthly", h(async (req) => ({ settings: monthly.settingsFor(req.params.slug), lastMonth: monthly.lastMonth(), email: notify.emailConfigured() }))); +app.put("/api/clients/:slug/monthly", requireAdmin, h(async (req) => ({ settings: monthly.setSettings(req.params.slug, req.body || {}) }))); +app.post("/api/clients/:slug/monthly/build", h(async (req) => monthly.buildWithPdf(req.params.slug, String(req.body?.month || monthly.lastMonth())))); +app.post("/api/clients/:slug/monthly/email", requireAdmin, h(async (req) => monthly.emailReport(req.params.slug, String(req.body?.month || monthly.lastMonth())))); + // ---------- notifications ---------- app.get("/api/notifications", h(async (req) => notify.listFor(req.user.id))); app.post("/api/notifications/read", h(async (req) => { notify.markRead(req.user.id, Array.isArray(req.body?.ids) ? req.body.ids : null); return notify.listFor(req.user.id); })); @@ -442,6 +449,7 @@ function scheduleNightly() { } if (process.env.DASHBOARD_NIGHTLY_REFRESH !== "false") scheduleNightly(); notifyEvents.start(); +if (process.env.DASHBOARD_NIGHTLY_REFRESH !== "false") monthly.startSchedule(); // Publish approved posts when they're due (each client's own time zone). Every 2 minutes. if (process.env.SCHEDULER_ENABLED !== "false") { diff --git a/dashboard/server/monthly.js b/dashboard/server/monthly.js new file mode 100644 index 0000000..aeaf38c --- /dev/null +++ b/dashboard/server/monthly.js @@ -0,0 +1,193 @@ +// Monthly client report, built from real numbers (no AI needed): posts and results for the month, follower change, +// best posts and formats, how the client compares with tracked competitors, and next month's plan. +// Written as clients//reports/YYYY-MM-monthly.md, exported to the branded PDF, optionally emailed to the client. +import fs from "node:fs"; +import path from "node:path"; +import { DATA_DIR, AGENCY_NAME } from "./config.js"; +import * as clients from "./clients.js"; +import * as metrics from "./metrics.js"; +import * as published from "./published.js"; +import * as portal from "./portal.js"; +import * as notify from "./notify.js"; +import * as plan from "../../connector/src/plan.js"; +import * as competitors from "../../connector/src/competitors.js"; +import { toPdf } from "../../tools/report-pdf/build.mjs"; + +const SETTINGS = path.join(DATA_DIR, "monthly.json"); +const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return d; } }; +const err = (msg, status = 400) => Object.assign(new Error(msg), { status }); +const nf = new Intl.NumberFormat("en-CA"); +const n = (v) => (v == null ? "—" : nf.format(v)); +const PL = { instagram: "Instagram", facebook: "Facebook", tiktok: "TikTok", youtube: "YouTube", gbp: "Google Business Profile", linkedin: "LinkedIn", pinterest: "Pinterest", threads: "Threads" }; +const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +export function settingsFor(slug) { return { auto: false, email: false, ...(readJson(SETTINGS, {})[slug] || {}) }; } +export function setSettings(slug, patch) { + clients.safeSlug(slug); + const all = readJson(SETTINGS, {}); + all[slug] = { ...settingsFor(slug), ...(patch.auto !== undefined ? { auto: Boolean(patch.auto) } : {}), ...(patch.email !== undefined ? { email: Boolean(patch.email) } : {}) }; + fs.writeFileSync(SETTINGS, JSON.stringify(all, null, 1)); + return all[slug]; +} + +const monthLabel = (m) => new Date(`${m}-15T12:00:00Z`).toLocaleDateString("en-CA", { month: "long", year: "numeric", timeZone: "UTC" }); +const lastMonth = () => { const d = new Date(); d.setUTCDate(1); d.setUTCMonth(d.getUTCMonth() - 1); return d.toISOString().slice(0, 7); }; +const nextMonthOf = (m) => { const d = new Date(`${m}-15T12:00:00Z`); d.setUTCMonth(d.getUTCMonth() + 1); return d.toISOString().slice(0, 7); }; +const endOf = (m) => { const d = new Date(`${m}-01T12:00:00Z`); d.setUTCMonth(d.getUTCMonth() + 1); d.setUTCDate(0); return d.toISOString().slice(0, 10); }; +const day = (iso) => new Date(`${String(iso).slice(0, 10)}T12:00:00Z`).toLocaleDateString("en-CA", { month: "short", day: "numeric", timeZone: "UTC" }); +const clean = (s, max = 110) => { const t = String(s || "").replace(/\s+/g, " ").replace(/[|]/g, "/").trim(); return t.length > max ? `${t.slice(0, max - 1)}…` : t; }; + +/** Build the report for one client and month (YYYY-MM, default last month). Returns the .md path relative to the client folder. */ +export function build(slug, month = lastMonth()) { + const dir = clients.safeSlug(slug); + if (!/^\d{4}-\d{2}$/.test(month)) throw err("Month must be YYYY-MM."); + const c = clients.getClient(slug); + const first = `${month}-01`, last = endOf(month); + const daysBack = Math.ceil((Date.now() - new Date(`${first}T00:00:00Z`)) / 86400000) + 1; + const items = published.feed({ client: slug, days: daysBack }).items.filter((i) => i.at && i.at.slice(0, 10) >= first && i.at.slice(0, 10) <= last); + const hist = metrics.readHistory(slug); + const inMonth = hist.filter((h) => h.date >= first && h.date <= last); + const before = [...hist].reverse().find((h) => h.date < first) || inMonth[0]; + const end = inMonth.at(-1); + const ig0 = before?.igFollowers, ig1 = end?.igFollowers, fb0 = before?.fbFollowers, fb1 = end?.fbFollowers; + const snap = metrics.readSnapshot(slug); + + const reach = items.filter((i) => i.metrics.reach != null).reduce((s, i) => s + i.metrics.reach, 0); + const inter = items.reduce((s, i) => s + (i.interactions || 0), 0); + const byPlatform = {}; + for (const i of items) byPlatform[i.platform] = (byPlatform[i.platform] || 0) + 1; + const top = [...items].filter((i) => i.interactions != null).sort((a, b) => b.interactions - a.interactions).slice(0, 3); + const byFormat = {}; + for (const i of items.filter((x) => x.platform === "instagram")) (byFormat[i.format] ||= []).push(i.interactions || 0); + const formatAvg = Object.entries(byFormat).map(([f, v]) => [f, Math.round(v.reduce((a, b) => a + b, 0) / v.length), v.length]).sort((a, b) => b[1] - a[1]); + const byDay = {}; + for (const i of items) (byDay[new Date(i.at).getDay()] ||= []).push(i.interactions || 0); + const bestDay = items.length < 6 ? null : Object.entries(byDay).filter(([, v]) => v.length >= 2).map(([d, v]) => [DAYS[d], v.reduce((a, b) => a + b, 0) / v.length]).sort((a, b) => b[1] - a[1])[0]; + + const own = snap?.ig ? { username: snap.ig.profile?.username, followers: snap.ig.profile?.followers_count, posts: snap.ig.posts || [], history: hist.map((h) => ({ date: h.date, followers: h.igFollowers })) } : null; + const comp = competitors.compare(slug, own); + const nm = nextMonthOf(month); + const upcoming = plan.calendar({ client: slug, from: `${nm}-01`, to: endOf(nm) }).items; + + const delta = (a, b) => (a == null || b == null ? null : b - a); + const igD = delta(ig0, ig1), fbD = delta(fb0, fb1); + const signed = (v) => (v == null ? "—" : `${v > 0 ? "+" : ""}${nf.format(v)}`); + const accounts = [snap?.ig?.profile?.username && `Instagram @${snap.ig.profile.username}`, snap?.fb?.page?.name && `Facebook ${snap.fb.page.name}`].filter(Boolean).join(" · "); + + const kpis = [ + `${n(items.length)} | Posts published | ${Object.entries(byPlatform).map(([p, k]) => `${k} ${PL[p] || p}`).join(", ") || "none this month"}`, + `${n(reach)} | Accounts reached on Instagram | summed across this month's posts`, + `${n(inter)} | Interactions | likes, comments, saves and shares`, + `${signed(igD)} | Instagram followers | ${ig1 != null ? `${n(ig1)} at month end` : "no data yet"}`, + ]; + const lines = []; + lines.push("---", `client: ${c?.name || slug}`, `report: Monthly social media report · ${monthLabel(month)}`, `period: ${day(first)} – ${day(last)}, ${month.slice(0, 4)}`); + if (accounts) lines.push(`accounts: ${accounts}`); + lines.push("kpis:", ...kpis.map((k) => ` - ${k}`), "---", ""); + + lines.push("## The month in short", ""); + const short = []; + short.push(items.length ? `We published ${items.length} post${items.length === 1 ? "" : "s"}${Object.keys(byPlatform).length > 1 ? ` across ${Object.keys(byPlatform).length} platforms` : ""}.` : "No posts went out this month."); + const fol = (k) => `${nf.format(k)} follower${k === 1 ? "" : "s"}`; + if (igD != null) short.push(igD > 0 ? `Instagram grew by ${fol(igD)}.` : igD < 0 ? `Instagram lost ${fol(-igD)}.` : "Instagram followers held steady."); + if (fbD != null && fbD !== 0) short.push(`Facebook ${fbD > 0 ? "gained" : "lost"} ${nf.format(Math.abs(fbD))} follower${Math.abs(fbD) === 1 ? "" : "s"}.`); + if (formatAvg.filter(([, , k]) => k >= 2).length > 1 && formatAvg[0][2] >= 2) short.push(`${formatAvg[0][0]}s did best on Instagram, with about ${nf.format(formatAvg[0][1])} interactions each.`); + if (bestDay) short.push(`${bestDay[0]} posts got the most engagement.`); + lines.push(short.join(" "), ""); + + if (top.length) { + lines.push("## Best posts", "", "| Date | Platform | Post | Reach | Interactions |", "|---|---|---|---|---|"); + for (const t of top) lines.push(`| ${day(t.at)} | ${PL[t.platform] || t.platform} | ${t.permalink ? `[${clean(t.caption, 70) || "View post"}](${t.permalink})` : clean(t.caption, 70)} | ${n(t.metrics.reach)} | ${n(t.interactions)} |`); + lines.push(""); + } + if (formatAvg.length) { + lines.push("## What worked on Instagram", "", "| Format | Posts | Average interactions |", "|---|---|---|"); + for (const [f, avg, k] of formatAvg) lines.push(`| ${f} | ${k} | ${n(avg)} |`); + lines.push(""); + } + if (items.length) { + lines.push("## Every post this month", "", "| Date | Platform | Format | Reach | Likes | Comments | Interactions |", "|---|---|---|---|---|---|---|"); + for (const i of [...items].sort((a, b) => a.at.localeCompare(b.at))) lines.push(`| ${day(i.at)} | ${PL[i.platform] || i.platform} | ${i.format} | ${n(i.metrics.reach)} | ${n(i.metrics.likes)} | ${n(i.metrics.comments)} | ${n(i.interactions)} |`); + lines.push(""); + } + if (comp.competitors.length) { + lines.push("## How you compare", "", "Engagement rate is a typical post's likes and comments divided by followers, so accounts of different sizes compare fairly.", "", + "| Account | Followers | Posts / week | Typical post | Engagement rate |", "|---|---|---|---|---|"); + for (const r of [...(comp.you ? [{ ...comp.you, username: comp.you.username || "you" }] : []), ...comp.competitors]) { + lines.push(`| ${r.own ? "**" : ""}@${r.username}${r.own ? " (you)**" : ""} | ${n(r.followers)} | ${r.postsPerWeek ?? "—"} | ${n(r.avgEngagement)} | ${r.engagementRate == null ? "—" : `${r.engagementRate}%`} |`); + } + lines.push(""); + } + lines.push(`## Coming up in ${monthLabel(nm)}`, ""); + if (upcoming.length) { + for (const u of upcoming.slice(0, 10)) lines.push(`- ${day(u.date)}: ${clean(u.title, 90)} (${u.stage === "published" ? "published" : u.stage === "scheduled" ? "scheduled" : "in planning"})`); + if (upcoming.length > 10) lines.push(`- …and ${upcoming.length - 10} more`); + } else lines.push("We'll share next month's plan with you for approval."); + lines.push("", `*Prepared by ${AGENCY_NAME}. Numbers come from Instagram and Facebook insights; reach isn't available for every platform.*`, ""); + + const reportsDir = path.join(dir, "reports"); + fs.mkdirSync(reportsDir, { recursive: true }); + const file = path.join(reportsDir, `${month}-monthly.md`); + fs.writeFileSync(file, lines.join("\n")); + return path.relative(dir, file).replace(/\\/g, "/"); +} + +/** Build and export the PDF. Returns { md, pdf } relative paths. */ +export function buildWithPdf(slug, month) { + const md = build(slug, month); + const dir = clients.safeSlug(slug); + const pdf = toPdf(path.join(dir, md)); + return { md, pdf: path.relative(dir, pdf).replace(/\\/g, "/") }; +} + +/** Email the month's PDF to the reviewer saved on the client's approval link. */ +export async function emailReport(slug, month = lastMonth()) { + const dir = clients.safeSlug(slug); + const link = portal.linkFor(slug); + if (!link.enabled || !link.contactEmail) throw err("Add the client's email first (client page → Client approval → Who reviews)."); + const pdf = path.join(dir, "reports", `${month}-monthly.pdf`); + if (!fs.existsSync(pdf)) throw err("Create the report first."); + const c = clients.getClient(slug); + const r = await notify.sendEmail({ + to: link.contactEmail, + subject: `${c?.name || "Your"} social media report · ${monthLabel(month)}`, + heading: `Your ${monthLabel(month)} social media report`, + lines: [`Hi${link.contactName ? ` ${link.contactName}` : ""}, attached is how ${c?.name || "your accounts"} did in ${monthLabel(month)}: what we posted, what worked, how you compare with competitors, and what's coming next month.`, "Reply to this email with any questions."], + fromName: AGENCY_NAME, + attachments: [{ filename: `${(c?.name || slug).replace(/[^\w-]+/g, "-")}-${month}-report.pdf`, path: pdf }], + }); + if (!r.sent) throw err(`Couldn't send: ${r.reason}`); + return { sent: true, to: link.contactEmail }; +} + +/** 1st of the month, 07:00: build last month's report for clients with auto on; email it where email is on. */ +export async function runMonthly() { + const month = lastMonth(); + const done = []; + for (const c of clients.listClients()) { + const s = settingsFor(c.slug); + if (!s.auto) continue; + try { + buildWithPdf(c.slug, month); + let sent = false; + if (s.email) { try { sent = (await emailReport(c.slug, month)).sent; } catch (e) { console.error("Monthly email", c.slug, e.message); } } + done.push(c.slug); + await notify.notify({ type: "monthly_report", level: "ok", title: `${c.name}: ${monthLabel(month)} report ready${sent ? " and emailed" : ""}`, link: `/clients/${c.slug}`, client: c.slug, to: "admins", email: false }); + } catch (e) { + await notify.notify({ type: "monthly_report", level: "bad", title: `${c.name}: monthly report failed`, body: e.message, link: `/clients/${c.slug}`, client: c.slug, to: "admins" }); + } + } + return { month, done }; +} + +export function startSchedule() { + const tick = () => { + const next = new Date(); next.setDate(1); next.setHours(7, 0, 0, 0); + if (next <= new Date()) { next.setMonth(next.getMonth() + 1); } + const wait = Math.min(next - new Date(), 2 ** 31 - 1); // setTimeout limit (~24 days); re-check then + setTimeout(async () => { if (new Date().getDate() === 1 && new Date().getHours() >= 7) { try { console.log("Monthly reports:", await runMonthly()); } catch (e) { console.error("Monthly reports failed", e); } } tick(); }, wait); + }; + tick(); +} + +export { lastMonth }; diff --git a/dashboard/server/notify.js b/dashboard/server/notify.js index 8e9bf44..9367423 100644 --- a/dashboard/server/notify.js +++ b/dashboard/server/notify.js @@ -39,7 +39,7 @@ ${button ? `

))} - {tab === "docs" && } + {tab === "docs" &&

} {tab === "runs" && } {tab === "portal" && } @@ -377,7 +378,7 @@ function FormatBars({ d }) { } // ---------------------------------------------------------------- documents -const KIND_LABEL = { audit: "Audits", report: "Weekly reports", calendar: "Content calendars", "profile-check": "Profile checks", setup: "Client setup files" }; +const KIND_LABEL = { monthly: "Monthly reports", audit: "Audits", report: "Weekly reports", calendar: "Content calendars", "profile-check": "Profile checks", setup: "Client setup files" }; function Documents({ slug, docs, reload }) { const [preview, setPreview] = useState(null); diff --git a/dashboard/web/src/pages/MonthlyReport.jsx b/dashboard/web/src/pages/MonthlyReport.jsx new file mode 100644 index 0000000..e23c3cf --- /dev/null +++ b/dashboard/web/src/pages/MonthlyReport.jsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from "react"; +import { FileBarChart, Mail } from "lucide-react"; +import { api } from "../api.js"; +import { useAuth } from "../App.jsx"; +import { Button, ErrorNote, Field, Panel } from "../ui.jsx"; + +/** Client page → Documents: build the monthly report from real numbers, email it, or have it done automatically. */ +export default function MonthlyReport({ slug, onBuilt }) { + const { isAdmin } = useAuth(); + const [info, setInfo] = useState(null); + const [month, setMonth] = useState(""); + const [busy, setBusy] = useState(null); + const [msg, setMsg] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { api(`/api/clients/${slug}/monthly`).then((r) => { setInfo(r); setMonth(r.lastMonth); }).catch(setError); }, [slug]); + if (!info) return null; + + const run = async (what, fn) => { setBusy(what); setError(null); setMsg(null); try { await fn(); } catch (e) { setError(e); } setBusy(null); }; + const build = () => run("build", async () => { const r = await api(`/api/clients/${slug}/monthly/build`, { method: "POST", body: { month } }); setMsg(`Created ${r.pdf}. It's in the list below.`); onBuilt?.(r); }); + const email = () => run("email", async () => { const r = await api(`/api/clients/${slug}/monthly/email`, { method: "POST", body: { month } }); setMsg(`Emailed to ${r.to}.`); }); + const toggle = (k) => run(k, async () => { const r = await api(`/api/clients/${slug}/monthly`, { method: "PUT", body: { [k]: !info.settings[k] } }); setInfo({ ...info, settings: r.settings }); }); + + return ( + +
+
+ setMonth(e.target.value)} /> + + {isAdmin && } +
+ {!info.email && isAdmin &&

Emailing needs SMTP set up on the server (see Settings → Notifications).

} + {isAdmin && ( +
+ + +
+ )} + {msg &&
{msg}
} + +
+
+ ); +}