// 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 } from "./config.js"; import { agencyName, reportOptions } from "./workspace.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 ${agencyName()}. 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), reportOptions()); 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: agencyName(), 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 };