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>
This commit is contained in:
metatroncubeswdev 2026-10-27 00:48:32 -04:00
parent d314a7ad7c
commit df3dbb63c4
11 changed files with 800 additions and 16 deletions

View File

@ -7,12 +7,12 @@ description: Audit one client's Instagram and Facebook using live data and write
2. Pull data with the social-manager tools:
- `instagram_insights` (28 days), `instagram_recent_posts` (limit 20)
- `facebook_page_insights` (28 days), `facebook_recent_posts` (limit 10)
- If a `profile-check-YYYY-MM.md` or `screenshots/` exists for the client, read it. It overrides anything guessed from the API (bio link, highlights, visuals, Page buttons, reviews).
- Screenshots: the team uploads them in the dashboard (client → Screenshots) to `clients/<slug>/screenshots/YYYY-MM/`, one file per shot: `ig-profile`, `ig-grid`, `ig-reels`, `fb-page`, `fb-reviews`, `gbp`, `gbp-reviews`, `bio-link` (.png/.jpg/.webp). Read every image in the newest month folder (and `profile-check-YYYY-MM.md` if present). They override anything guessed from the API (bio link, highlights, visuals, Page buttons, reviews). List missing required shots (`ig-profile`, `ig-grid`, `fb-page`, `gbp`) under open questions instead of guessing.
3. Analyse against `playbooks/instagram.md` and `playbooks/facebook.md`:
- Posting frequency and gaps; format mix (Reels vs carousel vs static).
- Top 3 and bottom 3 posts by reach and by shares+saves — what do the winners have in common?
- Reach vs followers (is content leaving the follower bubble?).
- Profile: bio, link, highlights, consistency of visuals (ask the user for a screenshot if needed).
- Profile: bio, link, highlights, consistency of visuals (from the screenshots; if missing, note it rather than asking mid-run).
4. Write **two** reports for the month:
**a) `clients/<slug>/audit-YYYY-MM-internal.md`**: for the agency team only.
@ -28,20 +28,24 @@ description: Audit one client's Instagram and Facebook using live data and write
- The PDF carries Metatroncube branding (it's our private deliverable to the client). Inside the text, never mention other clients, team members, tools/APIs or internal file names. Write as "we".
- Structure: **Summary** (3–4 sentences) → **Your numbers this month** (short table, 5–7 metrics) → **What's working** → **What we're changing** (3–5 points framed as the plan, not blame) → **Next 30 days** → **What we need from you** (checklist).
- Leave out: raw data caveats, internal compliance doubts (turn them into neutral questions in "What we need from you"), mistakes we're fixing quietly, cross-client notes, budgets we haven't discussed with them.
- Keep it to roughly one page. Before saving, check it doesn't contradict the internal version.
- **Front matter for the branded PDF:** start each report file with front matter. The PDF builder turns it into the dark title block and 4 headline-number cards, so don't add a `#` H1:
- Short text, mostly visuals: 3–4 PDF pages. Before saving, check it doesn't contradict the internal version.
- **Front matter:** start each file with it (no `#` H1; the builder draws the title block):
```
---
client: <Client name>
report: <Report type> · <Month YYYY>
period: <date range>
accounts: Instagram @handle · Facebook Page
prepared: <Month D, YYYY>
kpis:
- <value> | <label> | <short note>
---
```
Use exactly 4 KPIs. Client version: plain-language wins and gaps. Internal version: the 4 numbers that matter most for the team. Put the client summary in a `>` blockquote so it renders as a callout.
- **Visual blocks** (rendered by `tools/report-pdf/blocks.mjs`; see `example-client.md` in this skill folder for a full example). Data blocks are empty fenced blocks; the charts are drawn from the stored account numbers, so never type numbers into them:
- ```` ```mc:kpis ```` headline number cards with sparklines; ```` ```mc:reach ```` daily reach with post days marked; ```` ```mc:funnel ```` seen → reached → engaged → website taps, biggest drop in red; ```` ```mc:followers ```` gained vs lost; ```` ```mc:formats ```` share of posts vs reach per format; ```` ```mc:rhythm ```` 8-week posting calendar; ```` ```mc:top-posts ```` best and weakest 3.
- ```` ```mc:score ```` one line per area: `Area | 0–10 | one-line reason` (Profile setup, Content, Reach, Consistency, Turning viewers into enquiries; add Google/reviews for local businesses). Scores are your judgement from the data; be honest.
- ```` ```mc:actions ```` one line per action: `Action | Owner (Us / You / Us + you) | When`. Use it for "Next 30 days".
- ```` ```mc:screenshot ```` first line a shot id (`ig-profile`, `ig-grid`, `fb-page`, `gbp`…), then one short note per line about what the screenshot shows. Skip it if that shot wasn't uploaded.
- Callouts: `> [!WIN] Title`, `> [!FIX] Title`, `> [!RISK] Title`, `> [!NOTE] Title`, then the text on the next line. Use them for the headline finding of each section, not every paragraph.
- Client order: Summary + `mc:score` → Your numbers (`mc:kpis`, `mc:reach`) → Where people drop off (`mc:funnel`, `mc:followers`) → What's working (`[!WIN]`, screenshot, `mc:formats`) → What we're changing (`mc:rhythm`, `mc:top-posts`, `[!FIX]`/`[!RISK]`) → Next 30 days (`mc:actions`) → What we need from you (checklist). The internal version uses the same blocks plus the internal sections.
- Each data block can be used once per report. If a block shows "no account data stored yet", refresh the client's numbers in the dashboard and re-export rather than writing numbers by hand.
5. If brand.md is mostly empty, draft it from what you saw (tone, pillars, visuals) and mark it DRAFT for the user to confirm.
6. Append 2–3 dated lessons to `history.md`.
7. Export both as branded PDFs: `node tools/report-pdf/build.mjs clients/<slug>/audit-YYYY-MM-client.md clients/<slug>/audit-YYYY-MM-internal.md`. Open the client PDF with Read and check the layout before handing it over.

View File

@ -0,0 +1,87 @@
---
client: Metatroncube
report: Social media review · October 2026
period: 21 Sep – 19 Oct 2026
accounts: Instagram @metatron_digitalagency · Facebook Page
---
## Summary
The Instagram account has almost 2,900 followers, but posts reach about 50 people a month, and more people unfollowed than followed. The followers were built on older content for a different audience, so Instagram has stopped showing the posts. The fix is to treat Instagram as a fresh start aimed at Canadian small-business owners: people on camera, real questions, and one clear story across every channel.
```mc:score
Profile setup | 7 | Clear bio, links page and highlights. Facebook tells a different story.
Content | 4 | Polished templates, but no faces and no real results.
Reach | 2 | Posts reach about 2% of followers.
Consistency | 5 | About one post a week, with gaps of up to two weeks.
Turning viewers into enquiries | 2 | No website taps this month.
```
## Your numbers this month
```mc:kpis
```
```mc:reach
```
> [!FIX] Posting isn't moving the line yet
> Days with a post look the same as days without one. That usually means the posts aren't being shown beyond a few existing followers. Reels with a person on camera are the fastest way to change that.
## Where people drop off
```mc:funnel
```
```mc:followers
```
## What's working
> [!WIN] The profile is ready for new visitors
> A clear bio aimed at Canadian small businesses, a "Free Consult" call to action, a links page and five highlights (Services, Reviews, Tips, Portfolio, About). When reach comes back, visitors will know what you do.
```mc:screenshot
ig-profile
Bio says exactly who it's for: ERP and web systems for Canadian small businesses.
Links page leads to booking, WhatsApp and portfolio.
Highlights cover services, reviews and portfolio.
The grid is all designed graphics: no faces from the team and no real client results.
```
```mc:formats
```
## What we're changing
```mc:rhythm
```
```mc:top-posts
```
> [!RISK] Facebook tells a different story
> Instagram says "ERP and web systems for Canadian small businesses". The Facebook cover says "Social Media Marketing, SEO, Web Designing". A business owner who checks both won't know what you actually do.
```mc:screenshot
fb-page
The cover lists four unrelated services.
The bio is generic ("technology focused solutions").
WhatsApp and Message buttons are set up, which is good.
```
## Next 30 days
```mc:actions
Post 2 Reels a week with a team member on camera answering one real question, e.g. "What does an ERP cost a 10-person business?" | Us | Weekly from Nov 3
Use Trial Reels so new Reels are shown to non-followers first | Us | From Nov 3
New Facebook cover and bio that match the ERP + web story | Us | By Nov 7
Two anonymised results posts ("hours saved for a Kitchener clinic") | Us + you | By Nov 21
Ask 5 recent happy clients for a Google review | You | By Nov 30
```
## What we need from you
- [ ] 20 minutes a week on camera, or a team member who will be
- [ ] Two client results we can share without naming the client
- [ ] Names of 5 clients to ask for a Google review

View File

@ -192,3 +192,148 @@ input[type="checkbox"] {
border-radius: 3px;
}
input[type="checkbox"]:checked { background: var(--mc-blue); }
/* =========================================================
Visual blocks (tools/report-pdf/blocks.mjs)
========================================================= */
:root { --mc-good: #2e9e6b; --mc-warn: #d99a1e; --mc-bad: #d0543f; --mc-good-soft: #eaf6f0; --mc-warn-soft: #fdf5e4; --mc-bad-soft: #fcecea; }
/* Screen preview in the dashboard */
@media screen {
body.mc-screen { max-width: 860px; margin: 0 auto; padding: 28px 32px 48px; background: #fff; font-size: 10.5pt; }
}
.mc-nodata { font-size: 8pt; color: var(--mc-muted); font-style: italic; }
/* Cards */
.mc-card { margin: 12px 0; padding: 14px 16px 12px; border: 1px solid var(--mc-line); border-radius: 12px; background: #fff; break-inside: avoid; }
.mc-card figcaption { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin-bottom: 10px; }
.mc-card-title { font: 700 10.5pt var(--mc-head); color: var(--mc-charcoal); }
.mc-card-sub { font-size: 7.5pt; color: var(--mc-muted); text-align: right; }
.mc-card-foot { margin: 10px 0 0; padding-top: 8px; border-top: 1px dashed var(--mc-line); font-size: 8.5pt; color: var(--mc-gray); }
.mc-chart { display: block; width: 100%; height: auto; }
.mc-ax { font: 7pt var(--mc-body); fill: var(--mc-muted); }
/* KPI grid with sparklines */
.mc-kpigrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 12px 0; break-inside: avoid; }
.mc-kpi2 { position: relative; padding: 12px 14px 12px 16px; border-radius: 12px; background: var(--mc-tint); overflow: hidden; }
.mc-kpi2::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--mc-blue); }
.mc-kpi2.is-bad::before { background: var(--mc-bad); }
.mc-kpi2.is-good::before { background: var(--mc-good); }
.mc-kpi2-top { display: flex; justify-content: space-between; align-items: flex-end; gap: 6px; }
.mc-kpi2-v { font: 800 20pt/1 var(--mc-head); color: var(--mc-charcoal); letter-spacing: -0.5px; }
.mc-kpi2-l { margin-top: 6px; font-size: 8pt; font-weight: 600; color: var(--mc-ink); }
.mc-kpi2-n { margin-top: 2px; font-size: 7.5pt; color: var(--mc-gray); }
.mc-kpi2.is-bad .mc-kpi2-n { color: var(--mc-bad); font-weight: 600; }
.mc-kpi2.is-good .mc-kpi2-n { color: var(--mc-good); font-weight: 600; }
.mc-spark { flex: none; }
/* Funnel */
.mc-fun { display: grid; gap: 4px; }
.mc-fun-conv { display: block; font-size: 7.5pt; color: var(--mc-muted); margin: 1px 0 2px 6px; }
.mc-fun-conv::before { content: "↓ "; }
.mc-fun-conv.is-bad { color: var(--mc-bad); font-weight: 700; }
.mc-fun-bar { min-width: 34px; height: 24px; border-radius: 6px; display: flex; align-items: center; padding: 0 8px; }
.mc-fun-bar span { font: 700 9pt var(--mc-head); color: #fff; }
.mc-fun-l { font-size: 8pt; color: var(--mc-gray); margin: 2px 0 0 2px; }
/* Followers gained vs lost */
.mc-flow { display: grid; grid-template-columns: 110px 1fr; gap: 16px; align-items: center; }
.mc-flow-net { text-align: center; padding: 12px 6px; border-radius: 12px; }
.mc-flow-net b { display: block; font: 800 24pt/1 var(--mc-head); }
.mc-flow-net span { font-size: 7.5pt; color: var(--mc-gray); }
.mc-flow-net.is-bad { background: var(--mc-bad-soft); color: var(--mc-bad); }
.mc-flow-net.is-good { background: var(--mc-good-soft); color: var(--mc-good); }
.mc-flow-row { display: grid; grid-template-columns: 70px 1fr 40px; align-items: center; gap: 8px; margin: 4px 0; font-size: 8.5pt; }
.mc-flow-track { height: 12px; border-radius: 6px; background: var(--mc-tint); overflow: hidden; }
.mc-flow-track div { height: 100%; border-radius: 6px; }
.mc-flow-v { font-weight: 700; text-align: right; }
.mc-flow-spark { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 7.5pt; color: var(--mc-muted); }
/* Formats: share of posts vs reach */
table.mc-pairs { margin: 0; font-size: 8.5pt; }
table.mc-pairs td { border-bottom: 1px solid var(--mc-line); padding: 6px; vertical-align: middle; }
table.mc-pairs tr:last-child td { border-bottom: 0; }
table.mc-pairs td:first-child { width: 80px; }
.mc-pair { position: relative; height: 20px; border-radius: 5px; background: var(--mc-tint); }
.mc-pair-bar { position: absolute; top: 0; bottom: 0; left: 0; border-radius: 5px; min-width: 3px; }
.mc-pair span { position: relative; line-height: 20px; padding-left: 8px; font-size: 7.8pt; font-weight: 600; color: var(--mc-charcoal); }
/* Posting rhythm */
.mc-rh { display: grid; grid-template-columns: auto 1fr; gap: 26px; align-items: center; }
.mc-rh-grid { display: grid; gap: 3px; }
.mc-rh-week { display: grid; grid-template-columns: 46px repeat(7, 14px); gap: 3px; align-items: center; }
.mc-rh-wl { font-size: 6.8pt; color: var(--mc-muted); }
.mc-rh-head b { font-size: 6.5pt; color: var(--mc-muted); text-align: center; font-weight: 600; }
.mc-rh-week i { width: 14px; height: 14px; border-radius: 3px; background: #edf0f5; }
.mc-rh-week i.is-on { background: var(--mc-blue); }
.mc-rh-week i.is-future { background: transparent; border: 1px dashed var(--mc-line); }
.mc-rh-stats { display: grid; gap: 10px; }
.mc-rh-stats div { display: flex; align-items: baseline; gap: 8px; }
.mc-rh-stats b { font: 800 18pt/1 var(--mc-head); color: var(--mc-charcoal); min-width: 44px; }
.mc-rh-stats span { font-size: 8.5pt; color: var(--mc-gray); }
/* Best / weakest posts */
.mc-posts { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.mc-posts h4 { margin: 0 0 6px; font: 700 8pt var(--mc-head); letter-spacing: 0.8px; text-transform: uppercase; }
.mc-posts h4.is-good { color: var(--mc-good); }
.mc-posts h4.is-bad { color: var(--mc-bad); }
.mc-posts ol { list-style: none; padding: 0; margin: 0; }
.mc-posts li { padding: 7px 0; border-bottom: 1px solid var(--mc-line); }
.mc-posts li:last-child { border-bottom: 0; }
.mc-post-meta { font-size: 7.3pt; color: var(--mc-muted); }
.mc-chip { display: inline-block; margin-right: 6px; padding: 1px 6px; border-radius: 10px; background: var(--mc-tint); color: var(--mc-blue-deep); font-weight: 700; font-size: 6.8pt; text-transform: uppercase; letter-spacing: 0.4px; }
.mc-post-cap { margin: 3px 0 5px; font-size: 8.3pt; line-height: 1.4; color: var(--mc-ink); }
.mc-post-bar { height: 5px; border-radius: 3px; background: var(--mc-tint); overflow: hidden; }
.mc-post-bar div { height: 100%; background: linear-gradient(90deg, var(--mc-blue), var(--mc-sky)); border-radius: 3px; }
.mc-post-nums { margin-top: 3px; font-size: 7.5pt; color: var(--mc-gray); }
.mc-post-nums b { color: var(--mc-charcoal); }
/* Score */
.mc-score { display: grid; grid-template-columns: 130px 1fr; gap: 18px; align-items: center; margin: 12px 0; padding: 16px; border-radius: 14px; background: linear-gradient(135deg, var(--mc-tint), #fff); border: 1px solid var(--mc-line); break-inside: avoid; }
.mc-score-ring { text-align: center; font-size: 7.5pt; color: var(--mc-gray); }
.mc-score-ring svg { display: block; margin: 0 auto 4px; }
.mc-ring-num { font: 800 20px var(--mc-head); fill: var(--mc-charcoal); }
.mc-score-rows { display: grid; gap: 8px; }
.mc-score-head { display: flex; justify-content: space-between; font-size: 8.8pt; }
.mc-score-head span { font: 800 9pt var(--mc-head); }
.mc-score-track { height: 7px; margin: 3px 0; border-radius: 4px; background: #e8edf3; overflow: hidden; }
.mc-score-track div { height: 100%; border-radius: 4px; }
.mc-score-row p { margin: 0; font-size: 7.8pt; color: var(--mc-gray); line-height: 1.4; }
/* Actions */
ol.mc-actions { list-style: none; counter-reset: act; padding: 0; margin: 10px 0; display: grid; gap: 7px; }
ol.mc-actions li { counter-increment: act; display: grid; grid-template-columns: 28px 1fr auto; gap: 10px; align-items: center; padding: 10px 12px; border: 1px solid var(--mc-line); border-radius: 10px; break-inside: avoid; margin: 0; }
ol.mc-actions li::before { content: counter(act); display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: var(--mc-blue); color: #fff; font: 800 9pt var(--mc-head); }
.mc-act-what { font-size: 9pt; line-height: 1.45; }
.mc-act-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 3px; white-space: nowrap; }
.mc-owner { padding: 2px 8px; border-radius: 10px; font-size: 7.3pt; font-weight: 700; }
.mc-owner.is-us { background: #e6f0fa; color: var(--mc-blue-deep); }
.mc-owner.is-client { background: var(--mc-warn-soft); color: #9a6a0c; }
.mc-when { font-size: 7.3pt; color: var(--mc-muted); }
/* Callouts */
.mc-alert { margin: 10px 0; padding: 11px 14px 11px 40px; border-radius: 10px; position: relative; break-inside: avoid; font-size: 9.2pt; }
.mc-alert::before { position: absolute; left: 13px; top: 11px; width: 18px; height: 18px; border-radius: 50%; display: grid; place-items: center; color: #fff; font: 800 9pt var(--mc-head); }
.mc-alert p { margin: 3px 0; }
.mc-alert-title { font: 700 9.8pt var(--mc-head); margin-bottom: 2px; }
.mc-alert.is-good { background: var(--mc-good-soft); }
.mc-alert.is-good::before { content: "✓"; background: var(--mc-good); }
.mc-alert.is-good .mc-alert-title { color: #1f7a51; }
.mc-alert.is-warn { background: var(--mc-warn-soft); }
.mc-alert.is-warn::before { content: "!"; background: var(--mc-warn); }
.mc-alert.is-warn .mc-alert-title { color: #8a5d06; }
.mc-alert.is-bad { background: var(--mc-bad-soft); }
.mc-alert.is-bad::before { content: "!"; background: var(--mc-bad); }
.mc-alert.is-bad .mc-alert-title { color: #a63a28; }
.mc-alert.is-info { background: var(--mc-tint); }
.mc-alert.is-info::before { content: "i"; background: var(--mc-blue); }
.mc-alert.is-info .mc-alert-title { color: var(--mc-blue-deep); }
/* Screenshot with numbered notes */
.mc-shot { display: grid; grid-template-columns: 58% 1fr; gap: 14px; margin: 12px 0; align-items: start; break-inside: avoid; }
.mc-shot.is-solo { grid-template-columns: 1fr; }
.mc-shot img { width: 100%; border-radius: 10px; border: 1px solid var(--mc-line); }
ol.mc-shot-notes { list-style: none; counter-reset: note; padding: 0; margin: 0; display: grid; gap: 8px; }
ol.mc-shot-notes li { counter-increment: note; position: relative; padding-left: 26px; font-size: 8.8pt; line-height: 1.45; margin: 0; }
ol.mc-shot-notes li::before { content: counter(note); position: absolute; left: 0; top: 0; width: 18px; height: 18px; border-radius: 50%; background: var(--mc-charcoal); color: #fff; display: grid; place-items: center; font: 700 8pt var(--mc-head); }

View File

@ -3,7 +3,7 @@ import fs from "node:fs";
import path from "node:path";
import { marked } from "marked";
import { safeSlug } from "./clients.js";
import { toPdf } from "../../tools/report-pdf/build.mjs";
import { toPdf, buildHtml } from "../../tools/report-pdf/build.mjs";
import { reportOptions } from "./workspace.js";
/** Resolve a document path inside one client's folder (blocks ../ escapes). */
@ -37,7 +37,9 @@ export function renderDoc(slug, rel) {
// Content is written by our own team/AI runs; links open in a new tab.
const html = marked.parse(body, { gfm: true, breaks: true }).replace(/<a href=/g, '<a target="_blank" rel="noopener" href=');
const pdf = full.replace(/\.md$/, ".pdf");
return { path: rel, meta, html, updatedAt: fs.statSync(full).mtime.toISOString(), hasPdf: fs.existsSync(pdf) };
// Reports with visual blocks (```mc:…) preview as the same designed page the PDF uses.
const page = /^```mc:/m.test(body) ? buildHtml(full, { ...reportOptions(), screen: true }) : undefined;
return { path: rel, meta, html, page, updatedAt: fs.statSync(full).mtime.toISOString(), hasPdf: fs.existsSync(pdf) };
}
export function exportPdf(slug, rel) {

View File

@ -32,6 +32,7 @@ import * as brandkit from "../../connector/src/brandkit.js";
import * as extra from "../../connector/src/platforms/index.js";
import { publicBaseUrl, resolveMediaToken } from "../../connector/src/platforms/media.js";
import { DATA_DIR as _DD } from "./config.js";
import * as shots from "./screenshots.js";
const app = express();
app.disable("x-powered-by");
@ -322,6 +323,15 @@ app.get("/api/clients/:slug/file", h(async (req, res) => {
else res.sendFile(full);
}));
// ---------- monthly profile screenshots (the audit reads them) ----------
app.get("/api/clients/:slug/screenshots", h(async (req) => shots.list(req.params.slug, req.query.month)));
app.post("/api/clients/:slug/screenshots/:shot", express.raw({ type: "*/*", limit: "16mb" }), h(async (req) =>
shots.save(req.params.slug, req.query.month, req.params.shot, req.body, decodeURIComponent(String(req.headers["x-filename"] || "")), who(req))));
app.delete("/api/clients/:slug/screenshots/:shot", h(async (req) => shots.remove(req.params.slug, req.query.month, req.params.shot)));
app.get("/api/clients/:slug/screenshots/:shot/image", h(async (req, res) => res.sendFile(shots.filePath(req.params.slug, req.query.month, req.params.shot))));
app.post("/api/clients/:slug/screenshots/:shot/example", requireAdmin, h(async (req) => shots.useAsExample(req.params.slug, req.query.month, req.params.shot)));
app.get("/api/screenshot-examples/:shot", h(async (req, res) => res.sendFile(shots.examplePath(req.params.shot))));
// ---------- AI jobs ----------
app.get("/api/jobs", h(async (req) => ({ jobs: jobs.listJobs({ slug: req.query.slug }), types: Object.fromEntries(Object.entries(jobs.JOB_TYPES).map(([k, v]) => [k, { label: v.label, needsClient: v.needsClient }])) })));
app.get("/api/jobs/:id", h(async (req) => {

View File

@ -8,6 +8,7 @@ import { query } from "@anthropic-ai/claude-agent-sdk";
import { PROJECT_ROOT, DATA_DIR, AI } from "./config.js";
import { getClient, listDocs, safeSlug } from "./clients.js";
import { toPdf } from "../../tools/report-pdf/build.mjs";
import { refreshSnapshot } from "./metrics.js";
const JOB_DIR = path.join(DATA_DIR, "jobs");
fs.mkdirSync(JOB_DIR, { recursive: true });
@ -171,6 +172,11 @@ async function run(job) {
persist(job);
const before = docStamp(job.slug);
log(job, "info", `Started: ${job.prompt.split("\n")[0]}`);
// Report charts are drawn from the stored numbers, so bring them up to date first.
if (job.slug && /^\/(account-audit|weekly-report)/.test(job.prompt)) {
try { await refreshSnapshot(job.slug); log(job, "info", "Refreshed the account numbers for the charts."); }
catch (e) { log(job, "warn", `Couldn't refresh account numbers (${e.message}); charts use the last stored ones.`); }
}
try {
for await (const msg of query({
prompt: job.prompt,

View File

@ -0,0 +1,113 @@
// Monthly profile screenshots per client: the team captures a fixed shot list, the audit reads them.
// Files: clients/<slug>/screenshots/YYYY-MM/<shot>.<ext>; example shots (the "template") live in DATA_DIR.
import fs from "node:fs";
import path from "node:path";
import { DATA_DIR } from "./config.js";
import { safeSlug } from "./clients.js";
export const SHOTS = [
{ id: "ig-profile", platform: "Instagram", label: "Profile top", required: true,
how: "In a computer browser, open instagram.com/<handle> (logged out is fine). Capture the whole window: name, bio, link, follower count, highlights and the first row of posts." },
{ id: "ig-grid", platform: "Instagram", label: "Grid", required: true,
how: "Zoom the browser out to 50% and capture the latest 9–12 posts, so the overall look of the feed shows." },
{ id: "ig-reels", platform: "Instagram", label: "Reels tab", required: false,
how: "Click the Reels tab under the highlights. Capture the covers with their view counts." },
{ id: "fb-page", platform: "Facebook", label: "Page header", required: true,
how: "In a computer browser, open the Facebook Page. Capture the whole window: cover photo, profile picture, name, followers, category, buttons (Message, WhatsApp, Book…) and intro." },
{ id: "fb-reviews", platform: "Facebook", label: "Reviews", required: false,
how: "Open the Reviews / Recommendations tab. Capture the score and the newest reviews. Skip if the Page has no reviews tab." },
{ id: "gbp", platform: "Google", label: "Business Profile", required: true,
how: "In a computer browser, search the business name on google.com/maps and open the listing. Capture rating, review count, category, hours, buttons and photos." },
{ id: "gbp-reviews", platform: "Google", label: "Google reviews", required: false,
how: "Open Reviews, sort by Newest and capture the latest reviews, including whether the owner replied." },
{ id: "bio-link", platform: "Website", label: "Link in bio page", required: false,
how: "Open the link from the Instagram bio. Capture the first screen that opens." },
];
const EXT = /\.(png|jpe?g|webp)$/i;
const EXAMPLES_DIR = path.join(DATA_DIR, "screenshot-examples");
const MAX_BYTES = 15 * 1024 * 1024;
const fail = (message, status = 400) => Object.assign(new Error(message), { status });
const shotDef = (id) => SHOTS.find((s) => s.id === id) || (() => { throw fail("Unknown screenshot.", 404); })();
const monthOf = (m) => {
const month = m || new Date().toISOString().slice(0, 7);
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month)) throw fail("Month must look like 2026-10.");
return month;
};
const rootDir = (slug) => path.join(safeSlug(slug), "screenshots");
const findFile = (dir, id) => (fs.existsSync(dir) ? fs.readdirSync(dir).find((f) => f.startsWith(`${id}.`) && EXT.test(f)) : null);
const metaPath = (dir) => path.join(dir, "uploads.json");
const readMeta = (dir) => { try { return JSON.parse(fs.readFileSync(metaPath(dir), "utf8")); } catch { return {}; } };
/** Reject anything that isn't really a PNG, JPEG or WebP image. */
function checkImage(buf, name) {
if (!EXT.test(name || "")) throw fail("Upload a PNG, JPG or WebP screenshot.");
if (!buf?.length) throw fail("The file is empty.");
if (buf.length > MAX_BYTES) throw fail("Screenshots must be under 15 MB.");
const png = buf[0] === 0x89 && buf[1] === 0x50, jpg = buf[0] === 0xff && buf[1] === 0xd8, webp = buf.subarray(8, 12).toString() === "WEBP";
if (!png && !jpg && !webp) throw fail("That file isn't a PNG, JPG or WebP image.");
return png ? ".png" : jpg ? ".jpg" : ".webp";
}
function writeShot(dir, id, buf, ext) {
fs.mkdirSync(dir, { recursive: true });
const old = findFile(dir, id);
if (old) fs.rmSync(path.join(dir, old));
fs.writeFileSync(path.join(dir, id + ext), buf);
}
export function list(slug, month) {
month = monthOf(month);
const root = rootDir(slug), dir = path.join(root, month), meta = readMeta(dir);
const months = fs.existsSync(root) ? fs.readdirSync(root).filter((m) => /^\d{4}-\d{2}$/.test(m)).sort().reverse() : [];
return {
month,
months: [...new Set([monthOf(), month, ...months])].sort().reverse(),
shots: SHOTS.map((s) => {
const file = findFile(dir, s.id);
return { ...s, example: !!findFile(EXAMPLES_DIR, s.id), file: file ? { name: file, size: fs.statSync(path.join(dir, file)).size, ...meta[s.id] } : null };
}),
};
}
export function save(slug, month, id, buf, name, by) {
shotDef(id);
const dir = path.join(rootDir(slug), monthOf(month));
writeShot(dir, id, buf, checkImage(buf, name));
const meta = readMeta(dir);
meta[id] = { by, at: new Date().toISOString() };
fs.writeFileSync(metaPath(dir), JSON.stringify(meta, null, 2));
return list(slug, month);
}
export function remove(slug, month, id) {
shotDef(id);
const dir = path.join(rootDir(slug), monthOf(month)), file = findFile(dir, id);
if (file) fs.rmSync(path.join(dir, file));
const meta = readMeta(dir);
delete meta[id];
if (fs.existsSync(dir)) fs.writeFileSync(metaPath(dir), JSON.stringify(meta, null, 2));
return list(slug, month);
}
export function filePath(slug, month, id) {
shotDef(id);
const dir = path.join(rootDir(slug), monthOf(month)), file = findFile(dir, id);
if (!file) throw fail("Screenshot not found.", 404);
return path.join(dir, file);
}
export function examplePath(id) {
shotDef(id);
const file = findFile(EXAMPLES_DIR, id);
if (!file) throw fail("No example yet.", 404);
return path.join(EXAMPLES_DIR, file);
}
/** Admin: make a client's upload the example every client sees for that shot. */
export function useAsExample(slug, month, id) {
const src = filePath(slug, month, id);
writeShot(EXAMPLES_DIR, id, fs.readFileSync(src), path.extname(src).toLowerCase());
return list(slug, month);
}

View File

@ -7,6 +7,7 @@ import ClientPortal from "./ClientPortal.jsx";
import MonthlyReport from "./MonthlyReport.jsx";
import BrandKit from "./BrandKit.jsx";
import LinkInBio from "./LinkInBio.jsx";
import Screenshots from "./Screenshots.jsx";
import { useAuth } from "../App.jsx";
import {
Badge, Button, cx, Dialog, Empty, ErrorNote, fmt, fmtDate, fmtDateTime, Panel, Spinner, Stat, StatCell, StatRow, STATUS_TONE, Tabs, timeAgo,
@ -101,6 +102,7 @@ export default function Client() {
{ value: "performance", label: "Performance" },
{ value: "docs", label: "Documents", count: data.docs.filter((d) => d.kind !== "setup").length },
{ value: "runs", label: "AI runs", count: data.jobs.length },
{ value: "shots", label: "Screenshots" },
{ value: "brand", label: "Brand kit" },
{ value: "bio", label: "Link in bio" },
{ value: "portal", label: "Client approval" },
@ -119,6 +121,7 @@ export default function Client() {
))}
{tab === "docs" && <div className="space-y-5"><MonthlyReport slug={slug} onBuilt={load} /><Documents slug={slug} docs={data.docs} reload={load} /></div>}
{tab === "runs" && <ClientRuns jobs={data.jobs} />}
{tab === "shots" && <Screenshots slug={slug} clientName={client.name} />}
{tab === "brand" && <BrandKit slug={slug} />}
{tab === "bio" && <LinkInBio slug={slug} />}
{tab === "portal" && <ClientPortal slug={slug} clientName={client.name} />}
@ -474,7 +477,9 @@ function Documents({ slug, docs, reload }) {
)
}
>
{preview?.loading ? <Spinner /> : preview && (
{preview?.loading ? <Spinner /> : preview?.page ? (
<iframe title={preview.doc.title} srcDoc={preview.page} sandbox="allow-popups allow-popups-to-escape-sandbox" className="w-full h-[72vh] rounded-lg border border-line bg-white" />
) : preview && (
<>
{preview.meta?.client && (
<div className="rounded-lg bg-[#232327] text-white px-5 py-4 mb-4">

View File

@ -0,0 +1,142 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Copy, ImageUp, Star, Trash2 } from "lucide-react";
import { api } from "../api.js";
import { useAuth } from "../App.jsx";
import { Badge, Button, Dialog, ErrorNote, Panel, Spinner, timeAgo } from "../ui.jsx";
const monthLabel = (m) => new Date(`${m}-15`).toLocaleDateString("en-CA", { month: "long", year: "numeric" });
/** Client page tab: the monthly screenshot checklist the team fills and the audit reads. */
export default function Screenshots({ slug, clientName }) {
const { isAdmin } = useAuth();
const [month, setMonth] = useState(null);
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [busy, setBusy] = useState(null);
const [view, setView] = useState(null); // { title, src }
const [copied, setCopied] = useState(false);
const load = useCallback(() => api(`/api/clients/${slug}/screenshots${month ? `?month=${month}` : ""}`).then((d) => { setData(d); setError(null); }).catch(setError), [slug, month]);
useEffect(() => { load(); }, [load]);
const act = async (id, fn) => {
setBusy(id); setError(null);
try { setData(await fn()); } catch (e) { setError(e); }
setBusy(null);
};
const q = `?month=${data?.month}`;
const upload = (shot, file) => act(shot.id, async () => {
const res = await fetch(`/api/clients/${slug}/screenshots/${shot.id}${q}`, { method: "POST", credentials: "same-origin", headers: { "X-Filename": encodeURIComponent(file.name), "Content-Type": "application/octet-stream" }, body: file });
const out = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(out.error || `Upload failed (${res.status})`);
return out;
});
const copyChecklist = async () => {
const lines = data.shots.map((s, i) => `${i + 1}. ${s.platform}, ${s.label}${s.required ? "" : " (if it exists)"}: ${s.how}`);
await navigator.clipboard.writeText(`Screenshots for ${clientName}, ${monthLabel(data.month)}\nUpload them in Quorla → ${clientName} → Screenshots.\n\n${lines.join("\n")}`);
setCopied(true); setTimeout(() => setCopied(false), 2000);
};
if (!data) return error ? <ErrorNote error={error} /> : <Spinner />;
const required = data.shots.filter((s) => s.required);
const done = required.filter((s) => s.file).length;
return (
<div className="space-y-4">
<Panel
title="Monthly screenshots"
subtitle="Take these in a computer browser before the monthly audit (Windows: Win+Shift+S). The audit reads them for what the numbers can't show: bio, highlights, how the feed looks, buttons and reviews."
action={
<div className="flex items-center gap-2">
<label htmlFor="ss-month" className="sr-only">Month</label>
<select id="ss-month" className="input h-8 w-auto" value={data.month} onChange={(e) => setMonth(e.target.value)}>
{data.months.map((m) => <option key={m} value={m}>{monthLabel(m)}</option>)}
</select>
<Button size="sm" icon={Copy} onClick={copyChecklist}>{copied ? "Copied" : "Copy checklist"}</Button>
</div>
}
>
<p className="text-[13px] text-ink-2">
<span className="num font-semibold text-ink">{done} of {required.length}</span> required screenshots uploaded for {monthLabel(data.month)}.
{done === required.length && " Ready for the audit."}
</p>
</Panel>
<ErrorNote error={error} />
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{data.shots.map((s) => (
<ShotCard key={s.id} shot={s} slug={slug} q={q} busy={busy === s.id} isAdmin={isAdmin}
onUpload={(f) => upload(s, f)}
onRemove={() => act(s.id, () => api(`/api/clients/${slug}/screenshots/${s.id}${q}`, { method: "DELETE" }))}
onExample={() => act(s.id, () => api(`/api/clients/${slug}/screenshots/${s.id}/example${q}`, { method: "POST" }))}
onView={setView} />
))}
</div>
<Dialog open={!!view} onClose={() => setView(null)} title={view?.title} wide>
{view && <img src={view.src} alt={view.title} className="mx-auto max-h-[70vh] w-auto rounded-lg border border-line" />}
</Dialog>
</div>
);
}
function ShotCard({ shot, slug, q, busy, isAdmin, onUpload, onRemove, onExample, onView }) {
const input = useRef(null);
const [drag, setDrag] = useState(false);
const title = `${shot.platform} · ${shot.label}`;
const stamp = shot.file?.at ? `&v=${encodeURIComponent(shot.file.at)}` : "";
const src = `/api/clients/${slug}/screenshots/${shot.id}/image${q}${stamp}`;
const exampleSrc = `/api/screenshot-examples/${shot.id}`;
const pick = (files) => files?.[0] && onUpload(files[0]);
return (
<section className="rounded-xl border border-line bg-white flex flex-col">
<header className="px-4 pt-3.5 pb-2 flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-[11.5px] font-medium text-muted uppercase tracking-wide">{shot.platform}</p>
<h3 className="text-[14px] font-semibold text-ink">{shot.label}</h3>
</div>
{shot.file ? <Badge tone="ok" dot>Uploaded</Badge> : shot.required ? <Badge tone="warn">Required</Badge> : <Badge>Optional</Badge>}
</header>
<p className="px-4 text-[12.5px] text-ink-2 leading-relaxed">{shot.how}</p>
<div className="p-4 mt-auto">
{shot.file ? (
<button type="button" onClick={() => onView({ title, src })} className="block w-full h-56 rounded-lg border border-line bg-canvas overflow-hidden">
<img src={src} alt={`${title} screenshot`} className="h-full w-full object-contain" loading="lazy" />
</button>
) : (
<div
onDragOver={(e) => { e.preventDefault(); setDrag(true); }}
onDragLeave={() => setDrag(false)}
onDrop={(e) => { e.preventDefault(); setDrag(false); pick(e.dataTransfer.files); }}
className={`h-56 rounded-lg border border-dashed flex flex-col items-center justify-center gap-2 text-center px-3 ${drag ? "border-brand bg-brand/5" : "border-line bg-canvas"}`}
>
{busy ? <Spinner label="Uploading" /> : (
<>
<ImageUp className="size-6 text-faint" aria-hidden="true" />
<p className="text-[12.5px] text-muted">Drop the screenshot here</p>
<Button size="sm" onClick={() => input.current?.click()}>Choose file</Button>
</>
)}
</div>
)}
<input ref={input} type="file" accept="image/png,image/jpeg,image/webp" className="sr-only" aria-label={`Upload ${title}`} onChange={(e) => { pick(e.target.files); e.target.value = ""; }} />
<div className="mt-2.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] text-muted">
{shot.file && <span>{shot.file.by ? `${shot.file.by}, ` : ""}{timeAgo(shot.file.at)}</span>}
{shot.example && <button type="button" className="underline hover:text-ink" onClick={() => onView({ title: `Example: ${title}`, src: exampleSrc })}>See example</button>}
{shot.file && (
<span className="ml-auto flex items-center gap-1">
<Button size="sm" loading={busy} onClick={() => input.current?.click()}>Replace</Button>
{isAdmin && <Button size="sm" icon={Star} title="Show this shot as the example for every client" onClick={onExample}>Use as example</Button>}
<button type="button" onClick={onRemove} className="p-1.5 rounded hover:bg-bad-soft text-muted hover:text-bad" aria-label={`Remove ${title}`}><Trash2 className="size-4" /></button>
</span>
)}
</div>
</div>
</section>
);
}

268
tools/report-pdf/blocks.mjs Normal file
View File

@ -0,0 +1,268 @@
// 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" })}` : "";
};

View File

@ -7,7 +7,7 @@ import { tmpdir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { execFileSync } from "node:child_process";
import { marked } from "marked";
import { renderMarkdown, asOfLabel } from "./blocks.mjs";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const brandDir = join(root, "branding");
@ -80,16 +80,18 @@ function heroHtml(meta) {
}
// opts.logoPath / opts.logoAlt: an agency's own logo (white-label) instead of branding/metatroncube-logo.png.
// opts.screen: page for the dashboard preview instead of print.
function buildHtml(mdPath, opts = {}) {
const { meta, content } = parseFrontMatter(readFileSync(mdPath, "utf8"));
const body = marked.parse(content, { gfm: true, breaks: true });
const internal = /-internal/i.test(basename(mdPath));
const body = renderMarkdown(content, { mdPath: resolve(mdPath), internal });
const asOf = /```mc:/.test(content) ? asOfLabel(mdPath) : "";
const css = readFileSync(join(brandDir, "report.css"), "utf8");
const custom = opts.logoPath && existsSync(opts.logoPath);
const logo = readFileSync(custom ? opts.logoPath : join(brandDir, "metatroncube-logo.png")).toString("base64");
const logoMime = custom && /\.jpe?g$/i.test(opts.logoPath) ? "image/jpeg" : "image/png";
const h1 = content.match(/^#\s+(.+)$/m)?.[1];
const title = (meta.client ? `${meta.client} — ${meta.report ?? "Report"}` : h1 ?? basename(mdPath, ".md")).replace(/[*_`]/g, "");
const internal = /-internal/i.test(basename(mdPath));
const date = meta.prepared || new Date().toLocaleDateString("en-CA", { year: "numeric", month: "long", day: "numeric" });
return `<!doctype html>
@ -98,13 +100,13 @@ function buildHtml(mdPath, opts = {}) {
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@600;700;800&display=swap" rel="stylesheet">
<style>${css}</style>
</head><body>
</head><body${opts.screen ? ' class="mc-screen"' : ""}>
<header class="mc-topbar">
${custom
? `<div><img src="data:${logoMime};base64,${logo}" alt="${escapeHtml(opts.logoAlt || "")}" style="display:block;height:44px;width:auto;max-width:260px;object-fit:contain"></div>`
: `<div class="mc-logo-crop"><img src="data:image/png;base64,${logo}" alt="Metatroncube — Digital Solutions Simplified"></div>`}
<div class="mc-topmeta">
Prepared ${escapeHtml(date)}
Prepared ${escapeHtml(date)}${asOf ? `<br>${escapeHtml(asOf)}` : ""}
${internal ? '<br><span class="mc-internal">INTERNAL — NOT FOR CLIENT</span>' : ""}
</div>
</header>