# Analytics scope — work-package 1: the five questions

> Authored by the orchestrator (Fable), 2026-07-20. **Authority:**
> `plans/rethink-target-picture.md` §8 (the five questions, verbatim charter:
> "Nothing else") + round-16 locks (`docs/DECISIONS.md` 2026-07-18) + the
> operator's genuine-states law. **Mock lineage:** `design/tiles/round-16/`
> (tiles a/b/c, reacted) — its README carries the honest per-question
> data-availability table; trust it. The gate judges against THIS document.

## Design locks (all ruled)

1. **Exactly five headed blocks, question-first** — the block title IS the
   question in plain language (tile-a). Anything on the current Analytics
   page not answering one of the five is DELETED, not moved.
2. **Live questions first, empty ones sink** (round-16 lock 2): today that
   means Agent outcomes, Cost, Volume above Campaigns and Delivery health
   (both empty while the WhatsApp number is Meta-restricted).
3. **Honest empty/thin states, COMPUTED from live system state — never
   canned strings.** Operator verbatim: "do not hardcode excuses or errors —
   make it genuine." The restriction is read from connection/health state,
   counts are actually counted, the label-store age is derived from the
   data's own timestamps. This is a GATE BAR, not styling.
4. **Channel + range control strip** at the top re-scopes every block
   together; URL-backed (house pattern from Activity filters).
5. **Drill depth:** Question 2 drills onto its own screen per tile-b
   (automation-rate trend, per-situation split, silence reasons, labels
   strip marked thin with a derived count). Other questions: landing-only v1.
6. **Style law:** `--cm-*` tokens; no pills; icons never emojis; no AI-isms;
   phone-friendly per tile-c (375 stacks to full-width cards, tags stay).

## Standing rules

TDD red-first; per-package capped-fork suites (`npx vitest run --pool=forks
--poolOptions.forks.maxForks=2`); `corepack pnpm` (pnpm not on PATH);
additive migrations only; Zod at boundaries; data over hardcoded; no gate
flips; no LLM spend; pathspec commits, pull --rebase, push per verified
stage; deploy/publish is the orchestrator's lane.

## Stages

### Stage 0 — deletion inventory
Inventory every element of the current Analytics page; map each to one of
the five questions or to the deletion list. Recorded in the stage log —
nothing dropped silently.

### Stage 1 — aggregation endpoints (api)
`GET /api/analytics/*` per question (shape is the executor's call — one
endpoint with sections or per-question; keep it boring). Computed from the
real tables: turn traces (cost, automation split, silence reasons),
`ig_comments` (+`channel` incl. facebook), `moderation_actions`,
`decision_labels`, `social_posts`, messages/conversations, connection/health
state for the restriction read. Every block's payload carries availability
metadata: `live | thin | empty` + a COMPUTED reason (lock 3). TDD.

### Stage 2 — the screen (web)
Question-first landing per tile-a; control strip; honest tags; Q2 drill per
tile-b; 375 behaviour per tile-c. The old dump dies per the Stage-0 list
(redirect/route kept at `/analytics`).

### Stage 3 — evidence
Screenshots at 1440 + 375 against the reacted tiles, saved under
`plans/rethink/evidence/analytics-scope-1-*`.

## Gate bar

1. Suites green per package (capped forks), typecheck/lint/build exit 0;
   additive migrations (if any) apply cleanly.
2. Exactly five blocks, question-first; Stage-0 deletion inventory recorded.
3. **Genuine-states proof:** tests pin that empty/thin copy is computed
   (e.g. the WhatsApp-restricted reason comes from connection/health state;
   the label age/count is derived); no canned excuse strings in source
   (grep at gate).
4. Live e2e (orchestrator): endpoint counts match direct DB queries.
5. Screenshots match the tiles; style law holds; 375 clean.
6. Every deferral written to the board; HANDOFF + board current.

## Out of scope (recorded)

- Campaigns / Delivery-health real data (number restricted — the computed
  empty states say so genuinely; they fill when sends resume).
- Lead-attribution engine improvements (Q2/Q5 "thin" is the honest state).
- Per-question drills beyond Q2.

## Stage log

*(executor appends; gate verdict recorded here)*

### Stage 0 — deletion inventory (executor, 2026-07-20)

Every element of the current Analytics surface (`apps/web/src/routes/Analytics.tsx`
+ `apps/api/src/analytics/*` + `apps/web/src/lib/analytics-api.ts`) mapped to one
of the five questions or to DELETE. Nothing dropped silently.

**Web page sections (`Analytics.tsx`) — the whole dump is replaced by the
five-question landing:**

| Current section | Maps to | Disposition |
|---|---|---|
| PageHeader "Analytics & quality" + "Refresh from Meta" btn + "Export CSV" btn | — | REPLACED by question-first header; both buttons DELETED (not one of the five) |
| Time-range filter bar (from/to + presets) | control strip | REPLACED by channel+range URL-backed control strip |
| Account health (current quality snapshot) | Q4 Delivery health | FOLDED into Q4 "Meta account state" (data kept, re-homed) |
| Messaging-limit tier trend (LineChart) | Q4 Delivery health | DELETED as standalone; tier value shown inside Q4 |
| Overview KPIs (sent/delivered/read/failed/rates) | Q1/Q4 | DELETED standalone (WhatsApp-send centric; sends=0 today) |
| Messages-over-time (TimeSeriesChart of sends) | Q1 Volume | DELETED; Q1 now = inbound comment volume |
| Conversation funnel | Q4-ish (sends) | DELETED (not one of the five as its own block) |
| Per-template performance table | Q3 Campaigns | DELETED from page (TemplateStatsPanel elsewhere keeps template stats) |
| Per-template quality table | Q4 | DELETED from page (quality module keeps it) |
| Per-segment delivery table | Q3 Campaigns | DELETED from page |
| Hour-of-day heatmap | — | DELETED outright (answers none of the five) |
| Conversation-cost estimate (WhatsApp conversation AED) | Q5 Cost | DELETED; Q5 now = LLM token spend from turn traces |
| Failure breakdown table | Q4 Delivery health | DELETED from page (folds into Q4, empty today) |
| Campaign performance (all-time) table | Q3 Campaigns | DELETED from page |

**API endpoints/service methods (`analytics.controller.ts` / `analytics.service.ts`).**
Verified consumers by grep across `apps/{api,worker,web}/src` + `packages`
(non-test). KEEP = still referenced off the Analytics page; DELETE = page-only,
now dead:

| Endpoint / method | Off-page consumer | Disposition |
|---|---|---|
| `GET /messages` · `messages()` | CampaignStatsPanel, CampaignDetail | KEEP |
| `GET /cost-rates` · `getCostRates()`/`loadCostRates()` | CostEstimate (compose) | KEEP |
| `GET /templates/:id` · `templateStats()` | TemplateStatsPanel | KEEP |
| `GET /templates/:id/meta` · `templateMetaAnalytics()` | TemplateStatsPanel | KEEP |
| `GET /campaigns/:id` · `campaign()` | CampaignDetail | KEEP |
| private `failures()` helper | used by `campaign()` + `templateStats()` | KEEP |
| `GET /failures` (standalone route) | Analytics page only | DELETE route (keep private helper) |
| `GET /campaigns` (list) · `campaigns()` | Analytics page only | DELETE |
| `GET /per-template` · `perTemplate()` | Analytics page only | DELETE |
| `GET /per-segment` · `perSegment()` | Analytics page only | DELETE |
| `GET /hour-of-day` · `hourOfDay()` | Analytics page only | DELETE |
| `GET /funnel` · `funnel()` + `countOutboundsThatGotAReply()` | Analytics page only | DELETE |
| `GET /cost-estimate` · `costEstimate()` | Analytics page only | DELETE |
| `GET /messages/export` | Analytics page only | DELETE |
| `FailuresQueryDto` (dto.ts) | only the /failures route | DELETE |

**Web client fns (`analytics-api.ts`) — DELETE (page-only, now dead):**
`getFailureBreakdown`, `getCampaignMetrics`, `getPerTemplate`, `getPerSegment`,
`getHourOfDay`, `getFunnel`, `getCostEstimate`. **KEEP:** `getMessageMetrics`,
`getCampaignDetail`, `getCostRates`, `getTemplateStats`, `getTemplateMetaAnalytics`
(all consumed by campaign/template panels).

**New surface (Stage 1/2):** one boring endpoint `GET /api/analytics/overview`
(`{from,to,channel}`) returning the five question payloads, each with
`availability: live|thin|empty` + a COMPUTED reason; the five-question landing +
Q2 drill consume it. Deletions land in Stage 2 (page rebuild); Stage 1 is
purely additive.

### Stage 1 — aggregation endpoint (executor, sha `7ffbe72`)

