Skip to content

feat: BI dashboard — signup-anchored cohorts, OS/agency dims, summary + delivery tables#6

Open
mtmtian wants to merge 12 commits into
oratis:mainfrom
mtmtian:feat/dashboard-bi
Open

feat: BI dashboard — signup-anchored cohorts, OS/agency dims, summary + delivery tables#6
mtmtian wants to merge 12 commits into
oratis:mainfrom
mtmtian:feat/dashboard-bi

Conversation

@mtmtian

@mtmtian mtmtian commented Jul 7, 2026

Copy link
Copy Markdown

Stacked on #5 (which stacks on #4) — merge in order. The dashboard-specific diff is the last two commits.

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)

  • Anchor = first signup (any source, never filtered by install-authority — the previous filter silently dropped backend signups once Adjust was wired). Users without a signup fall back to their first authority-filtered install. Attribution still prefers the install event (MMP channel/os beats signup UTM) via userKey join — anchor belongs to the backend, attribution belongs to the MMP.
  • New dims/fields: os on ConversionEvent/CohortSnapshot/Report; agency on Report/PlatformAccount; signups/revenueD0/revenueD7 on CohortSnapshot (one additive migration, hand-authored — no shadow DB available; prisma validate + generate pass). SOURCES gains first-class backend.
  • growth-sync now injects spend per cohort×channel (CAC no longer permanently null); unmappable platform spend is reported as unallocatedSpend, never guessed.
  • Retention rates gate on mature windows only — also fixes the pre-existing D7 dilution bug in /api/growth/overview (immature cohorts inflated the denominator).
  • New APIs: 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) and GET /api/reports/breakdown (date>OS>platform>agency; impressions/clicks/spend/CPC — funnel columns return funnelJoin: 'pending').

UI (frontend commit)

  • Reusable DateRangePicker, generic DataTable (totals row), FieldConfigBar (fixed base columns + checkbox/drag-order optional columns, localStorage-persisted), FilterBar multi-selects. No new dependencies (native HTML5 drag).
  • Summary table refuses to fabricate blended rates in the totals row (renders — where denominators are missing); delivery table's funnel columns render — with a "attribution join pending" header tooltip.
  • Legacy stat cards + trend chart kept below the new tables. i18n en+zh.

Known limits (deliberate, documented)

  • Funnel↔delivery bridge (registration/retention/ROI split by platform×agency) is out of scope — needs campaign-level channel mapping; columns are visibly pending, not wrong.
  • Platform→channel spend mapping is conservative (google/meta/tiktok → one channel each); Adjust-attributed *_ios cohort rows can show null CAC while the *_web sibling absorbs the platform's spend, until the bridge lands.
  • revenueToDate column: for ranges ending today it equals in-range revenue; historical ranges include post-range revenue (tooltip says so).
  • Agency stamping covers the appsflyer/adjust/amazon/linkedin sync path; the google/meta/tiktok adapter writer path is a follow-up.

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 --noEmit clean · npm run lint 0 errors (35 pre-existing warnings) · npm run build passes
  • Migration is additive-only (ALTER ADD + unique index rebuild); prisma validate passes

🤖 Generated with Claude Code

mtmtian and others added 6 commits July 7, 2026 16:35
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>
@oratis

oratis commented Jul 8, 2026

Copy link
Copy Markdown
Owner

🤖 Code review — Claude Code

Verdict: Request changes — one High correctness bug that undercuts this PR's central goal, plus one should-fix unbounded query.

Scope note: #4#5#6#7 are a stacked series all targeting main, so this PR's diff-vs-main also contains #4/#5. This review covers only the incremental delta #6 adds over #5; #4/#5 are reviewed on their own PRs. Because the branches are stacked but based on main, please merge strictly in order (or retarget bases) — merging #6 before #5/#4 would pull them in implicitly.

Summary

Adds a signup-anchored cohort acquisition model (signup anchors the cohort day; install attribution wins for channel/os), new os/agency/signups/revenueD0/revenueD7 columns, two read routes (growth/summary, reports/breakdown), and a reworked /dashboard BI UI backed by a new pure-function lib (dashboard-bi.ts). Migration is additive-only and matches the schema delta.

