metatroncubeswdev aa56d2b531 Monthly client report from real numbers, emailed or automatic
- 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 <noreply@anthropic.com>
2026-10-23 10:11:04 -04:00

194 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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/<slug>/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 };