metatroncubeswdev df3dbb63c4 Visual reports and client Screenshots tab
- Reports: mc: blocks (score, KPIs, reach, funnel, followers, formats,
  rhythm, top posts, actions, screenshots, callouts) drawn as SVG from
  stored account data, frozen per report; same page in PDF and preview
- Audit/weekly-report runs refresh account numbers first
- Screenshots tab: monthly shot list, uploads, examples; audit reads them

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 00:48:32 -04:00

269 lines
20 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.

// Visual report blocks: the AI writes small fenced markers, this file draws them as inline SVG/HTML.
// Charts are drawn from the stored account numbers (never from figures typed by the AI) and frozen
// next to the report as <report>.data.json the first time it renders, so a re-export shows the same numbers.
//
// Data blocks: ```mc:kpis ```mc:reach ```mc:funnel ```mc:followers ```mc:formats ```mc:rhythm ```mc:top-posts
// Written blocks: ```mc:score (Area | 6/10 | why) ```mc:actions (Action | Owner | When)
// ```mc:screenshot (first line: shot id, then one note per line)
// Callouts: > [!WIN] title / [!FIX] / [!RISK] / [!NOTE]
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "node:fs";
import { dirname, extname, join, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { Marked } from "marked";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const DATA_DIR = process.env.QUORLA_DASHBOARD_DATA || join(root, "data", "dashboard");
const DAY = 86400000;
const C = { blue: "#3a7bbf", sky: "#52ace0", ink: "#232327", gray: "#8a8f99", line: "#e3e7ee", good: "#2e9e6b", warn: "#d99a1e", bad: "#d0543f" };
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
const num = (n) => (n == null ? "—" : n >= 10000 ? `${(n / 1000).toFixed(n >= 100000 ? 0 : 1)}k` : Math.round(n).toLocaleString("en-CA"));
const pct = (a, b) => (b ? Math.round((a / b) * 100) : null);
const day = (d) => new Date(d).toLocaleDateString("en-CA", { month: "short", day: "numeric", timeZone: "UTC" });
const plain = (s) => String(s || "").normalize("NFKC").replace(/\s+/g, " ").trim(); // 𝐁𝐨𝐥𝐝 unicode → Bold
const readJson = (f) => { try { return JSON.parse(readFileSync(f, "utf8")); } catch { return null; } };
let uid = 0;
// ---------- data ----------
function slugFor(mdPath) {
const parts = resolve(mdPath).split(sep);
const i = parts.lastIndexOf("clients");
return i >= 0 ? parts[i + 1] : null;
}
function freshData(slug) {
const snap = readJson(join(DATA_DIR, "snapshots", `${slug}.json`));
if (!snap) return null;
const typeOf = (p) => (p.media_product_type === "REELS" || p.media_type === "VIDEO" ? "Reel" : p.media_type === "CAROUSEL_ALBUM" ? "Carousel" : "Image");
const ig = snap.ig && {
username: snap.ig.profile?.username,
followers: snap.ig.profile?.followers_count,
totals: snap.ig.totals || {},
dailyReach: snap.ig.dailyReach || [],
dailyFollowers: snap.ig.dailyFollowers || [],
derived: snap.ig.derived || {},
posts: (snap.ig.posts || []).map((p) => ({
at: p.timestamp, type: typeOf(p), caption: plain(p.caption).slice(0, 140), link: p.permalink,
reach: p.insights?.reach ?? null, views: p.insights?.views ?? null, likes: p.like_count ?? 0, comments: p.comments_count ?? 0,
saves: p.insights?.saved ?? 0, shares: p.insights?.shares ?? 0,
})),
};
const fb = snap.fb && { name: snap.fb.page?.name, followers: snap.fb.page?.followers_count, totals: snap.fb.totals || {}, posts: (snap.fb.posts || []).map((p) => ({ at: p.created_time })) };
return { slug, asOf: snap.fetchedAt, ig, fb };
}
export function reportData(mdPath) {
const frozen = resolve(mdPath).replace(/\.md$/i, ".data.json");
if (existsSync(frozen)) return readJson(frozen);
const slug = slugFor(mdPath);
const data = slug && freshData(slug);
if (data) try { writeFileSync(frozen, JSON.stringify(data)); } catch { /* read-only folder: render live */ }
return data;
}
// ---------- small SVG pieces ----------
function spark(values, color = C.blue, w = 96, h = 26) {
if (values.length < 2) return "";
const max = Math.max(...values, 1), step = w / (values.length - 1);
const pts = values.map((v, i) => `${(i * step).toFixed(1)},${(h - 2 - (v / max) * (h - 4)).toFixed(1)}`).join(" ");
return `<svg class="mc-spark" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" aria-hidden="true"><polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/></svg>`;
}
function ring(score, size = 92) {
const r = size / 2 - 7, c = 2 * Math.PI * r, f = Math.max(0, Math.min(1, score / 10));
const col = score < 4 ? C.bad : score < 7 ? C.warn : C.good;
return `<svg viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" aria-hidden="true">
<circle cx="${size / 2}" cy="${size / 2}" r="${r}" fill="none" stroke="${C.line}" stroke-width="8"/>
<circle cx="${size / 2}" cy="${size / 2}" r="${r}" fill="none" stroke="${col}" stroke-width="8" stroke-linecap="round" stroke-dasharray="${(c * f).toFixed(1)} ${c.toFixed(1)}" transform="rotate(-90 ${size / 2} ${size / 2})"/>
<text x="50%" y="52%" text-anchor="middle" dominant-baseline="middle" class="mc-ring-num">${score.toFixed(1)}</text></svg>`;
}
const card = (cls, title, sub, body, foot = "") =>
`<figure class="mc-card ${cls}">${title ? `<figcaption><span class="mc-card-title">${esc(title)}</span>${sub ? `<span class="mc-card-sub">${sub}</span>` : ""}</figcaption>` : ""}${body}${foot ? `<p class="mc-card-foot">${foot}</p>` : ""}</figure>`;
const noData = (what) => `<p class="mc-nodata">${esc(what)}: no account data stored yet.</p>`;
// ---------- data blocks ----------
const BLOCKS = {
kpis(d) {
const ig = d?.ig;
if (!ig) return noData("Key numbers");
const t = ig.totals, net = (t.new_follows ?? 0) - (t.unfollows ?? 0);
const reachRatio = pct(t.reach, ig.followers);
const tiles = [
{ v: num(ig.followers), l: "Instagram followers", n: t.new_follows != null ? `${net >= 0 ? "▲" : "▼"} ${num(Math.abs(net))} net this month` : "", tone: net < 0 ? "bad" : "good" },
{ v: num(t.reach), l: "People reached", n: reachRatio != null ? `${reachRatio}% of followers` : "", tone: reachRatio != null && reachRatio < 20 ? "bad" : "", s: ig.dailyReach.map((x) => x.value) },
{ v: num(t.total_interactions), l: "Likes, comments, saves, shares", n: t.reach ? `${pct(t.total_interactions, t.reach)} per 100 reached` : "" },
{ v: num(ig.derived.postsLast28), l: "Posts in 28 days", n: ig.derived.daysSinceLastPost != null ? `last post ${ig.derived.daysSinceLastPost} days ago` : "" },
{ v: num(t.profile_links_taps), l: "Taps on the website link", n: t.profile_links_taps ? "" : "nobody reached the website", tone: t.profile_links_taps ? "" : "bad" },
...(d.fb ? [{ v: num(d.fb.followers), l: "Facebook followers", n: d.fb.totals.page_total_media_view_unique != null ? `${num(d.fb.totals.page_total_media_view_unique)} people saw posts` : "" }] : []),
];
return `<section class="mc-kpigrid">${tiles.map((k) => `<div class="mc-kpi2 ${k.tone ? `is-${k.tone}` : ""}"><div class="mc-kpi2-top"><span class="mc-kpi2-v">${esc(k.v)}</span>${k.s ? spark(k.s) : ""}</div><div class="mc-kpi2-l">${esc(k.l)}</div>${k.n ? `<div class="mc-kpi2-n">${esc(k.n)}</div>` : ""}</div>`).join("")}</section>`;
},
reach(d) {
const s = d?.ig?.dailyReach || [];
if (s.length < 2) return noData("Daily reach");
const W = 640, H = 180, L = 34, R = 10, T = 12, B = 34, id = `g${++uid}`;
const max = Math.max(...s.map((x) => x.value), 1), yMax = Math.ceil(max / 5) * 5 || 5;
const x = (i) => L + (i * (W - L - R)) / (s.length - 1), y = (v) => T + (H - T - B) * (1 - v / yMax);
const line = s.map((p, i) => `${x(i).toFixed(1)},${y(p.value).toFixed(1)}`).join(" ");
const grid = [0, 0.5, 1].map((f) => `<line x1="${L}" x2="${W - R}" y1="${y(yMax * f)}" y2="${y(yMax * f)}" stroke="${C.line}"/><text x="${L - 6}" y="${y(yMax * f) + 3}" text-anchor="end" class="mc-ax">${num(yMax * f)}</text>`).join("");
const dates = s.map((p) => p.date);
const postDays = new Set((d.ig.posts || []).map((p) => p.at.slice(0, 10)));
const marks = dates.map((dt, i) => (postDays.has(dt) ? `<path d="M${x(i)} ${H - B + 8} l4 6 h-8 z" fill="${C.blue}"/>` : "")).join("");
const labels = [0, Math.floor((s.length - 1) / 2), s.length - 1].map((i) => `<text x="${x(i)}" y="${H - 4}" text-anchor="${i === 0 ? "start" : i === s.length - 1 ? "end" : "middle"}" class="mc-ax">${day(dates[i])}</text>`).join("");
const best = s.reduce((a, b) => (b.value > a.value ? b : a));
const svg = `<svg viewBox="0 0 ${W} ${H}" class="mc-chart" role="img" aria-label="Daily people reached over the last ${s.length} days">
<defs><linearGradient id="${id}" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="${C.sky}" stop-opacity=".45"/><stop offset="1" stop-color="${C.sky}" stop-opacity="0"/></linearGradient></defs>
${grid}<polygon points="${x(0)},${y(0)} ${line} ${x(s.length - 1)},${y(0)}" fill="url(#${id})"/><polyline points="${line}" fill="none" stroke="${C.blue}" stroke-width="2" stroke-linejoin="round"/>${marks}${labels}</svg>`;
return card("", "People reached each day", `▲ marks a day with a post · busiest day ${day(best.date)} (${num(best.value)})`, svg);
},
funnel(d) {
const t = d?.ig?.totals;
if (!t) return noData("Funnel");
const steps = [["Saw a post or reel", t.views], ["Different people reached", t.reach], ["Liked, commented, saved or shared", t.accounts_engaged], ["Tapped the website link", t.profile_links_taps]].filter((s) => s[1] != null);
const max = Math.max(...steps.map((s) => s[1]), 1);
let leak = -1, worst = 101;
steps.forEach((s, i) => { if (i && steps[i - 1][1]) { const r = pct(s[1], steps[i - 1][1]); if (r < worst) { worst = r; leak = i; } } });
const rows = steps.map(([label, v], i) => {
const w = Math.max(2, Math.sqrt(v / max) * 100);
const conv = i && steps[i - 1][1] ? `<span class="mc-fun-conv ${i === leak ? "is-bad" : ""}">${pct(v, steps[i - 1][1])}% kept${i === leak ? " · biggest drop" : ""}</span>` : "";
return `<div class="mc-fun-row">${conv}<div class="mc-fun-bar" style="width:${w.toFixed(1)}%;background:${i === leak ? C.bad : i % 2 ? C.sky : C.blue}"><span>${num(v)}</span></div><div class="mc-fun-l">${esc(label)}</div></div>`;
}).join("");
return card("", "From seeing a post to visiting the website", "last 28 days, Instagram", `<div class="mc-fun">${rows}</div>`);
},
followers(d) {
const t = d?.ig?.totals;
if (t?.new_follows == null) return noData("Followers gained and lost");
const gained = t.new_follows, lost = t.unfollows ?? 0, net = gained - lost, max = Math.max(gained, lost, 1);
const bar = (v, col, label) => `<div class="mc-flow-row"><span class="mc-flow-l">${label}</span><div class="mc-flow-track"><div style="width:${((v / max) * 100).toFixed(1)}%;background:${col}"></div></div><span class="mc-flow-v">${num(v)}</span></div>`;
const daily = (d.ig.dailyFollowers || []).map((x) => x.value);
return card("", "Followers gained vs lost", "last 28 days", `<div class="mc-flow"><div class="mc-flow-net ${net < 0 ? "is-bad" : "is-good"}"><b>${net > 0 ? "+" : ""}${num(net)}</b><span>net change</span></div><div class="mc-flow-bars">${bar(gained, C.good, "New")}${bar(lost, C.bad, "Unfollowed")}${daily.length > 1 ? `<div class="mc-flow-spark">${spark(daily, C.good, 220, 30)}<span>new followers per day</span></div>` : ""}</div></div>`,
net < 0 ? "More people left than joined: the content isn't matching the audience the account has." : "");
},
formats(d) {
const posts = (d?.ig?.posts || []).filter((p) => p.reach != null && Date.parse(d.asOf) - Date.parse(p.at) > 2 * DAY);
if (!posts.length) return noData("Formats");
const types = ["Reel", "Carousel", "Image"].map((type) => {
const ps = posts.filter((p) => p.type === type);
const avg = ps.length ? ps.reduce((a, p) => a + p.reach, 0) / ps.length : 0;
const act = ps.length ? ps.reduce((a, p) => a + p.saves + p.shares, 0) / ps.length : 0;
return { type, n: ps.length, share: pct(ps.length, posts.length), avg, act };
}).filter((t) => t.n);
const maxAvg = Math.max(...types.map((t) => t.avg), 1);
const rows = types.map((t) => `<tr><td>${t.type}s</td>
<td><div class="mc-pair"><div class="mc-pair-bar" style="width:${t.share}%;background:#c9d6e6"></div><span>${t.share}% of posts</span></div></td>
<td><div class="mc-pair"><div class="mc-pair-bar" style="width:${((t.avg / maxAvg) * 100).toFixed(1)}%;background:${C.sky}"></div><span>${num(t.avg)} reached per post</span></div></td></tr>`).join("");
const best = types.reduce((a, b) => (b.avg > a.avg ? b : a)), most = types.reduce((a, b) => (b.n > a.n ? b : a));
const foot = best.type !== most.type && most.avg ? `${best.type}s reach ${(best.avg / most.avg).toFixed(1)}× more people per post than ${most.type.toLowerCase()}s, but ${most.type.toLowerCase()}s are ${most.share}% of what gets posted.` : "";
return card("", "What gets posted vs what reaches people", `last ${posts.length} posts`, `<table class="mc-pairs"><tbody>${rows}</tbody></table>`, foot);
},
rhythm(d) {
const posts = d?.ig?.posts?.length ? d.ig.posts : d?.fb?.posts || [];
if (!posts.length) return noData("Posting rhythm");
const end = new Date(d.asOf); end.setUTCHours(0, 0, 0, 0);
const monday = new Date(+end - (((end.getUTCDay() + 6) % 7) + 7 * 7) * DAY); // Monday, 8 weeks ago
const byDay = new Map();
posts.forEach((p) => { const k = p.at.slice(0, 10); byDay.set(k, (byDay.get(k) || 0) + 1); });
const weeks = [];
for (let w = monday; w <= end; w = new Date(+w + 7 * DAY)) weeks.push(w);
const cells = weeks.map((w) => `<div class="mc-rh-week"><span class="mc-rh-wl">${day(w)}</span>${[...Array(7)].map((_, i) => {
const dt = new Date(+w + i * DAY), k = dt.toISOString().slice(0, 10), n = byDay.get(k) || 0;
return `<i class="${dt > end ? "is-future" : n ? "is-on" : ""}" title="${k}${n ? `: ${n} post${n > 1 ? "s" : ""}` : ""}"></i>`;
}).join("")}</div>`).join("");
const inRange = posts.filter((p) => Date.parse(p.at) >= +monday).length;
const dv = d.ig?.derived || {};
return card("", "Posting rhythm, last 8 weeks", "one square per day · Mon → Sun",
`<div class="mc-rh"><div class="mc-rh-grid"><div class="mc-rh-week mc-rh-head"><span class="mc-rh-wl"></span>${"MTWTFSS".split("").map((c) => `<b>${c}</b>`).join("")}</div>${cells}</div>
<div class="mc-rh-stats"><div><b>${(inRange / weeks.length).toFixed(1)}</b><span>posts a week</span></div>${dv.longestGapDays != null ? `<div><b>${dv.longestGapDays}</b><span>days, longest gap</span></div>` : ""}${dv.daysSinceLastPost != null ? `<div><b>${dv.daysSinceLastPost}</b><span>days since last post</span></div>` : ""}</div></div>`);
},
"top-posts"(d) {
const posts = (d?.ig?.posts || []).filter((p) => p.reach != null && Date.parse(d.asOf) - Date.parse(p.at) > 2 * DAY);
if (posts.length < 2) return noData("Top posts");
const sorted = [...posts].sort((a, b) => b.reach - a.reach), max = sorted[0].reach || 1, n = Math.min(3, Math.floor(posts.length / 2));
const item = (p) => `<li><div class="mc-post-meta"><span class="mc-chip">${p.type}</span>${day(p.at)}${p.link ? ` · <a href="${esc(p.link)}">open</a>` : ""}</div>
<div class="mc-post-cap">${esc(p.caption.slice(0, 90) || "(no caption)")}${p.caption.length > 90 ? "…" : ""}</div>
<div class="mc-post-bar"><div style="width:${((p.reach / max) * 100).toFixed(1)}%"></div></div>
<div class="mc-post-nums"><b>${num(p.reach)}</b> reached · ${num(p.saves + p.shares)} saved or shared${p.reach ? ` (${((p.saves + p.shares) / p.reach * 100).toFixed(1)} per 100)` : ""}</div></li>`;
return card("mc-wide", "Best and weakest posts", `last ${posts.length} posts, by people reached`, `<div class="mc-posts"><div><h4 class="is-good">Best ${n}</h4><ol>${sorted.slice(0, n).map(item).join("")}</ol></div><div><h4 class="is-bad">Weakest ${n}</h4><ol>${sorted.slice(-n).reverse().map(item).join("")}</ol></div></div>`);
},
};
// ---------- written blocks ----------
const rowsOf = (body) => body.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).map((l) => l.replace(/^[-*]\s+/, "").split("|").map((s) => s.trim()));
function scoreBlock(body) {
const rows = rowsOf(body).map(([area, score, why]) => ({ area, why, s: Math.max(0, Math.min(10, parseFloat(score) || 0)) }));
if (!rows.length) return "";
const avg = rows.reduce((a, r) => a + r.s, 0) / rows.length;
const tone = (s) => (s < 4 ? C.bad : s < 7 ? C.warn : C.good);
return `<section class="mc-score"><div class="mc-score-ring">${ring(avg)}<span>overall, out of 10</span></div><div class="mc-score-rows">${rows.map((r) => `
<div class="mc-score-row"><div class="mc-score-head"><b>${esc(r.area)}</b><span style="color:${tone(r.s)}">${r.s}/10</span></div>
<div class="mc-score-track"><div style="width:${r.s * 10}%;background:${tone(r.s)}"></div></div>${r.why ? `<p>${esc(r.why)}</p>` : ""}</div>`).join("")}</div></section>`;
}
function actionsBlock(body) {
const rows = rowsOf(body);
if (!rows.length) return "";
return `<ol class="mc-actions">${rows.map(([what, owner = "", when = ""]) => {
const who = /client|you/i.test(owner) ? "client" : "us";
return `<li><div class="mc-act-what">${esc(what).replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")}</div><div class="mc-act-meta">${owner ? `<span class="mc-owner is-${who}">${esc(owner)}</span>` : ""}${when ? `<span class="mc-when">${esc(when)}</span>` : ""}</div></li>`;
}).join("")}</ol>`;
}
const LEGACY_SHOTS = { "ig-profile": "instagram-profile", "fb-page": "facebook-page" };
function screenshotBlock(body, ctx) {
const [id = "", ...notes] = body.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
const dir = join(dirname(ctx.mdPath), "screenshots");
if (!existsSync(dir)) return "";
const months = readdirSync(dir).filter((m) => /^\d{4}-\d{2}$/.test(m)).sort().reverse();
const want = [id, LEGACY_SHOTS[id]].filter(Boolean);
const find = (folder) => existsSync(folder) && readdirSync(folder).find((f) => want.some((w) => f.startsWith(`${w}.`)) && /\.(png|jpe?g|webp)$/i.test(f));
let file = null;
for (const m of months) { const f = find(join(dir, m)); if (f) { file = join(dir, m, f); break; } }
if (!file) { const f = find(dir); file = f && join(dir, f); }
if (!file || statSync(file).size > 6 * 1024 * 1024) return ctx.internal ? `<p class="mc-nodata">Screenshot "${esc(id)}" not uploaded yet.</p>` : "";
const mime = { ".png": "image/png", ".webp": "image/webp" }[extname(file).toLowerCase()] || "image/jpeg";
const img = `<img src="data:${mime};base64,${readFileSync(file).toString("base64")}" alt="${esc(id)} screenshot">`;
return `<figure class="mc-shot ${notes.length ? "" : "is-solo"}">${img}${notes.length ? `<ol class="mc-shot-notes">${notes.map((n) => `<li>${esc(n.replace(/^\d+[.)]\s*|^[-*]\s+/, ""))}</li>`).join("")}</ol>` : ""}</figure>`;
}
// ---------- markdown ----------
const ALERTS = { WIN: ["good", "What's working"], FIX: ["warn", "To fix"], RISK: ["bad", "Risk"], NOTE: ["info", "Note"] };
export function renderMarkdown(md, ctx) {
const data = () => (ctx._data === undefined ? (ctx._data = reportData(ctx.mdPath)) : ctx._data);
const marked = new Marked({ gfm: true, breaks: true });
marked.use({
renderer: {
code(code, info) {
const kind = String(info || "").trim().match(/^mc:([\w-]+)/)?.[1];
if (!kind) return false;
if (BLOCKS[kind]) return BLOCKS[kind](data());
if (kind === "score") return scoreBlock(code);
if (kind === "actions") return actionsBlock(code);
if (kind === "screenshot") return screenshotBlock(code, ctx);
return ctx.internal ? `<p class="mc-nodata">Unknown block mc:${esc(kind)}</p>` : "";
},
blockquote(quote) {
const m = quote.match(/^\s*<p>\[!(WIN|FIX|RISK|NOTE)\]\s*([^<]*)(?:<br>)?/);
if (!m) return false;
const [tone, fallback] = ALERTS[m[1]];
const rest = quote.slice(m[0].length).replace(/^\s*<\/p>/, "");
return `<aside class="mc-alert is-${tone}"><div class="mc-alert-title">${m[2].trim() || fallback}</div>${rest.startsWith("<") ? rest : `<p>${rest}`}</aside>`;
},
},
});
return marked.parse(md);
}
export const asOfLabel = (mdPath) => {
const d = reportData(mdPath);
return d?.asOf ? `Account data as of ${new Date(d.asOf).toLocaleDateString("en-CA", { month: "long", day: "numeric", year: "numeric" })}` : "";
};