Derived from the operator's reference report (~/Downloads/Example of Reporting* — a 6-section
visual dashboard; images decoded to .local_media/report_ref/image1..6.png). This is the design
the Office Reports tab grows toward. v1 (GA4 + GSC, the two live sources) ships first; this
spec is the full target the connectors + comparison engine fill in as channels light up.
Governing rule: the framework ships now with HONEST empty-states; numbers populate as (a) UTM
is stamped at publish, (b) channel keys/auth land, (c) launch traffic flows. **Never fabricate a
number.** A zero with "awaiting launch / connect <X>" is correct; a made-up number is a defect.
Six sections. The signature pattern is identical in all of them: **every metric is a comparison
(YoY / WoW / TY-vs-LY) shown as a big number + a colored delta arrow (↗ green up / ↘ red down),
never a bare number — and every channel rolls up to REVENUE.**
Tapcart[their e-comm app] · Organic Search · Social · Unclassified) × **Sessions TY/LY/YoY% AND
Revenue TY/LY/YoY%**, with a Total row.
· Last-Touch Revenue · Revenue-per-Send, each TY/LY/YoY% and LW/WoW%; + **Email Revenue by
Type** (Campaign vs Flow).
Spend**, TW/LY/YoY%.
trend sparkline + delta. Pulled from the platforms, NOT GA4.
For a SERVICES business (not e-comm): "revenue/AOV/Rev-per-Session" map to **Stripe deal value +
Cal.com bookings**, not cart AOV. "Tapcart" has no analog → drop it; our channel list is the 9 social
+ Email + SMS + Direct + Organic + Referral + (later) Paid.
Mirror of how content-index.json is the content spine: ONE accumulating metrics file is the spine;
every surface bakes from it; connectors write slices; the dashboard never calls an API per render.
content/_generated/metrics-snapshot.json (APPEND-ONLY by date)Must accumulate history so WoW/YoY are computable (you can't compare to last week if you overwrote it).
Schema = a list of rows:
{ "date": "2026-06-08", "channel": "instagram", "campaign": "studio-launch",
"cell": "W23-studio-launch-beat-1", "metric": "impressions", "value": 1234 }
Keys: date (day) · channel (ga4|gsc|email|sms|<9 social>|stripe|calcom|pagespeed) · campaign ·
cell (UTM manualAdContent) · metric · value. Connectors upsert by (date,channel,campaign,cell,metric).
Roll-ups (WoW/YoY, per-channel, per-funnel-stage) are computed at bake time from this one file.
engine/integrations/metrics/<name>.py, each gated, each graceful-empty| Connector | Source | Metrics | Status / gate |
|---|---|---|---|
ga4.py | GA4 Data API (token live) | sessions, users, channelGroup, hostName, pagePath, conversions+revenue (if events configured), CWV | ✅ token — wire conversions/revenue once GA4 events exist |
gsc.py | Search Console (token live) | clicks, impressions, CTR, position, top query/page | ✅ token |
resend.py | Resend API (key present) | sent, delivered, open, click, unsub, bounce → open/click rate, rev-per-send (via UTM join) | key present, pull unwired |
twilio.py | Twilio API | sent, delivered, link-clicks → CTR/CVR/ROAS/spend | needs creds |
stripe.py | Stripe MCP/API | revenue, AOV (deal value), new customers, MRR | Stripe MCP available — the money layer |
calcom.py | Cal.com API | bookings/consults (the services "conversion") | needs key |
social.py | Meta Graph (FB/IG), YouTube Analytics, LinkedIn, X, TikTok, Pinterest, Bluesky — OR Blotato analytics if exposed | impressions, views, engagement, follows, profile-visits | biggest gap — none wired; check Blotato analytics read first |
pagespeed.py | PageSpeed Insights / CrUX (free, no key) | site speed / Core Web Vitals, daily | free — wire early (the "Site Speed" section) |
Each connector: pull(date_range) -> list[row]; missing key → returns [] (the dashboard renders the
honest empty-state). A nightly/build-time scripts/pull_metrics.py runs all available connectors,
upserts into the snapshot. Office bakes from the snapshot (no live API at render → fast, deterministic).
engine/lib/metrics_report.pyPure functions over the snapshot: kpi(metric, channel?, period) → `{value, prev, yoy_pct, wow_pct,
dir}; channel_table(metrics, period) → the §2 grid; delta_arrow(pct)` → the colored ↗/↘ render.
GA4/GSC can compute YoY/WoW NOW via dual date-range queries even before history accrues; other
channels compute from the accumulating snapshot.
engine/lib/utm.py is wired but NOT invoked in distribute.py/publish_run.py. Until every
published link carries utm_source/medium/campaign/content(=cell), the channel→campaign→cell→revenue
join (the right half of the reference) can't populate. → stamp UTM at publish (a prerequisite task).
funnel.yaml (Reach → Capture → Convert)Map each metric into a stage so the report answers "did the calendar move the needle," end-to-end:
Each stage rolls per-campaign + per-week, joined on UTM — the scorecard, not a traffic mirror.
gtm.yaml per-quarter kpi_emphasisRender KPIs as actual-vs-target (the per-quarter operational goals already in gtm.yaml), so the
dashboard is a scorecard with green/red against plan, not just a mirror.
/reports (hub) — Website KPI cards (delta-arrow) + the channel-breakdown table (sessions+revenue,YoY) + the per-week PLAN-vs-RESULT calendar correlation + links to the per-site sub-pages.
/reports/xyz, /reports/com — full per-site dashboards (KPIs · acquisition · hostName subdomaintransparency · top pages · GSC queries/pages · site speed).
/reports/email, /reports/sms, /reports/social— each mirroring the reference's dedicated section.
(GA4/GSC dual-range), the channel table w/ Sessions+Revenue comparison columns (revenue cols render
"awaiting conversion tracking" until UTM+Stripe land), the per-channel section scaffold w/ honest
empty-states.
metrics-snapshot.json + metrics_report.py).pagespeed.py (free), stripe.py (MCP), resend.py (key) →populate Site-Speed + revenue + Email sections.
v1 renders (real .com numbers + honest .xyz empty-state). Each connector: graceful-empty without its
key; with its key, writes dated rows to the snapshot; the comparison engine renders the delta arrow.
No secret/token ever written into apps/office/public/. Every empty cell carries a "connect <X> /
awaiting launch" reason — zero fabricated numbers.
/reports/<channel> in the reference shape, from dataThe per-channel section the reference report has (§4 Email, §5 SMS, §6 Social) now exists as a
registry + a metrics layer + pull stages + pages, and a gate that holds them together. Nothing
on a channel page is typed by hand: a number exists only because a pull read it from a named system.
governance/reporting.jsonPer channel (email · sms · blog · youtube_long · social · front_door):
rooms — the sub-lanes (email: campaign / always_on / _untagged; social: the nine rooms withtheir Blotato platform id and whether Blotato collects analytics for it; front door rooms are
dynamic — form id, event type, contact source, touch kind).
metrics — the STORED numbers: id · unit · agg (sum | last) · source · field (the exact APIcall / SQL the number comes from).
kpis — the RENDERED rows in the example's shape: id · label · definition · unit · source and either field (a stored metric) or derive ({num, den} for a rate, {sum: [...]} for a total),
optional scale (ms → hours). A KPI with source: null MUST say status: not_connected and what it
needs — the revenue rows are this: the shape is there, the number is not, and never will be until
the Chairman declares a revenue source (and decides the money-on-office-pages rule:
verify_no_public_money refuses any currency figure on an office page today).
by_type — the "by type" tables (email by lane · social by room · front door by form / eventtype / source / kind), each pointing at a declared metric or KPI.
columns — TY / LY / YoY % / LW / WoW % with their definitions. _week states the calendar: fiscal weeks (Sun → Sat) labeled by schedule.fiscal_label; TY = current fiscal week to date;
LW = the previous full week; LY = the same fiscal week 52 weeks earlier (before the anchor → "no
prior fiscal year").
sources — every pull / connector / table a KPI may name, with system, needs and a statuscarrying the last live verification.
unique(workspace_id, channel, room, metric, day)) — supabase/migrations/20260828_metrics.sql`,
owner-read RLS like the other tables. Written, not applied.
content/_generated/metrics.json — the same rows plus a pulls[source] stamp (pulled_at · rows · window · note), written by engine/lib/metrics.py::export_json(). The office
renders from the fold, never from an API or the database.
engine/lib/metrics.py — write_rows() (the one exit for every pull: validate against the registry → upsert metrics_daily when the Postgres env is set → fold), fiscal-week helpers
(current_week · week_bounds · week_label), weekly() (sum or last per week; **None when no row
— that is "no data yet", distinct from 0**), evaluate_kpi() (TY/LY/LW + YoY/WoW; from lets
youtube_long read the youtube room of social so no number is stored twice).
engine/stages/pull_metrics_*.py (each REFUSES, writing nothing, when not configured)| stage | reads | can get | cannot get (API fact) | state 2026-08-24 |
|---|---|---|---|---|
pull_metrics_crm | our Postgres: inquiries · bookings · crm_contacts · crm_touches | inquiries by form, bookings / cancellations by event type, new contacts and contacts-with-receipt by source, touches by kind, SMS opt-ins (consent.channel = sms) — counts only, no PII | — | Postgres env unset → prints the SQL, exit 2 |
pull_metrics_resend | Resend REST | sent per day (GET /emails), lane from tags (GET /emails/{id}, one call each), delivered / opens / clicks / bounced / complained as lower bounds from last_event; subscribers + new contacts from GET /audiences/{id}/contacts (needs RESEND_AUDIENCE_ID); exact opens / clicks / unsubs when the webhook log exists | Resend exposes no aggregate counts by REST (list-emails and get-broadcast carry none; verified in the docs) — exact engagement is webhook-only | key is send-only: GET /emails → 403 (live) → refuses |
pull_metrics_blotato | Blotato REST GET /posts?status=published + GET /posts/{id}/analytics | posts published per room per day (all nine); views · reach · likes · comments · shares · saves · clicks · follows · profile visits · watch time (ms) — a post's latest snapshot attributed to its publish day | LinkedIn analytics ("not available yet"); posts not published through Blotato; per-day deltas (snapshots are lifetime-to-date) | subscription expired: GET /posts → 403 subscriptionStatus: canceled (live) → refuses |
pull_metrics_ga4 | the office's Google client (reused, not duplicated) | blog sessions / users / page views (pagePath begins /blog), Search Console clicks / impressions to blog pages | anything without a valid token | token expired: invalid_grant (live) → refuses |
metrics_twilio (existing connector) | Twilio | sent / delivered / spend | clicks (Twilio does not count them), CVR, ROAS | no TWILIO_* in .env |
apps/office/render_reports.py/reports/email · /sms · /blog · /youtube_long · /social · /front_door, each: a Weekly KPIs
table (KPI · TY · LY · YoY % · LW · WoW %), the by type tables, and a Sources footer that
names every pull the page used with its last-pull stamp and what it still needs. Every numeric cell
carries data-src="<source>"; an empty cell reads "no data yet — source: <pull>" (or "no prior
year — source: …"), never 0; a not-connected KPI reads "not connected — <what it needs>". The hub
gained a Channels table (source · last pull) and the Reports sub-nav is grouped Sites / Channels.
The GA4 / GSC hub and per-site pages are unchanged.
engine/gates/verify_reporting.py → REPORTING PASS | FAIL (n) exists; a KPI without a source is explicitly not_connected with a definition; fields / derive
parts / by_type targets / from all resolve;
(channel, metric) rows from declared sources;<td> has a declared data-src, the footer names everysource that fed a number, and no currency figure is rendered.
Not yet registered in engine/goal.py GATES (outside this change's file list).
python -m engine.stages.pull_metrics_crm # needs the Postgres env (else prints its SQL)
python -m engine.stages.pull_metrics_resend # needs a READ-capable Resend key (+ RESEND_AUDIENCE_ID)
python -m engine.stages.pull_metrics_blotato # needs an active Blotato subscription
python -m engine.stages.pull_metrics_ga4 # needs a valid Google token
python apps/office/render_reports.py # bakes hub + 2 site pages + 6 channel pages
python engine/gates/verify_reporting.py