- Settings → Workspace: agency name, logo, brand colour, support email, website - Logo replaces Quorla's in the app (with 'Powered by Quorla'), sign-in page, client approval page and PDF reports; brand colour drives buttons, links and highlights; emails and reports use the agency name - Getting started checklist on the Clients page for admins, checked against the real state (workspace, Meta, first client, team, platforms, email, library, first post, approval link); dismissible Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
195 lines
13 KiB
JavaScript
195 lines
13 KiB
JavaScript
// 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 } 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 };
|