- competitor-scan skill + run type: web search for local rivals and niche benchmarks, competitor_suggest checks each on Instagram - Suggestions inbox on Competitors page (Track / Dismiss) - Report blocks: leaderboard, size-vs-engagement map, format lift, market's best posts, hashtag gap, Google ratings - Stats: engagement by format, top hashtags; audit adds leaderboard Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
398 lines
32 KiB
JavaScript
398 lines
32 KiB
JavaScript
// 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";
|
||
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 };
|
||
}
|
||
|
||
/** 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) })),
|
||
});
|
||
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");
|
||
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>`);
|
||
},
|
||
|
||
// ---------- 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 = `<table class="mc-lb"><thead><tr><th>#</th><th>Account</th><th>Followers</th><th>Posts / week</th><th>Typical post</th><th>Engagement rate</th></tr></thead><tbody>${rows.map((r, i) => `
|
||
<tr class="${r.role === "you" ? "is-you" : ""}"><td class="mc-lb-rank">${i + 1}</td>
|
||
<td><div class="mc-lb-name">${esc(r.name || `@${r.username}`)} ${tag(r)}</div><div class="mc-lb-handle">@${esc(r.username)}</div></td>
|
||
<td class="num">${num(r.followers)}${r.growth30 != null ? `<div class="mc-lb-sub ${r.growth30 < 0 ? "is-bad" : "is-good"}">${r.growth30 >= 0 ? "▲" : "▼"} ${num(Math.abs(r.growth30))} / 30d</div>` : ""}</td>
|
||
<td class="num">${r.postsPerWeek ?? "—"}</td>
|
||
<td class="num">${num(r.avgEngagement)}<div class="mc-lb-sub">likes + comments</div></td>
|
||
<td><div class="mc-lb-bar"><div style="width:${(((r.engagementRate || 0) / max) * 100).toFixed(1)}%"></div><span>${rateText(r.engagementRate)}</span></div></td></tr>`).join("")}</tbody></table>`;
|
||
const foot = youAt >= 0 ? `You rank <b>${youAt + 1} of ${rows.length}</b> 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 `<line x1="${x}" x2="${x}" y1="${T}" y2="${H - B}" stroke="${C.line}"/><text x="${x}" y="${H - B + 14}" text-anchor="middle" class="mc-ax">${num(10 ** t)}</text>`; }).join("");
|
||
const quad = (x, y, anchor, text) => `<text x="${x}" y="${y}" text-anchor="${anchor}" class="mc-quad">${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 `<g><circle cx="${X(r).toFixed(1)}" cy="${Y(r.engagementRate).toFixed(1)}" r="${rad.toFixed(1)}" fill="${fill}" fill-opacity="${you ? 0.95 : 0.55}" stroke="#fff" stroke-width="1.5"/>
|
||
<text x="${(X(r) + (left ? -(rad + 3) : rad + 3)).toFixed(1)}" y="${labelY(X(r), Y(r.engagementRate) + 3).toFixed(1)}" text-anchor="${left ? "end" : "start"}" class="mc-dot-l${you ? " is-you" : ""}">${you ? "You" : `@${esc(r.username.slice(0, 18))}`}</text></g>`;
|
||
}).join("");
|
||
const svg = `<svg viewBox="0 0 ${W} ${H}" class="mc-chart" role="img" aria-label="Followers against engagement rate for the client and competitors">
|
||
${axis}<line x1="${mx}" x2="${mx}" y1="${T}" y2="${H - B}" stroke="${C.gray}" stroke-dasharray="3 4"/><line x1="${L}" x2="${W - R}" y1="${my}" y2="${my}" stroke="${C.gray}" stroke-dasharray="3 4"/>
|
||
${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}<text x="${(L + W - R) / 2}" y="${H - 3}" text-anchor="middle" class="mc-ax">Followers →</text><text x="12" y="${(T + H - B) / 2}" transform="rotate(-90 12 ${(T + H - B) / 2})" text-anchor="middle" class="mc-ax">Engagement rate →</text></svg>`;
|
||
return card("mc-wide", "Size vs engagement", `bigger dot = posts more often · <span style="color:#fc6d45">●</span> you <span style="color:${C.blue}">●</span> local <span style="color:${C.sky}">●</span> 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 `<div class="mc-fm-row${r.role === "you" ? " is-you" : ""}"><span class="mc-fm-l">${r.role === "you" ? "You" : `@${esc(r.username.slice(0, 20))}`}</span><div class="mc-fm-bar">${kinds.filter((k) => r.formatMix[k]).map((k) => `<div style="width:${((r.formatMix[k] / total) * 100).toFixed(1)}%;background:${col[k]}" title="${k}"></div>`).join("")}</div></div>`;
|
||
}).join("");
|
||
const legend = kinds.map((k) => `<span><i style="background:${col[k]}"></i>${k}s${market[k]?.length ? ` · ${med(market[k]).toFixed(1)}× a typical post` : ""}</span>`).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", `<div class="mc-fm">${lines}</div><div class="mc-fm-legend">${legend}</div>`, 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",
|
||
`<div class="mc-tags">${tags.map(([t, n]) => `<span class="mc-tag${n > 1 ? " is-hot" : ""}">${esc(t)}${n > 1 ? `<b>${n}</b>` : ""}</span>`).join("")}</div>`,
|
||
"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",
|
||
`<div class="mc-ideas">${top.map((p) => `<div class="mc-idea"><div class="mc-post-meta"><span class="mc-chip">${p.type}</span>@${esc(p.r.username)} · ${day(p.at)}${p.link ? ` · <a href="${esc(p.link)}">open</a>` : ""}</div>
|
||
<div class="mc-post-cap">${esc(p.caption.slice(0, 110) || "(no caption)")}${p.caption.length > 110 ? "…" : ""}</div>
|
||
<div class="mc-post-nums"><b>${num(p.eng)}</b> likes + comments · ${p.rate.toFixed(1)}% of their followers</div></div>`).join("")}</div>`);
|
||
},
|
||
|
||
"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);
|
||
return card("", "Google rating and reviews", "where local customers often decide",
|
||
`<div class="mc-gg">${sorted.map((r) => `<div class="mc-gg-row${r.role === "you" ? " is-you" : ""}"><span class="mc-gg-l">${r.role === "you" ? "You" : esc(r.name || `@${r.username}`)}</span>
|
||
<span class="mc-gg-star">★ ${r.google.rating.toFixed(1)}</span><div class="mc-gg-bar"><div style="width:${(((r.google.reviews || 0) / maxRev) * 100).toFixed(1)}%"></div></div><span class="mc-gg-n">${num(r.google.reviews)} reviews</span></div>`).join("")}</div>`);
|
||
},
|
||
};
|
||
|
||
/** 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" ? `<span class="mc-chip is-you">You</span>` : `<span class="mc-chip${r.kind === "benchmark" ? " is-bench" : ""}">${r.kind === "benchmark" ? "Benchmark" : "Local"}</span>${r.role === "suggested" ? `<span class="mc-chip is-new">New</span>` : ""}`);
|
||
|
||
// ---------- 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" })}` : "";
|
||
};
|