// 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 .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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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 ``; } 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 ``; } const card = (cls, title, sub, body, foot = "") => `
${title ? `
${esc(title)}${sub ? `${sub}` : ""}
` : ""}${body}${foot ? `

${foot}

` : ""}
`; const noData = (what) => `

${esc(what)}: no account data stored yet.

`; // ---------- 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 `
${tiles.map((k) => `
${esc(k.v)}${k.s ? spark(k.s) : ""}
${esc(k.l)}
${k.n ? `
${esc(k.n)}
` : ""}
`).join("")}
`; }, 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) => `${num(yMax * f)}`).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) ? `` : "")).join(""); const labels = [0, Math.floor((s.length - 1) / 2), s.length - 1].map((i) => `${day(dates[i])}`).join(""); const best = s.reduce((a, b) => (b.value > a.value ? b : a)); const svg = ` ${grid}${marks}${labels}`; 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] ? `${pct(v, steps[i - 1][1])}% kept${i === leak ? " · biggest drop" : ""}` : ""; return `
${conv}
${num(v)}
${esc(label)}
`; }).join(""); return card("", "From seeing a post to visiting the website", "last 28 days, Instagram", `
${rows}
`); }, 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) => `
${label}
${num(v)}
`; const daily = (d.ig.dailyFollowers || []).map((x) => x.value); return card("", "Followers gained vs lost", "last 28 days", `
${net > 0 ? "+" : ""}${num(net)}net change
${bar(gained, C.good, "New")}${bar(lost, C.bad, "Unfollowed")}${daily.length > 1 ? `
${spark(daily, C.good, 220, 30)}new followers per day
` : ""}
`, 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) => `${t.type}s
${t.share}% of posts
${num(t.avg)} reached per post
`).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`, `${rows}
`, 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) => `
${day(w)}${[...Array(7)].map((_, i) => { const dt = new Date(+w + i * DAY), k = dt.toISOString().slice(0, 10), n = byDay.get(k) || 0; return ``; }).join("")}
`).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", `
${"MTWTFSS".split("").map((c) => `${c}`).join("")}
${cells}
${(inRange / weeks.length).toFixed(1)}posts a week
${dv.longestGapDays != null ? `
${dv.longestGapDays}days, longest gap
` : ""}${dv.daysSinceLastPost != null ? `
${dv.daysSinceLastPost}days since last post
` : ""}
`); }, "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) => `
  • ${esc(p.caption.slice(0, 90) || "(no caption)")}${p.caption.length > 90 ? "…" : ""}
    ${num(p.reach)} reached · ${num(p.saves + p.shares)} saved or shared${p.reach ? ` (${((p.saves + p.shares) / p.reach * 100).toFixed(1)} per 100)` : ""}
  • `; return card("mc-wide", "Best and weakest posts", `last ${posts.length} posts, by people reached`, `

    Best ${n}

      ${sorted.slice(0, n).map(item).join("")}

    Weakest ${n}

      ${sorted.slice(-n).reverse().map(item).join("")}
    `); }, }; // ---------- 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 `
    ${ring(avg)}overall, out of 10
    ${rows.map((r) => `
    ${esc(r.area)}${r.s}/10
    ${r.why ? `

    ${esc(r.why)}

    ` : ""}
    `).join("")}
    `; } function actionsBlock(body) { const rows = rowsOf(body); if (!rows.length) return ""; return `
      ${rows.map(([what, owner = "", when = ""]) => { const who = /client|you/i.test(owner) ? "client" : "us"; return `
    1. ${esc(what).replace(/\*\*(.+?)\*\*/g, "$1")}
      ${owner ? `${esc(owner)}` : ""}${when ? `${esc(when)}` : ""}
    2. `; }).join("")}
    `; } 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 ? `

    Screenshot "${esc(id)}" not uploaded yet.

    ` : ""; const mime = { ".png": "image/png", ".webp": "image/webp" }[extname(file).toLowerCase()] || "image/jpeg"; const img = `${esc(id)} screenshot`; return `
    ${img}${notes.length ? `
      ${notes.map((n) => `
    1. ${esc(n.replace(/^\d+[.)]\s*|^[-*]\s+/, ""))}
    2. `).join("")}
    ` : ""}
    `; } // ---------- 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 ? `

    Unknown block mc:${esc(kind)}

    ` : ""; }, blockquote(quote) { const m = quote.match(/^\s*

    \[!(WIN|FIX|RISK|NOTE)\]\s*([^<]*)(?:
    )?/); if (!m) return false; const [tone, fallback] = ALERTS[m[1]]; const rest = quote.slice(m[0].length).replace(/^\s*<\/p>/, ""); return `

    `; }, }, }); 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" })}` : ""; };