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:
parent
d314a7ad7c
commit
df3dbb63c4
@ -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.
|
||||
|
||||
87
.claude/skills/account-audit/example-client.md
Normal file
87
.claude/skills/account-audit/example-client.md
Normal 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
|
||||
@ -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); }
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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,
|
||||
|
||||
113
dashboard/server/screenshots.js
Normal file
113
dashboard/server/screenshots.js
Normal 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);
|
||||
}
|
||||
@ -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">
|
||||
|
||||
142
dashboard/web/src/pages/Screenshots.jsx
Normal file
142
dashboard/web/src/pages/Screenshots.jsx
Normal 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
268
tools/report-pdf/blocks.mjs
Normal 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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" })}` : "";
|
||||
};
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user