Findings

  • [High] src/lib/growth/kpi-canon.ts:154maturity gate is off by one day. matureAt = cohortStart + days * 86_400_000 marks a cohort mature at the very first instant of calendar day N. But retention is counted via dayDiff(...) === N in cohorts.ts, which spans the full 24h of day N — so a cohort isn't final until now >= cohortStart + (days+1)*86_400_000. As written, cohorts exactly N days old are folded into the D1/D7 numerator+denominator the instant day N begins, before that day's events can arrive — reintroducing, for a one-day-wide band, the exact "D7 dilution" this PR's own doc (docs/growth/06-mmp-ingest.md §6) claims to fix. It propagates into all three read routes meant to be the fix: growth/overview/route.ts:50,54, growth/channels/route.ts:40-41, growth/summary/route.ts:139,143. The unit test kpi-canon.test.ts:133 asserts true at exactly 2026-07-08T00:00:00Z for a 7-day window — i.e. it encodes the bug rather than catching it. → matureAt = cohortStart + (days + 1) * 86_400_000, and fix the test's boundary expectation.

  • [Medium] src/app/api/reports/breakdown/route.ts:46-55report.findMany has no take/pagination and both start/end are optional, so a request omitting the range scans and returns every Report row the org has. Sibling routes in this PR window their queries; this one doesn't. The UI always sends a 30d default so it's not triggered today, but there's no server-side guard. → Add a default/max window (mirror growth-sync's WINDOW_DAYS) or a take cap.

  • [Low] src/lib/growth/cohorts.ts (revenueD0, in the for (const e of userEvents) loop) — uses dayDiff <= 0 not === 0, so a subscription event timestamped before the anchor day counts as D0 revenue. Matches the doc/spec so it's intentional, but only reachable via clock-skew/backdated replay — worth confirming it's truly desired before it inflates D0 ROI in that case.

  • [Nit] src/lib/dashboard-bi.ts:33-38 (quickRangeDates) — today = new Date() (local) then .toISOString() (UTC); for viewers west of UTC late in their day the "Last 7/14/30 days" picker can shift by a day. Consistent with the repo's UTC-day convention, just a known edge.

No multi-tenant issues found — every new/changed route (growth/summary, reports/breakdown, overview, channels, cohorts, cron/growth-sync) scopes by org.id from requireAuthWithOrg(); the cron iterates all orgs but scopes each per-org query. No raw SQL anywhere. Migration 20260707120000_bi_breakdown matches schema.prisma (incl. the widened CohortSnapshot unique index) and is staged.

Test coverage

Strong Vitest unit coverage ships with the logic (kpi-canon, cohorts, new dashboard-bi, plus ingest-parse updates for os) — good discipline, but note the High finding is a case where the test asserts the buggy boundary. Gap: no e2e/*.spec.ts for the new /dashboard tables or the two new routes — worth a smoke-level Playwright case per the repo's soft convention.

Strengths

  • Doc-comments cross-reference docs/growth/06-mmp-ingest.md §6 for every non-obvious decision — easy to audit intent vs. implementation.
  • Disciplined null/zero handling; summarizeSummaryRows deliberately refuses to average un-weightable rate columns rather than fabricate a number.
  • Consistent, correct org-scoping on every route with no exceptions.

Independent automated review. Findings are advisory — use judgment; verify the High item against your intended D-day semantics.

mtmtian and others added 6 commits July 9, 2026 12:16
…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>
mtmtian added a commit to mtmtian/adex that referenced this pull request Jul 9, 2026
…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>
mtmtian added a commit to mtmtian/adex that referenced this pull request Jul 9, 2026
…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>
@mtmtian

mtmtian commented Jul 9, 2026

Copy link
Copy Markdown
Author

✅ Changes applied (fixup pushed)

  • [High] maturity gate off by one dayisMatureForRetentionWindow now uses matureAt = cohortStart + (days + 1) * 86_400_000. Since retention counts engagement across the full 24h of day N (dayDiff === N), a cohort is only final once day N has ended (UTC midnight of cohortDate+N+1), not at day N's first instant. This closes the one-day-wide dilution band the previous boundary reintroduced. The test that encoded the buggy boundary (kpi-canon.test.ts:133) is rewritten: it now asserts immature throughout day N (00:00 and 23:59:59) and mature from day N+1. Propagates correctly to overview/channels/summary.
  • [Medium] unbounded breakdown query/api/reports/breakdown now defaults to a rolling 60-day window (mirroring growth-sync's WINDOW_DAYS) when start is omitted, on both the Report and CohortSnapshot sides, so a range-less request can't scan every org Report row.

Re the [Low] revenueD0 <= 0 and [Nit] local-vs-UTC picker edge — both are intentional and match the documented UTC-day convention; left as-is with the existing doc notes.

Commit 7faaf29. Re-verified: 389/389 vitest, tsc clean, lint clean, build passes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants