Back Office · office.temerarii.xyz
REPORTING-SPEC.md

← all docs

Reporting System — Cross-Channel Performance Spec

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.


The reference design (what "track metrics across channels" means)

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.**

  1. Website KPIs — Conversion Rate · AOV · Traffic · Revenue/Session, each big-number + YoY delta.
  2. Traffic Channel Breakdown YoY — table: every channel (Paid · Email · SMS · Direct · Tapcart

[their e-comm app] · Organic Search · Social · Unclassified) × **Sessions TY/LY/YoY% AND

Revenue TY/LY/YoY%**, with a Total row.

  1. Site Speed — YoY + WoW big numbers + a daily grouped bar (this week / 4 weeks ago / 1 year ago).
  2. Email Performance — Recipients · Subscribers · Collected · Open Rate · Click Rate · Unsub Rate

· Last-Touch Revenue · Revenue-per-Send, each TY/LY/YoY% and LW/WoW%; + **Email Revenue by

Type** (Campaign vs Flow).

  1. SMS Performance — New Subs · Total Subs · Last-Touch Revenue · CTR · CVR · **Platform ROAS ·

Spend**, TW/LY/YoY%.

  1. Social (platform-native) — per-platform visits/profile-visits (Facebook, Instagram, …) as a

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.


Architecture — the unified metrics layer

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.

1. The snapshot store — 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.

2. Connectors — engine/integrations/metrics/<name>.py, each gated, each graceful-empty

ConnectorSourceMetricsStatus / gate
ga4.pyGA4 Data API (token live)sessions, users, channelGroup, hostName, pagePath, conversions+revenue (if events configured), CWV✅ token — wire conversions/revenue once GA4 events exist
gsc.pySearch Console (token live)clicks, impressions, CTR, position, top query/page✅ token
resend.pyResend API (key present)sent, delivered, open, click, unsub, bounce → open/click rate, rev-per-send (via UTM join)key present, pull unwired
twilio.pyTwilio APIsent, delivered, link-clicks → CTR/CVR/ROAS/spendneeds creds
stripe.pyStripe MCP/APIrevenue, AOV (deal value), new customers, MRRStripe MCP available — the money layer
calcom.pyCal.com APIbookings/consults (the services "conversion")needs key
social.pyMeta Graph (FB/IG), YouTube Analytics, LinkedIn, X, TikTok, Pinterest, Bluesky — OR Blotato analytics if exposedimpressions, views, engagement, follows, profile-visitsbiggest gap — none wired; check Blotato analytics read first
pagespeed.pyPageSpeed Insights / CrUX (free, no key)site speed / Core Web Vitals, dailyfree — 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).

3. The comparison engine — engine/lib/metrics_report.py

Pure 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.

4. The UTM prerequisite (BLOCKS the revenue half)

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).

5. The funnel rollup — funnel.yaml (Reach → Capture → Convert)

Map each metric into a stage so the report answers "did the calendar move the needle," end-to-end:

  • Reach = social impressions/views + YouTube views + GSC impressions.
  • Capture = GA4 sessions + email signups + content downloads + follows.
  • Convert = Cal.com bookings + Stripe revenue + (CRM) pipeline.

Each stage rolls per-campaign + per-week, joined on UTM — the scorecard, not a traffic mirror.

6. Targets — gtm.yaml per-quarter kpi_emphasis

Render 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.


Office surfaces

  • /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 subdomain

transparency · top pages · GSC queries/pages · site speed).

  • (as connectors land) per-channel deep sections: /reports/email, /reports/sms, /reports/social

— each mirroring the reference's dedicated section.

Build order

  1. v1 (in flight): GA4 + GSC dashboards, hub + 2 sub-pages, calendar correlation. ← background agent.
  2. Design pass (integration): adopt the reference visual language — delta-arrow KPI cards w/ YoY+WoW

(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.

  1. Snapshot store + comparison engine (metrics-snapshot.json + metrics_report.py).
  2. Free/keyed connectors now: pagespeed.py (free), stripe.py (MCP), resend.py (key) →

populate Site-Speed + revenue + Email sections.

  1. UTM-at-publish (prerequisite) → unlocks the revenue/channel join.
  2. Social + Twilio + Cal.com connectors (keys/auth) → the remaining reference sections.
  3. Funnel rollup + targets → the scorecard.

Verification

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.


Channel pages (2026-08-24) — /reports/<channel> in the reference shape, from data

The 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.

The registry — governance/reporting.json

Per channel (email · sms · blog · youtube_long · social · front_door):

  • rooms — the sub-lanes (email: campaign / always_on / _untagged; social: the nine rooms with

their 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 API

call / 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 / event

type / 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 status

carrying the last live verification.

The metrics layer

  • Postgres `metrics_daily(workspace_id, channel, room, metric, day, value, source, pulled_at,

unique(workspace_id, channel, room, metric, day)) — supabase/migrations/20260828_metrics.sql`,

owner-read RLS like the other tables. Written, not applied.

  • The fold 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).

The pulls — engine/stages/pull_metrics_*.py (each REFUSES, writing nothing, when not configured)

stagereadscan getcannot get (API fact)state 2026-08-24
pull_metrics_crmour Postgres: inquiries · bookings · crm_contacts · crm_touchesinquiries 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_resendResend RESTsent 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 existsResend exposes no aggregate counts by REST (list-emails and get-broadcast carry none; verified in the docs) — exact engagement is webhook-onlykey is send-only: GET /emails → 403 (live) → refuses
pull_metrics_blotatoBlotato REST GET /posts?status=published + GET /posts/{id}/analyticsposts 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 dayLinkedIn 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_ga4the office's Google client (reused, not duplicated)blog sessions / users / page views (pagePath begins /blog), Search Console clicks / impressions to blog pagesanything without a valid tokentoken expired: invalid_grant (live) → refuses
metrics_twilio (existing connector)Twiliosent / delivered / spendclicks (Twilio does not count them), CVR, ROASno TWILIO_* in .env

The pages — 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.

The gate — engine/gates/verify_reporting.py → REPORTING PASS | FAIL (n)

  1. every metric / KPI names a declared source whose file (pull, connector) or migration (table)

exists; a KPI without a source is explicitly not_connected with a definition; fields / derive

parts / by_type targets / from all resolve;

  1. the fold (when present) carries only declared (channel, metric) rows from declared sources;
  2. the rendered channel pages: every numeric <td> has a declared data-src, the footer names every

source that fed a number, and no currency figure is rendered.

Not yet registered in engine/goal.py GATES (outside this change's file list).

Run order

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