`GET /api/analytics/overview` — one `QuestionsService` computes all five §8
questions from live tables. Every block carries `availability` + a reason
computed from the values it just counted (genuine-states law):
- **Q1 Volume** — `ig_comments` grouped by channel (instagram/facebook) +
  whatsapp `messages` counts; daily inbound series + busiest day derived.
- **Q2 Agent outcomes** — automation rate = (turns − reviewed)/turns (pinned
  def, raw join `agent_turns⋈review_items`); a clean handled/queued/escalated
  partition (escalated ⊆ reviewed via `moderation_action='escalate'`); silence
  reasons from skipped-turn `decisionReason`; labels count + verdict split +
  `sinceIso` DERIVED from the earliest `decision_labels.createdAt`; per-situation
  drill (comment-reply / comment-moderation / dm). Leads honestly `null` (thin).
- **Q3 Campaigns** — empty unless a campaign broadcast; reason reads the live
  WhatsApp `phoneNumberStatus` from the latest `quality_snapshot`.
- **Q4 Delivery health** — failed/by-error-class from `messages`; `metaState`
  (quality/tier/status/restricted) read from the snapshot; `restricted` computed
  (status ≠ CONNECTED); a missing snapshot is "no reading", never a fake status.
- **Q5 Cost** — token spend per channel + per day + total from `agent_turns`
  cost fields; cost-per-lead honestly `null` (thin).

Zod `OverviewQueryDto` (channel enum, default all). Wired into
`AnalyticsModule`. TDD red-first; the genuine-states properties are pinned
(phoneNumberStatus flows into the Q4/Q3 reason; label age derived; counts flip
availability). **Oracle:** `questions.service.test.ts` 15/15 + full analytics
suite 39/39 (capped forks); `nest build` exit 0.

### Stage 2 — the screen + the deletions (executor, shas `79232ee`+`ab88148`)

`Analytics.tsx` rebuilt as the five question-first blocks (tile-a) on `--cm-*`
tokens (`analytics.css`), URL-backed channel+range control strip
(`analytics-shared.tsx`), squared honest tags, live SVG charts, computed
empty/thin reasons surfaced verbatim from the payload. Q2 drills to
`/analytics/agent-outcomes` (`AnalyticsAgentOutcomes.tsx`, tile-b). 375 stacks
per tile-c. `analytics-api.ts` cut over to `getAnalyticsOverview`.

**Deletions executed per the Stage-0 list:** the old dump (sends KPIs, messages-
over-time, funnel, per-template, per-template-quality, per-segment, hour-of-day,
conversation-cost, failure table, campaign table, account-health/tier sections)
and the now-dead API endpoints (`/failures` route, `/campaigns` list,
`/per-template`, `/per-segment`, `/hour-of-day`, `/funnel`, `/cost-estimate`,
`/messages/export`) + service methods + `FailuresQueryDto` + the page-only web
client fns. Kept (still consumed off-page): `messages`, `cost-rates`,
`templates/:id[/meta]`, `campaigns/:id` + the private `failures()` helper.

**Oracle:** web `Analytics.test.tsx` 5/5 + `analytics-api.test.ts` 3/3; all
affected consumer suites (campaigns + templates panels, CampaignDetail) 113/113;
api analytics 32/32; web+api typecheck exit 0; web lint 0 errors; api+web builds
exit 0. **Multi-seat note:** a concurrent shared-index write split the commit
(3 new files in `79232ee`, the 9 modified in `ab88148`); both pushed, origin
consistent + building. Later commits used `git commit <pathspec>` to dodge the
staging-window race.

### Stage 3 — evidence (executor)

Faithful static harness (the real `analytics.css` + tokens + exact component
markup, genuine-state data: live Q1/Q2/Q5, empty Q3/Q4 reading a RESTRICTED
snapshot, thin labels since 2026-07-18) screenshotted:
- `plans/rethink/evidence/analytics-scope-1-landing-1440.png` — five blocks,
  matches tile-a.
- `plans/rethink/evidence/analytics-scope-1-landing-375.png` — full-width cards,
  row-splits stack, tags stay — matches tile-c.
- `plans/rethink/evidence/analytics-scope-1-drill-1440.png` — Q2 drill, matches
  tile-b (automation headline, per-situation table, silence reasons, thin labels).

**NOT verified by the executor (orchestrator's lane):** live e2e against real DB
(no local Postgres) — endpoint counts vs direct DB queries, NestJS DI boot +
health, web publish. The screenshots are exact-markup renders, not live data.
