feat(admin): the Growth panel — arrival, activation funnel, plan mix, campaign (PR1) - #216
Conversation
… campaign (PR1)
A new operator page at /admin/growth answering "what is happening with
Parachute users" in counts, cohorts, and buckets. PR1 reads EXISTING tables
only — zero migrations, no new collection — so the free-3-months push is
measurable from the hour the announcement goes out.
Four cards:
- Arrival — signups 7d/30d + a week-over-week delta, and a 12-week
signups sparkline (inline SVG polyline, no JavaScript).
- Activation funnel — five stages over two cohorts (0–14d and 14–44d),
so a week-2 number is only ever read off accounts old enough to have
one; the young cohort's week-2 stage renders grayed "too young".
- Plan mix + trial pipeline — accounts per plan, trials ending within
7 days, subscribers vs comps, scheduled churn.
- Campaign — promo redemptions per code per UTC day.
Two rules the code enforces rather than states:
PRIVACY. Every query on this page returns counts only — no email, no user
id, no vault name, not even transiently. Per-account rows stay in
/admin/users and /admin/vaults, where comp and suspend actually need a
person. Pinned by a real negative test: given seeded users whose addresses
ARE renderable (the positive control finds all three on /admin/users), the
Growth page's HTML matches no email-shaped string at all.
HONESTY. Several numbers are proxies over data collected for other
reasons, so each renders beside a "how measured" line naming its blind
spot: byte-growth misses a day-0 write that vanished before the first
rollup; 90-day sliding sessions and long MCP refresh cycles mean the
week-2 return signal UNDER-counts; and no acquisition-source data exists
anywhere in the schema, so the page can say WHEN someone arrived and never
WHERE FROM.
Two places the design met the real schema and the schema won:
- "connected AI" reuses drip.ts's CONSOLE_CLIENT_ID and is the exact
inverse of the day-3 connect-nudge eligibility query, so the nudge and
the bar can never disagree about what connecting an AI means.
- "scheduled churn" EXCLUDES trials. pending_plan='expired' is the
deferred-downgrade flag, but every fresh signup is written with it —
it IS the 30-day trial clock (users.ts createUser) — so counting it
unqualified would just re-report the trial cohort.
Wiring: the gate reuses admin.ts's operatorFor + notFound (both now
exported) so the 404-not-403 posture cannot drift between admin pages;
ADMIN_STYLE + adminHeader are exported from admin-ui.ts and the nav gains
a Growth entry. /admin in CEREMONY_PREFIXES already covers the new path,
so route-manifest.ts is unchanged.
Version note: main is rc.122 and rc.123 is claimed by the in-flight email
redesign (#215), so this bumps to rc.124.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eir labels say
Reviewer found four places where a label promised more than the query
delivered. All four were "the metric doesn't mean what it says", which on
this page is the only bug class that matters — an operator acts on these
numbers.
1. `Paying subscribers` counted EVER-subscribed, not currently.
`stripe_subscription_id` is write-once: the sole writer is
billing-lifecycle.ts's checkout CAS, and cancellation records only
`pending_plan`/`plan_downgrade_at`, so a fully-churned account sits on
the expired floor still carrying its old id forever (promo.ts's header
documents exactly this — its redeem gate keys on the plan for the same
reason). The tile could therefore only ever ratchet up. Now ANDed
against the paid tiers (bound, not inlined), making it
currently-subscribed. Mid-cancellation still counts, correctly: they
are paid through the period. Pinned by a churned-subscriber test that
fails against the old query.
2. `Has content`'s checklist branch counts a CLICK, not a write.
console.ts's checklist door marks the item and redirects in one
gesture, so `write-note` means "opened the editor". The copy named
only under-count blind spots; it now names the over-count too. There
is no honest signal to AND against — the byte branch is the other half
of the union — so this is a copy fix, deliberately.
3. The byte-growth branch is blind to a vault's entire FIRST rollup, not
just to writes before any rollup ran. Growth is latest-minus-earliest
and the earliest row IS the baseline, so a day-0 import that lands
before the first cron is invisible forever, not until tomorrow.
4. The hardcoded "30-day trial clock" would have gone stale within the
week (30 → 90 in flight). Now interpolated from TRIAL_DURATION_DAYS —
and, since it is the identical failure mode on the same line, so are
the session and refresh TTLs, from SESSION_TTL_MS and
REFRESH_TOKEN_TTL_MS. Pinned by a test that derives from the
constants.
Also folded in:
- the privacy test now pins VAULT NAMES absent (they are user-chosen
free text and identify a person as well as an address does), with
/admin/vaults as the positive control proving they are renderable;
- promo.ts records `promo.code` from the row it just validated rather
than the request's input. Byte-equal today only via uppercasing plus
BINARY collation; since the ledger is the one stored string the
Growth page reads back out, its provenance is now structural.
Deliberately NOT here, per review: the tokens(user_id) index migration
(PR1 is zero-migration by design) and the scheduled-churn comp-expiry
label nuance.
Gates: identity vitest 1041 passed / 0 failed across 38 files
(admin-growth 16/16, promo 25/25, console 87/87); identity + root
typecheck clean; root bun test 167 pass / 0 fail. Still rc.124.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts: # package.json
Review fixes folded in —
|
| # | Fix | Where |
|---|---|---|
| 1 | Paying subscribers now ANDs the paid tiers (bound, not inlined) — was counting EVER-subscribed |
admin-growth.ts:299 (query), :271-290 (why), admin-growth-ui.ts:216 (copy) |
| 2 | Has content copy now names the checklist over-count (a row records a click, not a write) |
admin-growth-ui.ts:194, admin-growth.ts:41,160 |
| 3 | …and that the byte branch is blind to a vault's entire first rollup, permanently | admin-growth-ui.ts:194, admin-growth.ts:33 |
| 4 | Trial duration interpolated from TRIAL_DURATION_DAYS |
admin-growth-ui.ts:20,216 |
On #1 — confirmed the write-once claim exactly: the only writer is billing-lifecycle.ts:240, and promo.ts:36 documents "a CHURNED subscriber falls back to the expired floor keeping a stale stripe_subscription_id (the sweep never clears it)". Now pinned by a test that seeds a churned account and asserts it doesn't count — I mutation-checked it by reverting the query, and it fails as intended (expected … to contain 'paid plan + live subscription' with the count at 2). Mid-cancellation accounts still count, correctly: they're paid through the period.
On #2 — I looked for a real signal to AND against and there isn't one; the byte branch is the other half of the union, so ANDing would collapse the metric. Copy fix, deliberately, and the funnel test now pins both directions of the caveat.
On #4 — I extended the same treatment to the two other rendered duration literals on that card, since it's the identical failure mode one line away: session TTL now derives from SESSION_TTL_MS and the refresh cycle from REFRESH_TOKEN_TTL_MS. A test derives its expectations from all three constants, so ag-trial-90-days landing will move this page automatically rather than silently falsifying it.
Both cheap ones done. The privacy test now pins vault names absent (user-chosen free text identifies a person as well as an address does) with /admin/vaults as the positive control proving they're renderable. promo.ts:146 was a genuine one-liner — it now binds promo.code from the validated row, so the one stored string the Growth page reads back out has structural rather than incidental provenance.
Not done, per your instruction: the tokens(user_id) index migration (PR1 stays zero-migration) and the scheduled-churn comp-expiry label nuance.
Rebase note
#215 merged as rc.123 while I was working, exactly as this PR predicted, so rc.124 stands — no renumber needed. I merged origin/main in rather than rebasing, so the SHAs you already verified (316d9d5, 5e977a8) remain intact and citable. The only conflict was the package.json version line. Re-verified CONSOLE_CLIENT_ID is still exported from the redesigned drip.ts — the funnel's "connected AI" inverse still binds to it.
Gates (post-merge, on 91cf287)
- identity vitest: 1041 passed, 0 failed across 38 files — admin-growth 16/16, admin 19/19, drip 24/24, promo 25/25, console 87/87, route-manifest 83/83
- identity + root typecheck: clean · root
bun test src test-bun: 167 pass, 0 fail - GitHub CI on the merged head: all three jobs pass · PR is
MERGEABLE/CLEAN
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
A new operator page at
/admin/growthanswering "what is happening with Parachute users — how many people are using it, how are they using it" in counts, cohorts, and buckets. PR1 reads EXISTING tables only — zero migrations, no new collection — so the free-3-months push is measurable from the hour Monday's announcement goes out.What lands
users.created_at<polyline>, no JavaScript)vaults,user_checklist,vault_usage,tokens,grants,sessionsuserspromo_redemptions(migration 0016)Two cohorts exist so a week-2 number is only ever read off accounts old enough to have one — the 0–14d cohort's week-2 stage renders grayed "too young" rather than as a misleading zero.
Two rules the code enforces rather than states
Privacy. Every query on this page returns counts only — no email, no user id, no vault name, not even transiently. Per-account rows stay in
/admin/usersand/admin/vaults, where comp and suspend actually need a person. Pinned by a real negative test: given seeded users whose addresses are renderable (the positive control finds all three on/admin/users), the Growth page's HTML matches no email-shaped string at all. The privacy footer renders verbatim and its text is pinned as a constant.Honesty. Several numbers are proxies over data collected for other reasons, so each renders beside a "how measured" line naming its blind spot:
Funnel stages are measured independently, not nested (someone can connect an AI before writing anything), so a later bar may exceed an earlier one. The page says so next to the bars.
Two places the design met the real schema and the schema won
connected AIreusesdrip.ts'sCONSOLE_CLIENT_IDand is the exact inverse of the day-3 connect-nudge eligibility query — the nudge and the bar can never disagree about what "connected an AI" means.scheduled churnEXCLUDES trials. The design specifiedpending_plan='expired'. That flag is the 30-day trial clock —users.ts createUserwrites it on every fresh signup — so counting it unqualified would just re-report the trial cohort. Restricted to non-trial accounts it means what it says: a paid or comped account with a downgrade on the books. Called out on the card itself.The activation-funnel SQL (please check the proxies)
workers/identity/src/admin-growth.ts:150— one grouped query, both cohorts,SUM(CASE WHEN … END)per stage over oneusersscan:Binds:
?1= now−14d,?2= now−44d,?3= now (future-dated rows stay out),?4=CONTENT_GROWTH_BYTES(8 KiB),?5=CONSOLE_CLIENT_ID.strftime('%Y-%m-%dT%H:%M:%fZ', …)reproducesDate.toISOString()byte-for-byte (the migration-0018 pattern), so the per-account week-2 window bounds compare correctly as plain text against stored ISO strings;date(…)is used againstvault_usage.day, which is a bareYYYY-MM-DD. Verified against real D1 under workerd before the query was built on it.Wiring
workers/identity/src/index.ts:305—app.get("/admin/growth", …), registered with the other/adminroutes.workers/identity/src/admin.ts:56,68—notFound+operatorForare now exported; the new page reuses them rather than re-implementing the boundary, so the 404-not-403 posture cannot drift between admin pages.workers/identity/src/admin-ui.ts:16,42—ADMIN_STYLE+adminHeaderexported, nav union gains"growth".route-manifest.tsunchanged —/admininCEREMONY_PREFIXESalready covers the sub-path (the 83-test drift catcher passes).drip.tsis imported, never modified.Gates
admin-growth.test.ts14/14,admin.test.ts19/19,route-manifest.test.ts83/83workers/identitytypecheckbun run typecheckbun test src test-bunThe suite was run per-file: a whole-suite run exhausted local ephemeral ports (~4900 TIME_WAIT →
EADDRNOTAVAIL), which is a sandbox limit, not a code failure — every file passes on its own. CI runs it whole.Version: main is
rc.122andrc.123is claimed by the in-flight email redesign (#215), so this bumps torc.124.🤖 Generated with Claude Code
https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB
Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com