feat: BI dashboard — signup-anchored cohorts, OS/agency dims, summary + delivery tables#6
feat: BI dashboard — signup-anchored cohorts, OS/agency dims, summary + delivery tables#6mtmtian wants to merge 12 commits into
Conversation
WHY: docs/growth/00 §7.3 disciplines existed only as pure functions in
src/lib/growth/{pilot-gates,budget-guard}.ts — zero act-time enforcement.
A $5K pilot could overspend and SKAN-immature campaigns could be
auto-adjusted; executeApprovedDecision also skipped all guardrails.
WHAT: register pilot_budget_cap (fail-closed, activates via pilotStartDate
config), skan_maturity (fail-closed, <72h reject / learning-phase warn),
tier_cac_ceiling (fail-open, increase-only) in the evaluator registry +
schemas; re-evaluate guardrails at execution time inside
executeApprovedDecision (all five caller routes inherit), with rollback
exemption via existing DecisionStep.rollbackOf. Tests for boundaries,
fail-closed paths, and the bypass fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY: perceive() attached GrowthSnapshot to the snapshot but plan.v1.md had no growth placeholder — funnel/gate/budget signals were computed then dropped before reaching the LLM. WHAT: add GROWTH_JSON block to plan.v1.md (after the cache-control marker) with explicit rules — gate=kill means reduce/stop only, budget non-ok forbids any spend-increasing action; render it in plan.ts; regression test pins the placeholder position relative to the cache split. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY: wiring an MMP into ConversionEvent alongside GA4 double-counts installs (source is part of the idempotency key, cohort aggregation is source-blind), splits channels (network names all fall through to organic), and zeroes retention/LTV (adid vs pseudo_id namespaces). WHAT: docs/growth/06-mmp-ingest.md — the three holes, two canon decisions to make first, and an executable S2S-callback integration checklist; pointer comments on the legacy Report-only clients. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY: wiring Adjust into ConversionEvent next to GA4 double-counts installs (source is part of the idempotency key; cohort aggregation was source-blind), splits channels (MMP network names fell through to organic), and breaks retention/LTV joins (adid vs pseudo_id namespaces). Decisions (docs/growth/06 §2): Adjust is the install/channel authority, GA4 supplies funnel-deep events only; userKey prefers a forwarded RC app_user_id, falling back to a non-joinable adjust: prefix. WHAT: mapAdjustCallback pure mapper + GET|POST /api/ingest/adjust (static-secret verifyBearer, token never persisted to raw, unmapped events acked not 5xx'd); ADJUST_NETWORK_MAP + resolveAdjustChannel; RawEvent.source + installAuthority filter on acquisition events only; resolveInstallAuthority with anti-zeroing fallback (legacy-creds-only orgs fall back to ga4 with a logged warning). 23 new tests incl. the dual-source no-double-count case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…reakdown WHY: the BI view needs registration-cohort metrics (signup anchor), an OS dimension, an agency dimension, and D0/D7 revenue windows — none existed. The previous install-authority filter also silently dropped backend signups once Adjust was wired (anchor model only held for non-MMP orgs). WHAT: cohort anchor = first signup (never authority-filtered), install fallback; channel/os attribution prefers the user's eligible install (MMP) over signup UTM. ConversionEvent/CohortSnapshot/Report gain os, Report/PlatformAccount gain agency, CohortSnapshot gains signups/revenueD0/revenueD7 (one additive migration). SOURCES gains first-class 'backend'. growth-sync injects spendByCohort (cac no longer always null; unmappable platform spend reported as unallocatedSpend). New GET /api/growth/summary (os×paid/organic, mature-window-gated retention, ROI/ARPU/ARPPU) and GET /api/reports/breakdown (date>os>platform>agency; funnel columns pending the attribution bridge). Fixes the D7 dilution bug in overview (immature cohorts no longer counted in the denominator). Canon recorded in docs/growth/06 §6. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tables WHAT: reusable DateRangePicker (presets + native inputs), generic DataTable (totals row, overflow scroll), FieldConfigBar (fixed base columns + checkbox/draggable optional columns persisted to localStorage), FilterBar multi-selects. Dashboard home becomes: date range + OS/source/platform/agency filters, OS×source summary table (retention gated to mature windows, revenue columns incl. D0/D7 windows), date>os>platform>agency delivery table (funnel columns render — until the attribution bridge lands), with the legacy stat cards and trend chart kept below. No new dependencies; totals row refuses to average rates without denominators. i18n en+zh. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🤖 Code review — Claude CodeVerdict: Request changes — one High correctness bug that undercuts this PR's central goal, plus one should-fix unbounded query.
SummaryAdds a signup-anchored cohort acquisition model (signup anchors the cohort day; install attribution wins for channel/os), new Findings
No multi-tenant issues found — every new/changed route ( Test coverageStrong Vitest unit coverage ships with the logic ( Strengths
Independent automated review. Findings are advisory — use judgment; verify the High item against your intended D-day semantics. |
…success WHY (review oratis#4): sync writes both an account-level row and per-campaign rows for the same spend, so the unfiltered sum double-counted and tripped the $5K cap at ~half real spend. The execution-time guardrail re-check was also discarded on the success path, silently dropping warn-level signals from the audit trail. WHAT: pilot_budget_cap filters level:'account' (the one-per-platform- per-day contract row); success-path decisionStep.update persists guardrailReport. Regression tests for both. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…it secret WHY (review oratis#5): install authority keyed off the legacy Report-API credential, so a recommended S2S-only org kept GA4 authority and its Adjust installs were silently excluded; mapAdjustCallback was always called with an empty event-token map, dropping every non-install event; the route shared the events-route HMAC secret while transmitting it in cleartext query strings. WHAT: resolveInstallAuthority prefers adjust when live source='adjust' installs exist OR the credential is configured; the route reads a distinct PlatformAuth(platform='ingest_adjust') slot (env fallback INGEST_ADJUST_SECRET) whose extra JSON supplies eventTokenMap; the ?token strip now happens AFTER the POST form merge so a form field named token can't smuggle the secret into raw. Docs + .env.example updated; S2S-only regression test added. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY: the previous commit typed the parsed map as Record<string,string>, which doesn't satisfy AdjustEventTokenMap (values must be canonical EventNames) — tsc error; and an unvalidated config value could have produced rows with non-canonical event names. WHAT: filter extra.eventTokenMap entries through the EVENTS closed set at parse time; typo'd entries degrade to token-unmapped (dropped), never a bad row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… window WHY (review oratis#6): isMatureForRetentionWindow marked a cohort mature at the first instant of day N, but retention counts events across the whole of day N (dayDiff === N) — cohorts exactly N days old entered the D1/D7 rate before that day's events could arrive, reintroducing the dilution this gate exists to fix; the test asserted the buggy boundary. Separately /api/reports/breakdown scanned every org Report row when start/end were omitted. WHAT: matureAt = cohortStart + (days + 1) days; boundary tests now assert immaturity throughout day N and maturity from day N+1. breakdown defaults to a rolling 60-day window when start is omitted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…atis#7) WHY (review oratis#7): aggregateBreakdownRows divided all-days spend by joined-days-only signups, silently inflating costPerSignup exactly in the funnelJoin:'partial' state this PR introduced; agency values from resolveReportAgency kept their original case while parseCampaignName lowercases, so mixed-case agencies ('GroupM') could never exact-match the funnel join — a permanent silent miss, not a pending state. WHAT: accumulate joinedSpend alongside signups and divide those twins; lowercase agency in resolveReportAgency and the backend ingest path; mixed joined/unjoined regression test (the exact gap the review named); cohort-side breakdown query now shares the report query's effective window; aggregateCohortWindow fixtures updated to the corrected maturity boundary (merged from oratis#6) plus an in-progress-day case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…atis#7) WHY (review oratis#7): aggregateBreakdownRows divided all-days spend by joined-days-only signups, silently inflating costPerSignup exactly in the funnelJoin:'partial' state this PR introduced; agency values from resolveReportAgency kept their original case while parseCampaignName lowercases, so mixed-case agencies ('GroupM') could never exact-match the funnel join — a permanent silent miss, not a pending state. WHAT: accumulate joinedSpend alongside signups and divide those twins; lowercase agency in resolveReportAgency and the backend ingest path; mixed joined/unjoined regression test (the exact gap the review named); cohort-side breakdown query now shares the report query's effective window; aggregateCohortWindow fixtures updated to the corrected maturity boundary (merged from oratis#6) plus an in-progress-day case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
✅ Changes applied (fixup pushed)
Re the [Low] Commit |
Summary
Rebuilds the dashboard home as a BI view (date range + filters + field configurator + two tables) on top of a corrected registration-cohort canon. Canon decisions recorded in
docs/growth/06-mmp-ingest.md§6.Canon (backend commit)
oson ConversionEvent/CohortSnapshot/Report;agencyon Report/PlatformAccount;signups/revenueD0/revenueD7on CohortSnapshot (one additive migration, hand-authored — no shadow DB available;prisma validate+generatepass).SOURCESgains first-classbackend.growth-syncnow injects spend per cohort×channel (CAC no longer permanently null); unmappable platform spend is reported asunallocatedSpend, never guessed./api/growth/overview(immature cohorts inflated the denominator).GET /api/growth/summary(OS×paid/organic; spend, signups, cost/signup, D1/D7, D0/D7 ROI, pay rate, 7d ARPU/ARPPU, trial→paid approx, revenue windows) andGET /api/reports/breakdown(date>OS>platform>agency; impressions/clicks/spend/CPC — funnel columns returnfunnelJoin: 'pending').UI (frontend commit)
DateRangePicker, genericDataTable(totals row),FieldConfigBar(fixed base columns + checkbox/drag-order optional columns, localStorage-persisted),FilterBarmulti-selects. No new dependencies (native HTML5 drag).Known limits (deliberate, documented)
*_ioscohort rows can show null CAC while the*_websibling absorbs the platform's spend, until the bridge lands.revenueToDatecolumn: for ranges ending today it equals in-range revenue; historical ranges include post-range revenue (tooltip says so).Test plan
npx vitest run— 351/351 across 29 files (53 new: anchor/authority interaction, revenue window boundaries, os grouping, mature-window gating, aggregation/format/field-config pure functions)npx tsc --noEmitclean ·npm run lint0 errors (35 pre-existing warnings) ·npm run buildpassesprisma validatepasses🤖 Generated with Claude Code