// 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 // (until the .md is rewritten, which takes a fresh copy). // // 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) // ```mc:search-trends (first line: keywords of one google_trends call, optional second: location) // 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"; import { compare, listSuggestions } from "../../connector/src/competitors.js"; 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 >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 10000 ? `${(n / 1000).toFixed(n >= 100000 ? 0 : 1)}k` : Math.round(n).toLocaleString("en-CA")); const rate = (r) => (r.avgEngagement != null && r.followers ? (r.avgEngagement / r.followers) * 100 : null); // unrounded, for big accounts const rateText = (v) => (v == null ? "—" : `${v >= 1 ? v.toFixed(1) : Number(v.toPrecision(2))}%`); 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 competition = competitionData(slug, snap); 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, competition, trends: trendsData() }; } /** Google Trends answers the runs fetched in the last week (connector/src/trends.js cache), frozen with the report. */ function trendsData() { try { return Object.values(readJson(join(root, "data", "trends-cache.json"))).map((v) => v.data).filter((t) => t?.keywords?.length); } catch { return []; } } /** The client plus tracked and suggested competitors, with the numbers the rival blocks need. */ function competitionData(slug, snap) { try { const history = (readFileSync(join(DATA_DIR, "history", `${slug}.jsonl`), "utf8").trim().split("\n").filter(Boolean)).map((l) => JSON.parse(l)); const own = snap.ig && { username: snap.ig.profile?.username, followers: snap.ig.profile?.followers_count ?? null, posts: snap.ig.posts || [], history: history.map((h) => ({ date: h.date, followers: h.igFollowers })) }; const { you, competitors } = compare(slug, own); const keep = (a, role) => ({ role, username: a.username, name: a.name || null, kind: a.kind || "local", why: a.why || a.note || "", google: a.google || null, followers: a.followers, postsPerWeek: a.postsPerWeek, avgEngagement: a.avgEngagement, engagementRate: a.engagementRate, growth30: a.growth30 ?? null, formatMix: a.formatMix || {}, engagementByFormat: a.engagementByFormat || {}, topTags: a.topTags || [], allTags: a.allTags || [], topPosts: (a.topPosts || []).map((p) => ({ at: p.at, type: p.type, likes: p.likes, comments: p.comments, link: p.permalink, caption: plain(p.caption).slice(0, 140) })), }); if (you && !you.google) { try { you.google = JSON.parse(readFileSync(join(root, "clients", slug, "client.json"), "utf8")).google || null; } catch {} } const rows = [...(you ? [keep(you, "you")] : []), ...competitors.map((c) => keep(c, "tracked")), ...listSuggestions(slug).map((s) => keep(s, "suggested"))]; return rows.length > 1 ? rows : null; } catch { return null; } } export function reportData(mdPath) { const frozen = resolve(mdPath).replace(/\.md$/i, ".data.json"); // Frozen until the report itself is rewritten (a new run or an edit): then its text and numbers move together. if (existsSync(frozen) && statSync(frozen).mtimeMs >= statSync(mdPath).mtimeMs) 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("")}
    `); }, // ---------- competitors ---------- rivals(d) { const rows = ranked(d); if (!rows) return noData("Competitors"); const max = Math.max(...rows.map((r) => r.engagementRate || 0), 0.01); const youAt = rows.findIndex((r) => r.role === "you"); const body = `${rows.map((r, i) => ` `).join("")}
    #AccountFollowersPosts / weekTypical postEngagement rate
    ${i + 1}
    ${esc(r.name || `@${r.username}`)} ${tag(r)}
    @${esc(r.username)}
    ${num(r.followers)}${r.growth30 != null ? `
    ${r.growth30 >= 0 ? "▲" : "▼"} ${num(Math.abs(r.growth30))} / 30d
    ` : ""}
    ${r.postsPerWeek ?? "—"} ${num(r.avgEngagement)}
    likes + comments
    ${rateText(r.engagementRate)}
    `; const foot = youAt >= 0 ? `You rank ${youAt + 1} of ${rows.length} on engagement rate: likes and comments on a typical post, divided by followers. It's the fairest way to compare a small account with a big one.` : ""; return card("mc-wide", "Who's winning on Instagram", "ranked by engagement rate", body, foot); }, "rivals-map"(d) { const rows = (ranked(d) || []).filter((r) => r.followers > 0 && r.engagementRate != null); if (rows.length < 3) return noData("Competitor map"); const W = 640, H = 300, L = 44, R = 16, T = 16, B = 34; const lx = rows.map((r) => Math.log10(r.followers)), ys = rows.map((r) => r.engagementRate); const x0 = Math.floor(Math.min(...lx) * 2) / 2, x1 = Math.ceil(Math.max(...lx) * 2) / 2 || x0 + 1, y1 = Math.max(...ys) * 1.15 || 1; const X = (r) => L + ((Math.log10(r.followers) - x0) / (x1 - x0 || 1)) * (W - L - R), Y = (v) => T + (H - T - B) * (1 - v / y1); const med = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)]; const mx = L + ((med(lx) - x0) / (x1 - x0 || 1)) * (W - L - R), my = Y(med(ys)); const ticks = []; for (let t = Math.ceil(x0); t <= x1; t++) ticks.push(t); const axis = ticks.map((t) => { const x = L + ((t - x0) / (x1 - x0 || 1)) * (W - L - R); return `${num(10 ** t)}`; }).join(""); const quad = (x, y, anchor, text) => `${text}`; const placed = []; // label positions, nudged down when two would overlap const labelY = (x, y) => { while (placed.some((p) => Math.abs(p.x - x) < 90 && Math.abs(p.y - y) < 10)) y += 10; placed.push({ x, y }); return y; }; const dots = rows.map((r) => { const rad = 5 + Math.min(10, (r.postsPerWeek || 0) * 1.6), you = r.role === "you"; const fill = you ? "#fc6d45" : r.kind === "benchmark" ? C.sky : C.blue; const left = X(r) > W * 0.72; // label on the left near the right edge return ` ${you ? "You" : `@${esc(r.username.slice(0, 18))}`}`; }).join(""); const svg = ` ${axis} ${quad(L + 6, T + 10, "start", "Small but loved")}${quad(W - R - 4, T + 10, "end", "Big and engaged")}${quad(L + 6, H - B - 6, "start", "Small and quiet")}${quad(W - R - 4, H - B - 6, "end", "Big but quiet")} ${dots}Followers →Engagement rate →`; return card("mc-wide", "Size vs engagement", `bigger dot = posts more often · ● you ● local ● benchmark`, svg, "Accounts top-left get more attention per follower than their size suggests: their content is what to learn from."); }, "rivals-formats"(d) { const rows = ranked(d); if (!rows) return noData("Formats"); const kinds = ["Reel", "Carousel", "Image", "Video"], col = { Reel: C.blue, Carousel: C.sky, Image: "#b9c3d1", Video: "#7a86c2" }; // Each format against the account's own typical post, so a 2M-follower brand doesn't drown out a local shop. const market = {}; rows.filter((r) => r.role !== "you" && r.avgEngagement).forEach((r) => Object.entries(r.engagementByFormat).forEach(([k, v]) => (market[k] ||= []).push(v / r.avgEngagement))); const med = (xs) => (xs?.length ? [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)] : null); const you = rows.find((r) => r.role === "you"); const best = kinds.filter((k) => market[k]?.length).sort((a, b) => med(market[b]) - med(market[a]))[0]; const lines = rows.map((r) => { const total = Object.values(r.formatMix).reduce((a, b) => a + b, 0) || 1; return `
    ${r.role === "you" ? "You" : `@${esc(r.username.slice(0, 20))}`}
    ${kinds.filter((k) => r.formatMix[k]).map((k) => `
    `).join("")}
    `; }).join(""); const legend = kinds.map((k) => `${k}s${market[k]?.length ? ` · ${med(market[k]).toFixed(1)}× a typical post` : ""}`).join(""); const foot = best && you ? `For your competitors, ${best.toLowerCase()}s do ${med(market[best]).toFixed(1)}× better than their typical post. ${you.formatMix[best] ? `They're ${Math.round((you.formatMix[best] / (Object.values(you.formatMix).reduce((a, b) => a + b, 0) || 1)) * 100)}% of what you post.` : "You don't post any yet."}` : ""; return card("mc-wide", "What everyone posts, and what earns engagement", "last 25 posts per account", `
    ${lines}
    ${legend}
    `, foot); }, "rivals-tags"(d) { const rows = ranked(d); if (!rows) return noData("Hashtags"); const you = rows.find((r) => r.role === "you"), mine = new Set(you?.allTags || []); const count = {}; rows.filter((r) => r.role !== "you").slice(0, 8).forEach((r) => r.topTags.forEach((t) => (count[t] = (count[t] || 0) + 1))); const tags = Object.entries(count).filter(([t]) => !mine.has(t)).sort((a, b) => b[1] - a[1]).slice(0, 18); if (!tags.length) return noData("Hashtags"); return card("", "Hashtags on the best posts in your market that you don't use", "from rivals' highest-engagement posts", `
    ${tags.map(([t, n]) => `${esc(t)}${n > 1 ? `${n}` : ""}`).join("")}
    `, "A number means several competitors use it on their best posts. Use the ones that describe your area and service; skip anything that isn't you."); }, "rivals-posts"(d) { const rows = (ranked(d) || []).filter((r) => r.role !== "you" && r.followers); const posts = rows.flatMap((r) => r.topPosts.map((p) => ({ ...p, r, eng: (p.likes || 0) + (p.comments || 0), rate: (((p.likes || 0) + (p.comments || 0)) / r.followers) * 100 }))); if (!posts.length) return noData("Competitor posts"); const top = posts.sort((a, b) => b.rate - a.rate).slice(0, 6); return card("mc-wide", "Best posts in your market", "ranked by engagement for the account's size", `
    ${top.map((p) => `
    ${esc(p.caption.slice(0, 110) || "(no caption)")}${p.caption.length > 110 ? "…" : ""}
    ${num(p.eng)} likes + comments · ${p.rate.toFixed(1)}% of their followers
    `).join("")}
    `); }, "rivals-google"(d) { const rows = (d?.competition || []).filter((r) => r.google?.rating); if (!rows.length) return noData("Google ratings"); const sorted = [...rows].sort((a, b) => (b.google.reviews || 0) - (a.google.reviews || 0)), maxRev = Math.max(...rows.map((r) => r.google.reviews || 0), 1); const unverified = rows.some((r) => r.google.verified === false); return card("", "Google rating and reviews", unverified ? "where local customers often decide · * as shown on the business's own website" : "where local customers often decide · from Google Maps", `
    ${sorted.map((r) => `
    ${r.role === "you" ? "You" : esc(r.name || `@${r.username}`)} ★ ${r.google.rating.toFixed(1)}${r.google.verified === false ? "*" : ""}
    ${r.google.reviews == null ? "—" : `${num(r.google.reviews)} reviews`}
    `).join("")}
    `); }, }; /** Client + competitors sorted by engagement rate (highest first); null without competitors. */ function ranked(d) { const rows = d?.competition; if (!rows || rows.length < 2) return null; return rows.map((r) => ({ ...r, engagementRate: rate(r) ?? r.engagementRate })).sort((a, b) => (b.engagementRate ?? -1) - (a.engagementRate ?? -1)); } const tag = (r) => (r.role === "you" ? `You` : `${r.kind === "benchmark" ? "Benchmark" : "Local"}${r.role === "suggested" ? `New` : ""}`); // ---------- 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" }; /** * ```mc:search-trends first line: the keywords of one google_trends call (comma-separated); optional second line: location. * Draws interest over time per keyword plus the rising searches, from the stored answer (never typed numbers). */ const TREND_COLORS = [C.blue, "#fc6d45", C.good, C.warn, C.sky]; function trendsBlock(body, d) { const [kwLine = "", locLine = ""] = String(body).split("\n").map((l) => l.trim()).filter(Boolean); const want = kwLine.toLowerCase().split(",").map((k) => k.trim()).filter(Boolean); const loc = locLine.toLowerCase(); const sets = (d?.trends || []).filter((t) => !loc || String(t.location).toLowerCase() === loc); const t = sets.find((s) => want.every((k) => s.keywords.some((x) => x.keyword === k))) || (!want.length ? sets[0] : null); if (!t) return `

    Search trends${want.length ? ` for ${esc(want.join(", "))}` : ""}: no stored Google Trends answer.

    `; const kws = want.length ? t.keywords.filter((k) => want.includes(k.keyword)) : t.keywords; const n = Math.max(...kws.map((k) => k.series.length)); if (n < 2) return `

    Search trends: not enough data points.

    `; const W = 640, H = 190, L = 30, R = 10, T = 12, B = 30; const x = (i) => L + (i * (W - L - R)) / (n - 1), y = (v) => T + (H - T - B) * (1 - v / 100); const grid = [0, 50, 100].map((v) => `${v}`).join(""); const lines = kws.map((k, i) => ``).join(""); const dates = kws.find((k) => k.series.length === n).series.map(([from]) => from); const labels = [0, Math.floor((n - 1) / 2), n - 1].map((i) => `${new Date(dates[i]).toLocaleDateString("en-CA", { month: "short", year: "2-digit", timeZone: "UTC" })}`).join(""); const svg = `${grid}${lines}${labels}`; const month = (iso) => (iso ? new Date(iso).toLocaleDateString("en-CA", { month: "short", year: "numeric", timeZone: "UTC" }) : "—"); const rows = kws.map((k, i) => { const ch = k.change == null ? "—" : `${k.change > 0 ? "▲" : k.change < 0 ? "▼" : ""} ${Math.abs(k.change)}%`; const tone = k.change == null ? "" : k.change >= 10 ? ` style="color:${C.good}"` : k.change <= -10 ? ` style="color:${C.bad}"` : ""; const rising = (k.rising || []).slice(0, 5).map((r) => `${esc(r.query)}`).join(""); return `${esc(k.keyword)}${ch}${month(k.peak?.from)}
    ${rising || "—"}
    `; }).join(""); const table = `${rows}
    SearchTrendBusiestRising related searches
    `; return card("", "What people are searching for on Google", `${esc(String(t.location).replace(/,\s*/g, ", "))} · interest from 0 to 100, relative to the busiest point · trend = latest quarter of the period vs the one before`, svg + table); } 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); if (kind === "search-trends") return trendsBlock(code, data()); 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" })}` : ""; };