From e46f484eaa63da3b4bcc639e56fc642ffbdb1b40 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Wed, 19 Aug 2026 18:11:15 +0200 Subject: [PATCH 1/8] docs: design expanded onboarding v4 funnel --- ...19-onboarding-v4-app-step-funnel-design.md | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-19-onboarding-v4-app-step-funnel-design.md diff --git a/docs/superpowers/specs/2026-08-19-onboarding-v4-app-step-funnel-design.md b/docs/superpowers/specs/2026-08-19-onboarding-v4-app-step-funnel-design.md new file mode 100644 index 0000000000..01b2c2b090 --- /dev/null +++ b/docs/superpowers/specs/2026-08-19-onboarding-v4-app-step-funnel-design.md @@ -0,0 +1,83 @@ +# Onboarding v4 App-Step Funnel Design + +## Goal + +Expand the admin dashboard's Frontend onboarding funnel (v4) so it shows the +actual app-creation journey rather than collapsing all app details into one +stage. + +The v4 funnel stages are: + +1. Intent +2. App name +3. App ID +4. App icon +5. Organization details +6. Setup reached + +This change is limited to onboarding v4. The v1-v3 funnels, daily conversion +charts, welcome-outcome chart, and journey graph keep their current behavior. + +## Event Model + +Use `onboarding_step_viewed` events from `console.capgo.app`, restricted to +`flow = pre_org` and `onboarding_version = 4`, as the source of stage reach. +The backend query records the earliest timestamp for the `app_name`, `app_id`, +and `app_icon` step values in addition to the existing intent, organization, +and setup timestamps. + +Step-view events are the source of truth because fields such as the app icon +can be skipped. Inferring reach from field-entry or completion events would +undercount users who legitimately advanced without providing an optional +value. + +## Funnel Semantics + +The funnel is monotonic. An attempt that reaches a later stage also counts as +having reached every earlier stage, even when an intermediate view event is +missing. This protects the chart from telemetry loss and matches the existing +funnel behavior. + +Each stage reports: + +- absolute attempt or user count, depending on the existing de-duplication + switch; +- conversion from the previous stage; and +- conversion from Intent. + +The existing v4 de-duplication option continues to select one best attempt per +user. The best attempt is the one that reached the furthest of the six v4 +stages; existing tie-break rules remain unchanged. + +## Backend Changes + +- Add `app_name_ms`, `app_id_ms`, and `app_icon_ms` to the analytics query and + attempt model. +- Keep the legacy four-stage funnel builder for onboarding v1-v3. +- Add a v4-specific six-stage funnel builder and use it for v4 funnel output, + v4 period summaries, and v4 best-attempt ranking. +- Preserve the current `details_ms` field for legacy funnels and daily + conversion metrics. + +## Frontend Changes + +- Extend the admin analytics types and color mapping with `app_name`, `app_id`, + and `app_icon`. +- Render the six backend-provided stages in the existing v4 funnel chart. +- Change only the v4 summary grid so six stage cards remain readable across + desktop widths. +- Use the requested labels exactly: Intent, App name, App ID, App icon, + Organization details, and Setup reached. + +## Verification + +Unit tests must cover: + +- extraction of the three new step timestamps from the PostHog query result; +- monotonic six-stage counts and conversion values; +- v4 best-attempt de-duplication based on the furthest expanded stage; +- unchanged four-stage output for v1-v3; and +- frontend type/color/summary handling for all six v4 stages. + +Run repository lint, backend and frontend typechecks, the unit suite, and a +production frontend build before opening the pull request. From e9547f4cd7201583a6ac8b666fde44feb255cb18 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Wed, 19 Aug 2026 18:13:25 +0200 Subject: [PATCH 2/8] docs: plan expanded onboarding v4 funnel --- ...026-08-19-onboarding-v4-app-step-funnel.md | 290 ++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-19-onboarding-v4-app-step-funnel.md diff --git a/docs/superpowers/plans/2026-08-19-onboarding-v4-app-step-funnel.md b/docs/superpowers/plans/2026-08-19-onboarding-v4-app-step-funnel.md new file mode 100644 index 0000000000..bc241caab4 --- /dev/null +++ b/docs/superpowers/plans/2026-08-19-onboarding-v4-app-step-funnel.md @@ -0,0 +1,290 @@ +# Onboarding v4 App-Step Funnel Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Expand the admin Frontend onboarding funnel (v4) from four aggregate stages to the six app-creation stages requested in the approved design. + +**Architecture:** Keep the existing four-stage model as the legacy path for onboarding v1-v3 and add a v4-specific stage definition, reach calculator, and best-attempt rank. Extend the existing PostHog attempt projection with the three app substep timestamps, then allow the frontend's order-driven funnel adapters to render the new backend stages. + +**Tech Stack:** TypeScript, Vue 3, PostHog HogQL, Vitest, Tailwind CSS. + +--- + +## File Structure + +- `supabase/functions/_backend/utils/frontend_onboarding_analytics_model.ts`: owns attempt data, funnel construction, KPI summaries, and de-duplication ranking. +- `supabase/functions/_backend/utils/frontend_onboarding_analytics.ts`: owns the HogQL query, result mapping, and admin endpoint response. +- `src/services/adminFrontendOnboarding.ts`: owns frontend response types, funnel stage colors, and chart/summary adapters. +- `src/pages/admin/dashboard/frontend-onboarding.vue`: owns the responsive dashboard layout. +- `tests/frontend-onboarding-analytics-model.unit.test.ts`: verifies six-stage v4 semantics and unchanged legacy behavior. +- `tests/frontend-onboarding-analytics.unit.test.ts`: verifies HogQL projection and row mapping. +- `tests/admin-frontend-onboarding-dashboard.unit.test.ts`: verifies frontend six-stage adaptation and dashboard source wiring. + +### Task 1: Specify the Backend Six-Stage Model + +**Files:** +- Modify: `tests/frontend-onboarding-analytics-model.unit.test.ts` +- Modify: `supabase/functions/_backend/utils/frontend_onboarding_analytics_model.ts` + +- [ ] **Step 1: Add failing v4 stage tests and complete fixture defaults** + +Add `appNameMs`, `appIdMs`, and `appIconMs` as `null` defaults in `attempt()`. Update the complete v4 fixtures to provide the three timestamps and assert this exact funnel: + +```ts +expect(analytics.funnels.v4).toEqual([ + { key: 'intent', label: 'Intent', reached: 4, of_start_percent: 100, dropoff_percent: 0 }, + { key: 'app_name', label: 'App name', reached: 3, of_start_percent: 75, dropoff_percent: 25 }, + { key: 'app_id', label: 'App ID', reached: 2, of_start_percent: 50, dropoff_percent: 1 / 3 * 100 }, + { key: 'app_icon', label: 'App icon', reached: 2, of_start_percent: 50, dropoff_percent: 0 }, + { key: 'organization', label: 'Organization details', reached: 2, of_start_percent: 50, dropoff_percent: 0 }, + { key: 'setup', label: 'Setup reached', reached: 2, of_start_percent: 50, dropoff_percent: 0 }, +]) +expect(analytics.funnels.v1.map(stage => stage.key)).toEqual(['intent', 'details', 'organization', 'setup']) +``` + +Add a de-duplication case where the same person has an older attempt reaching `app_icon` and a newer attempt reaching only `app_id`; assert the older, furthest attempt wins. + +- [ ] **Step 2: Run the model test and verify RED** + +Run: `bunx vitest run tests/frontend-onboarding-analytics-model.unit.test.ts` + +Expected: FAIL because `FrontendOnboardingAttempt` has no app substep fields and v4 still returns four stages. + +- [ ] **Step 3: Add legacy and v4 stage definitions** + +Extend the model types and definitions: + +```ts +export type FrontendOnboardingStageKey + = 'intent' | 'details' | 'app_name' | 'app_id' | 'app_icon' | 'organization' | 'setup' + +export interface FrontendOnboardingAttempt { + // existing fields + appNameMs: number | null + appIdMs: number | null + appIconMs: number | null +} + +const LEGACY_FUNNEL_STAGES = [ + { key: 'intent', label: 'Intent' }, + { key: 'details', label: 'App details' }, + { key: 'organization', label: 'Organization' }, + { key: 'setup', label: 'Setup reached' }, +] as const + +const V4_FUNNEL_STAGES = [ + { key: 'intent', label: 'Intent' }, + { key: 'app_name', label: 'App name' }, + { key: 'app_id', label: 'App ID' }, + { key: 'app_icon', label: 'App icon' }, + { key: 'organization', label: 'Organization details' }, + { key: 'setup', label: 'Setup reached' }, +] as const +``` + +- [ ] **Step 4: Build v4-specific reach and ranking behavior** + +Keep `buildLegacyFunnel()` on the existing details/organization/setup logic. Add `buildV4Funnel()` whose counts are monotonic by checking the current or any later timestamp, and make the rank version-aware: + +```ts +function stageRank(attempt: FrontendOnboardingAttempt): number { + if (isStepInFollowupWindow(attempt.setupMs, attempt.intentMs)) return attempt.onboardingVersion === 4 ? 5 : 3 + if (isStepInFollowupWindow(attempt.organizationMs, attempt.intentMs)) return attempt.onboardingVersion === 4 ? 4 : 2 + if (attempt.onboardingVersion !== 4) + return isStepInFollowupWindow(attempt.detailsMs, attempt.intentMs) ? 1 : 0 + if (isStepInFollowupWindow(attempt.appIconMs, attempt.intentMs)) return 3 + if (isStepInFollowupWindow(attempt.appIdMs, attempt.intentMs)) return 2 + if (isStepInFollowupWindow(attempt.appNameMs, attempt.intentMs)) return 1 + return 0 +} +``` + +Change `summarizePeriod(attempts, buildFunnel)` to accept a funnel builder, calculate completion from the final stage instead of index `3`, and use `buildV4Funnel` for current/previous v4 and v4 de-duplicated output. Keep `buildLegacyFunnel` for v1-v3. + +- [ ] **Step 5: Run the model test and verify GREEN** + +Run: `bunx vitest run tests/frontend-onboarding-analytics-model.unit.test.ts` + +Expected: PASS with v4 returning six stages and v1-v3 returning four. + +- [ ] **Step 6: Commit the model behavior** + +```bash +git add tests/frontend-onboarding-analytics-model.unit.test.ts supabase/functions/_backend/utils/frontend_onboarding_analytics_model.ts +git commit -m "feat(analytics): expand onboarding v4 funnel stages" +``` + +### Task 2: Project App-Step Timestamps from PostHog + +**Files:** +- Modify: `tests/frontend-onboarding-analytics.unit.test.ts` +- Modify: `supabase/functions/_backend/utils/frontend_onboarding_analytics.ts` + +- [ ] **Step 1: Add failing HogQL and mapping assertions** + +Extend the mocked PostHog row with: + +```ts +app_name_ms: intentMs + 500, +app_id_ms: intentMs + 1_000, +app_icon_ms: intentMs + 1_500, +``` + +Assert the built query includes the three `minIf` projections and the mapped v4 funnel contains the keys `['intent', 'app_name', 'app_id', 'app_icon', 'organization', 'setup']`. + +- [ ] **Step 2: Run the endpoint unit test and verify RED** + +Run: `bunx vitest run tests/frontend-onboarding-analytics.unit.test.ts` + +Expected: FAIL because the query and mapped attempt do not contain the new timestamps. + +- [ ] **Step 3: Extend query projection and result mapping** + +Add these expressions to `onboarding_attempts` and select the aliases in the final projection: + +```sql +toUnixTimestamp64Milli(minIf(timestamp, event = 'onboarding_step_viewed' AND step = 'app_name')) AS app_name_ms, +toUnixTimestamp64Milli(minIf(timestamp, event = 'onboarding_step_viewed' AND step = 'app_id')) AS app_id_ms, +toUnixTimestamp64Milli(minIf(timestamp, event = 'onboarding_step_viewed' AND step = 'app_icon')) AS app_icon_ms, +``` + +Map them with the existing nullable timestamp helper: + +```ts +appNameMs: nullableMs(row.app_name_ms), +appIdMs: nullableMs(row.app_id_ms), +appIconMs: nullableMs(row.app_icon_ms), +``` + +- [ ] **Step 4: Run endpoint and model tests and verify GREEN** + +Run: `bunx vitest run tests/frontend-onboarding-analytics.unit.test.ts tests/frontend-onboarding-analytics-model.unit.test.ts` + +Expected: PASS. + +- [ ] **Step 5: Commit the query and mapping** + +```bash +git add tests/frontend-onboarding-analytics.unit.test.ts supabase/functions/_backend/utils/frontend_onboarding_analytics.ts +git commit -m "feat(analytics): query onboarding v4 app steps" +``` + +### Task 3: Render Six Stages in the Admin Dashboard + +**Files:** +- Modify: `tests/admin-frontend-onboarding-dashboard.unit.test.ts` +- Modify: `src/services/adminFrontendOnboarding.ts` +- Modify: `src/pages/admin/dashboard/frontend-onboarding.vue` + +- [ ] **Step 1: Add failing frontend adapter and layout tests** + +Add a six-stage v4 fixture and assert stable key-based colors and ordered conversion summaries: + +```ts +expect(buildFrontendOnboardingFunnelStages(analytics.funnels.v4).map(stage => stage.label)).toEqual([ + 'Intent', 'App name', 'App ID', 'App icon', 'Organization details', 'Setup reached', +]) +expect(buildFrontendOnboardingFunnelSummaries(analytics.funnels.v4).map(stage => stage.reached)).toEqual([10, 9, 8, 7, 6, 5]) +``` + +Assert the Vue source contains `xl:grid-cols-6` for the v4 summary grid while the legacy v1 summary remains four columns. + +- [ ] **Step 2: Run the dashboard unit test and verify RED** + +Run: `bunx vitest run tests/admin-frontend-onboarding-dashboard.unit.test.ts` + +Expected: FAIL because the stage union/colors and v4 layout support only four keys/cards. + +- [ ] **Step 3: Extend frontend types and colors** + +Use the same expanded stage union as the backend and add colors that preserve the blue-to-green journey: + +```ts +const FUNNEL_STAGE_COLORS: Record = { + intent: '#119eff', + details: '#6366f1', + app_name: '#4f7cff', + app_id: '#6366f1', + app_icon: '#8b5cf6', + organization: '#a855f7', + setup: '#10b981', +} +``` + +Do not add label-specific branching: the existing adapters must continue honoring backend order and labels. + +- [ ] **Step 4: Make the v4 summary grid responsive for six cards** + +Change only the v4 summary container to: + +```html +
+``` + +- [ ] **Step 5: Run the dashboard unit test and verify GREEN** + +Run: `bunx vitest run tests/admin-frontend-onboarding-dashboard.unit.test.ts` + +Expected: PASS. + +- [ ] **Step 6: Commit the dashboard rendering** + +```bash +git add tests/admin-frontend-onboarding-dashboard.unit.test.ts src/services/adminFrontendOnboarding.ts src/pages/admin/dashboard/frontend-onboarding.vue +git commit -m "feat(admin): render expanded onboarding v4 funnel" +``` + +### Task 4: Full Verification and Pull Request + +**Files:** +- Verify all modified files + +- [ ] **Step 1: Run formatting and lint checks** + +Run: `bun lint && bun lint:backend` + +Expected: both commands exit 0. + +- [ ] **Step 2: Run backend and frontend typechecks** + +Run: `bun run typecheck:backend && bun run typecheck:frontend` + +Expected: both commands exit 0. + +- [ ] **Step 3: Run the full unit suite** + +Run: `bun test:unit` + +Expected: all unit tests pass. + +- [ ] **Step 4: Run the production build** + +Run: `CHOKIDAR_USEPOLLING=true bun run build` + +Expected: production build exits 0. If generated declarations change without semantic source changes, restore them to `HEAD` and confirm the worktree contains only intended files. + +- [ ] **Step 5: Push and create the pull request** + +```bash +git push -u origin wolny/onboarding-v4-app-step-funnel +gh pr create --base main --head wolny/onboarding-v4-app-step-funnel --title "feat(admin): expand onboarding v4 funnel stages" --body "## Summary + +- expand only the onboarding v4 funnel to Intent, App name, App ID, App icon, Organization details, and Setup reached +- source the three new stages from console.capgo.app onboarding_step_viewed events and rank de-duplicated v4 attempts by the furthest expanded stage +- keep v1-v3 funnels and existing daily conversion charts unchanged + +## Verification + +- bun lint +- bun lint:backend +- bun run typecheck:backend +- bun run typecheck:frontend +- bun test:unit +- CHOKIDAR_USEPOLLING=true bun run build" +``` + +The PR body must summarize the six-stage model, v4-only scope, PostHog source, de-duplication change, and verification commands. Do not merge the PR. + +- [ ] **Step 6: Run the `pr-ready` workflow** + +Observe checks, reviews, mergeability, and unresolved threads. Fix actionable findings, rerun affected local verification, push updates, and restart observation. Report stable-green only after two identical fully green observations at least five minutes apart. From 013744f4d4e2bdf85b4d99a32201a502f740365a Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Wed, 19 Aug 2026 18:16:37 +0200 Subject: [PATCH 3/8] feat(analytics): expand onboarding v4 funnel stages --- .../frontend_onboarding_analytics_model.ts | 131 +++++++++++++----- ...nd-onboarding-analytics-model.unit.test.ts | 96 +++++++++++-- 2 files changed, 187 insertions(+), 40 deletions(-) diff --git a/supabase/functions/_backend/utils/frontend_onboarding_analytics_model.ts b/supabase/functions/_backend/utils/frontend_onboarding_analytics_model.ts index 21605f40ae..5f9b14c47b 100644 --- a/supabase/functions/_backend/utils/frontend_onboarding_analytics_model.ts +++ b/supabase/functions/_backend/utils/frontend_onboarding_analytics_model.ts @@ -3,7 +3,7 @@ export type FrontendOnboardingVersion = typeof FRONTEND_ONBOARDING_VERSIONS[numb export const FRONTEND_ONBOARDING_FOLLOWUP_MS = 24 * 60 * 60 * 1000 export const FRONTEND_ONBOARDING_PRODUCTION_HOST = ['console', 'capgo', 'app'].join('.') -export type FrontendOnboardingStageKey = 'intent' | 'details' | 'organization' | 'setup' +export type FrontendOnboardingStageKey = 'intent' | 'details' | 'app_name' | 'app_id' | 'app_icon' | 'organization' | 'setup' export interface FrontendOnboardingAttempt { attemptId: string @@ -11,6 +11,9 @@ export interface FrontendOnboardingAttempt { personId: string intentMs: number detailsMs: number | null + appNameMs: number | null + appIdMs: number | null + appIconMs: number | null organizationMs: number | null setupMs: number | null aiInstructionsCopiedMs: number[] @@ -25,7 +28,7 @@ export interface FrontendOnboardingInteractionEvent { export interface FrontendOnboardingFunnelStage { key: FrontendOnboardingStageKey - label: 'Intent' | 'App details' | 'Organization' | 'Setup reached' + label: 'Intent' | 'App details' | 'App name' | 'App ID' | 'App icon' | 'Organization' | 'Organization details' | 'Setup reached' reached: number of_start_percent: number dropoff_percent: number @@ -116,13 +119,22 @@ export interface FrontendOnboardingAnalytics { const DAY_MS = 24 * 60 * 60 * 1000 -const FUNNEL_STAGES: Array> = [ +const LEGACY_FUNNEL_STAGES: Array> = [ { key: 'intent', label: 'Intent' }, { key: 'details', label: 'App details' }, { key: 'organization', label: 'Organization' }, { key: 'setup', label: 'Setup reached' }, ] +const V4_FUNNEL_STAGES: Array> = [ + { key: 'intent', label: 'Intent' }, + { key: 'app_name', label: 'App name' }, + { key: 'app_id', label: 'App ID' }, + { key: 'app_icon', label: 'App icon' }, + { key: 'organization', label: 'Organization details' }, + { key: 'setup', label: 'Setup reached' }, +] + interface PeriodSummary { kpis: FrontendOnboardingPeriodKpis funnel: FrontendOnboardingFunnelStage[] @@ -136,11 +148,20 @@ function isStepInFollowupWindow(timestamp: number | null, intentMs: number): tim function stageRank(attempt: FrontendOnboardingAttempt): number { if (isStepInFollowupWindow(attempt.setupMs, attempt.intentMs)) - return 3 + return 6 if (isStepInFollowupWindow(attempt.organizationMs, attempt.intentMs)) - return 2 + return 5 + if (attempt.onboardingVersion === 4) { + if (isStepInFollowupWindow(attempt.appIconMs, attempt.intentMs)) + return 4 + if (isStepInFollowupWindow(attempt.appIdMs, attempt.intentMs)) + return 3 + if (isStepInFollowupWindow(attempt.appNameMs, attempt.intentMs)) + return 2 + return 0 + } if (isStepInFollowupWindow(attempt.detailsMs, attempt.intentMs)) - return 1 + return 2 return 0 } @@ -175,7 +196,22 @@ function median(values: number[]): number | null { : sorted[middle] } -function buildFunnel(attempts: FrontendOnboardingAttempt[]): FrontendOnboardingFunnelStage[] { +function buildFunnelStages( + stages: Array>, + reached: number[], +): FrontendOnboardingFunnelStage[] { + return stages.map((stage, index) => { + const previousReached = index === 0 ? 0 : reached[index - 1] + return { + ...stage, + reached: reached[index], + of_start_percent: reached[0] === 0 ? 0 : reached[index] / reached[0] * 100, + dropoff_percent: previousReached === 0 ? 0 : (previousReached - reached[index]) / previousReached * 100, + } + }) +} + +function buildLegacyFunnel(attempts: FrontendOnboardingAttempt[]): FrontendOnboardingFunnelStage[] { const reached = [ attempts.length, attempts.filter(attempt => ( @@ -190,15 +226,38 @@ function buildFunnel(attempts: FrontendOnboardingAttempt[]): FrontendOnboardingF attempts.filter(attempt => isStepInFollowupWindow(attempt.setupMs, attempt.intentMs)).length, ] - return FUNNEL_STAGES.map((stage, index) => { - const previousReached = index === 0 ? 0 : reached[index - 1] - return { - ...stage, - reached: reached[index], - of_start_percent: reached[0] === 0 ? 0 : reached[index] / reached[0] * 100, - dropoff_percent: previousReached === 0 ? 0 : (previousReached - reached[index]) / previousReached * 100, - } - }) + return buildFunnelStages(LEGACY_FUNNEL_STAGES, reached) +} + +function buildV4Funnel(attempts: FrontendOnboardingAttempt[]): FrontendOnboardingFunnelStage[] { + const reached = [ + attempts.length, + attempts.filter(attempt => ( + isStepInFollowupWindow(attempt.appNameMs, attempt.intentMs) + || isStepInFollowupWindow(attempt.appIdMs, attempt.intentMs) + || isStepInFollowupWindow(attempt.appIconMs, attempt.intentMs) + || isStepInFollowupWindow(attempt.organizationMs, attempt.intentMs) + || isStepInFollowupWindow(attempt.setupMs, attempt.intentMs) + )).length, + attempts.filter(attempt => ( + isStepInFollowupWindow(attempt.appIdMs, attempt.intentMs) + || isStepInFollowupWindow(attempt.appIconMs, attempt.intentMs) + || isStepInFollowupWindow(attempt.organizationMs, attempt.intentMs) + || isStepInFollowupWindow(attempt.setupMs, attempt.intentMs) + )).length, + attempts.filter(attempt => ( + isStepInFollowupWindow(attempt.appIconMs, attempt.intentMs) + || isStepInFollowupWindow(attempt.organizationMs, attempt.intentMs) + || isStepInFollowupWindow(attempt.setupMs, attempt.intentMs) + )).length, + attempts.filter(attempt => ( + isStepInFollowupWindow(attempt.organizationMs, attempt.intentMs) + || isStepInFollowupWindow(attempt.setupMs, attempt.intentMs) + )).length, + attempts.filter(attempt => isStepInFollowupWindow(attempt.setupMs, attempt.intentMs)).length, + ] + + return buildFunnelStages(V4_FUNNEL_STAGES, reached) } function findLargestDropoff(funnel: FrontendOnboardingFunnelStage[]): FrontendOnboardingLargestDropoff | null { @@ -223,8 +282,12 @@ function findLargestDropoff(funnel: FrontendOnboardingFunnelStage[]): FrontendOn return largestDropoff } -function summarizePeriod(attempts: FrontendOnboardingAttempt[]): PeriodSummary { +function summarizePeriod( + attempts: FrontendOnboardingAttempt[], + buildFunnel: (attempts: FrontendOnboardingAttempt[]) => FrontendOnboardingFunnelStage[], +): PeriodSummary { const funnel = buildFunnel(attempts) + const completed = funnel.at(-1)?.reached ?? 0 const completionDurations: number[] = [] for (const attempt of attempts) { const setupMs = attempt.setupMs @@ -236,8 +299,8 @@ function summarizePeriod(attempts: FrontendOnboardingAttempt[]): PeriodSummary { funnel, kpis: { attempts: attempts.length, - completed: funnel[3].reached, - completion_rate: attempts.length === 0 ? 0 : funnel[3].reached / attempts.length * 100, + completed, + completion_rate: attempts.length === 0 ? 0 : completed / attempts.length * 100, median_completion_ms: median(completionDurations), largest_dropoff: findLargestDropoff(funnel), }, @@ -279,9 +342,15 @@ function firstReachedStageMs(attempt: FrontendOnboardingAttempt, stage: Frontend ? [attempt.intentMs] : stage === 'details' ? [attempt.detailsMs, attempt.organizationMs, attempt.setupMs] - : stage === 'organization' - ? [attempt.organizationMs, attempt.setupMs] - : [attempt.setupMs] + : stage === 'app_name' + ? [attempt.appNameMs, attempt.appIdMs, attempt.appIconMs, attempt.organizationMs, attempt.setupMs] + : stage === 'app_id' + ? [attempt.appIdMs, attempt.appIconMs, attempt.organizationMs, attempt.setupMs] + : stage === 'app_icon' + ? [attempt.appIconMs, attempt.organizationMs, attempt.setupMs] + : stage === 'organization' + ? [attempt.organizationMs, attempt.setupMs] + : [attempt.setupMs] const reached = candidates.filter(timestamp => isStepInFollowupWindow(timestamp, attempt.intentMs)) return reached.length === 0 ? null : Math.min(...reached) @@ -408,10 +477,10 @@ export function buildFrontendOnboardingAnalytics( && attempt.intentMs < currentEndMs) const previousV3Attempts = previousAttempts.filter(attempt => attempt.onboardingVersion === 3) const previousV4Attempts = previousAttempts.filter(attempt => attempt.onboardingVersion === 4) - const currentV3 = summarizePeriod(currentV3Attempts) - const previousV3 = summarizePeriod(previousV3Attempts) - const currentV4 = summarizePeriod(currentV4Attempts) - const previousV4 = summarizePeriod(previousV4Attempts) + const currentV3 = summarizePeriod(currentV3Attempts, buildLegacyFunnel) + const previousV3 = summarizePeriod(previousV3Attempts, buildLegacyFunnel) + const currentV4 = summarizePeriod(currentV4Attempts, buildV4Funnel) + const previousV4 = summarizePeriod(previousV4Attempts, buildV4Funnel) const deduplicatedCurrentAttempts = selectDeduplicatedAttempts(currentAttempts) const deduplicatedCurrentV3Attempts = selectDeduplicatedAttempts(currentV3Attempts) const deduplicatedCurrentV4Attempts = selectDeduplicatedAttempts(currentV4Attempts) @@ -429,8 +498,8 @@ export function buildFrontendOnboardingAnalytics( deduplicated: { daily_attempts: buildDailyAttempts(deduplicatedCurrentAttempts, currentStartMs, currentEndMs), funnels: { - v3: buildFunnel(deduplicatedCurrentV3Attempts), - v4: buildFunnel(deduplicatedCurrentV4Attempts), + v3: buildLegacyFunnel(deduplicatedCurrentV3Attempts), + v4: buildV4Funnel(deduplicatedCurrentV4Attempts), }, }, daily_conversions: { @@ -444,9 +513,9 @@ export function buildFrontendOnboardingAnalytics( organization_to_setup: buildDailyConversion(currentV4ConversionAttempts, currentStartMs, currentEndMs, 'organization', 'setup'), }, funnels: { - v1: buildFunnel(currentV1Attempts), - v2: buildFunnel(currentV2Attempts), - v3: buildFunnel(currentV3Attempts), + v1: buildLegacyFunnel(currentV1Attempts), + v2: buildLegacyFunnel(currentV2Attempts), + v3: buildLegacyFunnel(currentV3Attempts), v4: currentV4.funnel, }, v2_graph: { nodes: buildInteractionGraph(currentV2Attempts) }, diff --git a/tests/frontend-onboarding-analytics-model.unit.test.ts b/tests/frontend-onboarding-analytics-model.unit.test.ts index ddf81acb4b..26b09ab9e9 100644 --- a/tests/frontend-onboarding-analytics-model.unit.test.ts +++ b/tests/frontend-onboarding-analytics-model.unit.test.ts @@ -20,6 +20,9 @@ function attempt(overrides: Partial & Pick & Pick { + it.concurrent('builds the expanded monotonic v4 app-step funnel while preserving legacy stages', () => { + const analytics = buildFrontendOnboardingAnalytics([ + attempt({ + attemptId: 'complete', + intentMs: CURRENT_START_MS, + appNameMs: CURRENT_START_MS + MINUTE_MS, + appIdMs: CURRENT_START_MS + 2 * MINUTE_MS, + appIconMs: CURRENT_START_MS + 3 * MINUTE_MS, + organizationMs: CURRENT_START_MS + 4 * MINUTE_MS, + setupMs: CURRENT_START_MS + 5 * MINUTE_MS, + }), + attempt({ + attemptId: 'setup-only', + intentMs: CURRENT_START_MS + MINUTE_MS, + setupMs: CURRENT_START_MS + 6 * MINUTE_MS, + }), + attempt({ + attemptId: 'app-name-only', + intentMs: CURRENT_START_MS + 2 * MINUTE_MS, + appNameMs: CURRENT_START_MS + 3 * MINUTE_MS, + }), + attempt({ attemptId: 'intent-only', intentMs: CURRENT_START_MS + 3 * MINUTE_MS }), + attempt({ + attemptId: 'legacy-v3', + onboardingVersion: 3, + intentMs: CURRENT_START_MS + 4 * MINUTE_MS, + detailsMs: CURRENT_START_MS + 5 * MINUTE_MS, + }), + ], CURRENT_START_MS, CURRENT_END_MS) + + expect(analytics.funnels.v4).toEqual([ + { key: 'intent', label: 'Intent', reached: 4, of_start_percent: 100, dropoff_percent: 0 }, + { key: 'app_name', label: 'App name', reached: 3, of_start_percent: 75, dropoff_percent: 25 }, + { key: 'app_id', label: 'App ID', reached: 2, of_start_percent: 50, dropoff_percent: 1 / 3 * 100 }, + { key: 'app_icon', label: 'App icon', reached: 2, of_start_percent: 50, dropoff_percent: 0 }, + { key: 'organization', label: 'Organization details', reached: 2, of_start_percent: 50, dropoff_percent: 0 }, + { key: 'setup', label: 'Setup reached', reached: 2, of_start_percent: 50, dropoff_percent: 0 }, + ]) + expect(analytics.funnels.v3.map(stage => stage.key)).toEqual(['intent', 'details', 'organization', 'setup']) + }) + + it.concurrent('de-duplicates v4 users by the furthest expanded app step', () => { + const analytics = buildFrontendOnboardingAnalytics([ + attempt({ + attemptId: 'older-app-icon', + personId: 'same-person', + intentMs: CURRENT_START_MS, + appIconMs: CURRENT_START_MS + MINUTE_MS, + }), + attempt({ + attemptId: 'newer-app-id', + personId: 'same-person', + intentMs: CURRENT_START_MS + DAY_MS, + appIdMs: CURRENT_START_MS + DAY_MS + MINUTE_MS, + }), + ], CURRENT_START_MS, CURRENT_END_MS) + + expect(analytics.deduplicated.daily_attempts).toEqual([ + { date: '2026-08-01', v1_attempts: 0, v2_attempts: 0, v3_attempts: 0, v4_attempts: 1 }, + { date: '2026-08-02', v1_attempts: 0, v2_attempts: 0, v3_attempts: 0, v4_attempts: 0 }, + ]) + expect(analytics.deduplicated.funnels.v4.map(stage => stage.reached)).toEqual([1, 1, 1, 1, 0, 0]) + }) + it.concurrent('preserves legacy v3 fields and exposes v4 analytics additively', () => { const analytics = buildFrontendOnboardingAnalytics([ attempt({ @@ -58,6 +125,9 @@ describe('buildFrontendOnboardingAnalytics', () => { onboardingVersion: 4 as FrontendOnboardingAttempt['onboardingVersion'], intentMs: CURRENT_START_MS, detailsMs: CURRENT_START_MS + MINUTE_MS, + appNameMs: CURRENT_START_MS + MINUTE_MS, + appIdMs: CURRENT_START_MS + 90_000, + appIconMs: CURRENT_START_MS + 105_000, organizationMs: CURRENT_START_MS + 2 * MINUTE_MS, setupMs: CURRENT_START_MS + 3 * MINUTE_MS, interactionEvents: [{ key: 'onboarding_app_name_entered', timestampMs: CURRENT_START_MS + MINUTE_MS }], @@ -68,7 +138,7 @@ describe('buildFrontendOnboardingAnalytics', () => { expect(analytics.v4_kpis).toMatchObject({ attempts: 1, completed: 1, completion_rate: 100 }) expect(analytics.daily_attempts[0]).toMatchObject({ v4_attempts: 1 }) expect(analytics.deduplicated.funnels.v3.map(stage => stage.reached)).toEqual([0, 0, 0, 0]) - expect(analytics.funnels.v4.map(stage => stage.reached)).toEqual([1, 1, 1, 1]) + expect(analytics.funnels.v4.map(stage => stage.reached)).toEqual([1, 1, 1, 1, 1, 1]) expect(analytics.v4_graph.nodes).toEqual([{ key: 'onboarding_app_name_entered', count: 1 }]) }) @@ -78,6 +148,9 @@ describe('buildFrontendOnboardingAnalytics', () => { attemptId: 'complete', intentMs: CURRENT_START_MS, detailsMs: CURRENT_START_MS + MINUTE_MS, + appNameMs: CURRENT_START_MS + MINUTE_MS, + appIdMs: CURRENT_START_MS + 90_000, + appIconMs: CURRENT_START_MS + 105_000, organizationMs: CURRENT_START_MS + 2 * MINUTE_MS, setupMs: CURRENT_START_MS + 4 * MINUTE_MS, }), @@ -90,6 +163,7 @@ describe('buildFrontendOnboardingAnalytics', () => { attemptId: 'details-only', intentMs: CURRENT_START_MS + DAY_MS, detailsMs: CURRENT_START_MS + DAY_MS + MINUTE_MS, + appNameMs: CURRENT_START_MS + DAY_MS + MINUTE_MS, }), attempt({ attemptId: 'intent-only', intentMs: CURRENT_START_MS + DAY_MS + MINUTE_MS }), attempt({ @@ -104,17 +178,19 @@ describe('buildFrontendOnboardingAnalytics', () => { completed: 2, completion_rate: 50, median_completion_ms: 300_000, - largest_dropoff: { from: 'details', to: 'organization', percentage: 1 / 3 * 100 }, + largest_dropoff: { from: 'app_name', to: 'app_id', percentage: 1 / 3 * 100 }, }) expect(analytics.daily_attempts).toEqual([ { date: '2026-08-01', v1_attempts: 0, v2_attempts: 0, v3_attempts: 0, v4_attempts: 2 }, { date: '2026-08-02', v1_attempts: 1, v2_attempts: 0, v3_attempts: 0, v4_attempts: 2 }, ]) - expect(analytics.funnels.v4.map(stage => stage.reached)).toEqual([4, 3, 2, 2]) + expect(analytics.funnels.v4.map(stage => stage.reached)).toEqual([4, 3, 2, 2, 2, 2]) expect(analytics.funnels.v4).toEqual([ { key: 'intent', label: 'Intent', reached: 4, of_start_percent: 100, dropoff_percent: 0 }, - { key: 'details', label: 'App details', reached: 3, of_start_percent: 75, dropoff_percent: 25 }, - { key: 'organization', label: 'Organization', reached: 2, of_start_percent: 50, dropoff_percent: 1 / 3 * 100 }, + { key: 'app_name', label: 'App name', reached: 3, of_start_percent: 75, dropoff_percent: 25 }, + { key: 'app_id', label: 'App ID', reached: 2, of_start_percent: 50, dropoff_percent: 1 / 3 * 100 }, + { key: 'app_icon', label: 'App icon', reached: 2, of_start_percent: 50, dropoff_percent: 0 }, + { key: 'organization', label: 'Organization details', reached: 2, of_start_percent: 50, dropoff_percent: 0 }, { key: 'setup', label: 'Setup reached', reached: 2, of_start_percent: 50, dropoff_percent: 0 }, ]) expect(analytics.funnels.v1.map(stage => stage.reached)).toEqual([1, 0, 0, 0]) @@ -147,6 +223,7 @@ describe('buildFrontendOnboardingAnalytics', () => { personId: 'latest', intentMs: CURRENT_START_MS + DAY_MS + 3 * MINUTE_MS, detailsMs: CURRENT_START_MS + DAY_MS + 4 * MINUTE_MS, + appNameMs: CURRENT_START_MS + DAY_MS + 4 * MINUTE_MS, }), attempt({ attemptId: 'blank-day-one', @@ -164,7 +241,7 @@ describe('buildFrontendOnboardingAnalytics', () => { { date: '2026-08-01', v1_attempts: 1, v2_attempts: 0, v3_attempts: 0, v4_attempts: 1 }, { date: '2026-08-02', v1_attempts: 0, v2_attempts: 0, v3_attempts: 0, v4_attempts: 2 }, ]) - expect(analytics.deduplicated.funnels.v4.map(stage => stage.reached)).toEqual([4, 2, 1, 0]) + expect(analytics.deduplicated.funnels.v4.map(stage => stage.reached)).toEqual([4, 2, 1, 1, 1, 0]) }) it.concurrent('keeps blank and namespaced person identities separate', () => { @@ -200,7 +277,7 @@ describe('buildFrontendOnboardingAnalytics', () => { }), ], CURRENT_START_MS, CURRENT_END_MS) - expect(analytics.funnels.v4.map(stage => stage.reached)).toEqual([1, 0, 0, 0]) + expect(analytics.funnels.v4.map(stage => stage.reached)).toEqual([1, 0, 0, 0, 0, 0]) expect(analytics.v4_kpis.median_completion_ms).toBeNull() }) @@ -268,7 +345,7 @@ describe('buildFrontendOnboardingAnalytics', () => { expect(analytics.funnels.v1.map(stage => stage.reached)).toEqual([0, 0, 0, 0]) expect(analytics.funnels.v2.map(stage => stage.reached)).toEqual([0, 0, 0, 0]) expect(analytics.funnels.v3.map(stage => stage.reached)).toEqual([0, 0, 0, 0]) - expect(analytics.funnels.v4.map(stage => stage.reached)).toEqual([0, 0, 0, 0]) + expect(analytics.funnels.v4.map(stage => stage.reached)).toEqual([0, 0, 0, 0, 0, 0]) expect(analytics.v2_graph.nodes).toEqual([]) expect(analytics.v3_graph.nodes).toEqual([]) expect(analytics.v4_graph.nodes).toEqual([]) @@ -499,6 +576,7 @@ describe('buildFrontendOnboardingAnalytics', () => { attemptId: 'windowed-interactions', intentMs, detailsMs: intentMs + MINUTE_MS, + appNameMs: intentMs + MINUTE_MS, interactionEvents: [ { key: 'before_intent', timestampMs: intentMs - 1 }, { key: 'at_boundary', timestampMs: intentMs + FRONTEND_ONBOARDING_FOLLOWUP_MS }, @@ -507,7 +585,7 @@ describe('buildFrontendOnboardingAnalytics', () => { }), ], CURRENT_START_MS, CURRENT_END_MS) - expect(analytics.funnels.v4.find(stage => stage.key === 'details')?.reached).toBe(1) + expect(analytics.funnels.v4.find(stage => stage.key === 'app_name')?.reached).toBe(1) expect(analytics.v4_graph.nodes).toEqual([{ key: 'at_boundary', count: 1 }]) }) From c78b0a76ea4af50429bde4e1698d9f8c46331797 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Wed, 19 Aug 2026 18:17:39 +0200 Subject: [PATCH 4/8] feat(analytics): query onboarding v4 app steps --- .../utils/frontend_onboarding_analytics.ts | 9 +++++++++ tests/frontend-onboarding-analytics.unit.test.ts | 14 ++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/supabase/functions/_backend/utils/frontend_onboarding_analytics.ts b/supabase/functions/_backend/utils/frontend_onboarding_analytics.ts index 6916e70f31..4d3b423e78 100644 --- a/supabase/functions/_backend/utils/frontend_onboarding_analytics.ts +++ b/supabase/functions/_backend/utils/frontend_onboarding_analytics.ts @@ -172,6 +172,9 @@ function mapAttempts(rows: Record[]): FrontendOnboardingAttempt personId: personId(row.person_id), intentMs, detailsMs: nullableMs(row.details_ms), + appNameMs: nullableMs(row.app_name_ms), + appIdMs: nullableMs(row.app_id_ms), + appIconMs: nullableMs(row.app_icon_ms), organizationMs: nullableMs(row.organization_ms), setupMs: nullableMs(row.setup_ms), aiInstructionsCopiedMs: timestamps(row.ai_instructions_copied_ms), @@ -226,6 +229,9 @@ export function buildFrontendOnboardingHogql(startDate: string, cohortEndDate: s toString(argMin(person_id, timestamp)) AS person_id, toUnixTimestamp64Milli(minIf(timestamp, event = 'onboarding_step_viewed' AND step = 'intent')) AS intent_ms, toUnixTimestamp64Milli(minIf(timestamp, event = 'onboarding_step_viewed' AND step IN ('details', 'app_name'))) AS details_ms, + toUnixTimestamp64Milli(minIf(timestamp, event = 'onboarding_step_viewed' AND step = 'app_name')) AS app_name_ms, + toUnixTimestamp64Milli(minIf(timestamp, event = 'onboarding_step_viewed' AND step = 'app_id')) AS app_id_ms, + toUnixTimestamp64Milli(minIf(timestamp, event = 'onboarding_step_viewed' AND step = 'app_icon')) AS app_icon_ms, toUnixTimestamp64Milli(minIf(timestamp, event = 'onboarding_step_viewed' AND step = 'organization')) AS organization_ms, toUnixTimestamp64Milli(minIf(timestamp, event = 'onboarding_step_viewed' AND step = 'setup')) AS setup_ms, groupUniqArrayIf(tuple(event, toUnixTimestamp64Milli(timestamp)), event IN (${interactionEventAllowlist})) AS interaction_events, @@ -258,6 +264,9 @@ export function buildFrontendOnboardingHogql(startDate: string, cohortEndDate: s count() OVER () AS total_attempts, intent_ms, details_ms, + app_name_ms, + app_id_ms, + app_icon_ms, organization_ms, setup_ms, interaction_events, diff --git a/tests/frontend-onboarding-analytics.unit.test.ts b/tests/frontend-onboarding-analytics.unit.test.ts index 9c3e4263e2..8c47820e38 100644 --- a/tests/frontend-onboarding-analytics.unit.test.ts +++ b/tests/frontend-onboarding-analytics.unit.test.ts @@ -76,6 +76,9 @@ describe('buildFrontendOnboardingHogql', () => { expect(query).toContain('JSONExtractString(toString(properties), \'step\') IN (\'welcome\', \'resume-prompt\')') expect(query).toContain('toUnixTimestamp64Milli(minIf(timestamp, event = \'onboarding_step_viewed\' AND step = \'intent\'))') expect(query).toContain("toUnixTimestamp64Milli(minIf(timestamp, event = 'onboarding_step_viewed' AND step IN ('details', 'app_name'))) AS details_ms") + expect(query).toContain("toUnixTimestamp64Milli(minIf(timestamp, event = 'onboarding_step_viewed' AND step = 'app_name')) AS app_name_ms") + expect(query).toContain("toUnixTimestamp64Milli(minIf(timestamp, event = 'onboarding_step_viewed' AND step = 'app_id')) AS app_id_ms") + expect(query).toContain("toUnixTimestamp64Milli(minIf(timestamp, event = 'onboarding_step_viewed' AND step = 'app_icon')) AS app_icon_ms") expect(query).toContain('toUnixTimestamp64Milli(minIf(timestamp, event = \'onboarding_step_viewed\' AND step = \'organization\'))') expect(query).toContain('toUnixTimestamp64Milli(minIf(timestamp, event = \'onboarding_step_viewed\' AND step = \'setup\'))') expect(query).toContain('groupUniqArrayIf(tuple(event, toUnixTimestamp64Milli(timestamp)), event IN (') @@ -148,6 +151,9 @@ describe('getAdminFrontendOnboardingAnalytics', () => { onboarding_version: 4, intent_ms: intentMs, details_ms: intentMs + 555, + app_name_ms: intentMs + 555, + app_id_ms: intentMs + 777, + app_icon_ms: intentMs + 888, organization_ms: intentMs + 1_000, setup_ms: intentMs + 2_666, interaction_events: [ @@ -247,7 +253,9 @@ describe('getAdminFrontendOnboardingAnalytics', () => { }, funnels: { v4: [ { key: 'intent', reached: 1 }, - { key: 'details', reached: 1 }, + { key: 'app_name', reached: 1 }, + { key: 'app_id', reached: 1 }, + { key: 'app_icon', reached: 1 }, { key: 'organization', reached: 1 }, { key: 'setup', reached: 1 }, ] }, @@ -439,7 +447,9 @@ describe('getAdminFrontendOnboardingAnalytics', () => { v4_kpis: { attempts: 2, completed: 0, completion_rate: 0 }, funnels: { v4: [ { key: 'intent', reached: 2 }, - { key: 'details', reached: 0 }, + { key: 'app_name', reached: 0 }, + { key: 'app_id', reached: 0 }, + { key: 'app_icon', reached: 0 }, { key: 'organization', reached: 0 }, { key: 'setup', reached: 0 }, ] }, From ae317c7f0bbdf8e6a8111d7ff3d6fde5c6e8948f Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Wed, 19 Aug 2026 18:19:19 +0200 Subject: [PATCH 5/8] feat(admin): render expanded onboarding v4 funnel --- .../2026-08-19-onboarding-v4-app-step-funnel.md | 4 ++-- .../admin/dashboard/frontend-onboarding.vue | 2 +- src/services/adminFrontendOnboarding.ts | 7 +++++-- ...in-frontend-onboarding-dashboard.unit.test.ts | 16 ++++++++++++++-- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/plans/2026-08-19-onboarding-v4-app-step-funnel.md b/docs/superpowers/plans/2026-08-19-onboarding-v4-app-step-funnel.md index bc241caab4..f5791e1800 100644 --- a/docs/superpowers/plans/2026-08-19-onboarding-v4-app-step-funnel.md +++ b/docs/superpowers/plans/2026-08-19-onboarding-v4-app-step-funnel.md @@ -205,8 +205,8 @@ const FUNNEL_STAGE_COLORS: Record = { details: '#6366f1', app_name: '#4f7cff', app_id: '#6366f1', - app_icon: '#8b5cf6', - organization: '#a855f7', + app_icon: '#7c3aed', + organization: '#8b5cf6', setup: '#10b981', } ``` diff --git a/src/pages/admin/dashboard/frontend-onboarding.vue b/src/pages/admin/dashboard/frontend-onboarding.vue index d89cba86f3..17bddfef6b 100644 --- a/src/pages/admin/dashboard/frontend-onboarding.vue +++ b/src/pages/admin/dashboard/frontend-onboarding.vue @@ -576,7 +576,7 @@ displayStore.defaultBack = '/dashboard'
-
+

{{ formatNumberValue(summary.conversion_percent) }}% diff --git a/src/services/adminFrontendOnboarding.ts b/src/services/adminFrontendOnboarding.ts index 57498cac9d..6134fa52e6 100644 --- a/src/services/adminFrontendOnboarding.ts +++ b/src/services/adminFrontendOnboarding.ts @@ -1,4 +1,4 @@ -export type FrontendOnboardingStageKey = 'intent' | 'details' | 'organization' | 'setup' +export type FrontendOnboardingStageKey = 'intent' | 'details' | 'app_name' | 'app_id' | 'app_icon' | 'organization' | 'setup' export const FRONTEND_ONBOARDING_DAILY_SETUP_CLI_OUTCOME_KEYS = [ 'cli_copy_init', @@ -39,7 +39,7 @@ export interface FrontendOnboardingComparison { export interface FrontendOnboardingFunnelStage { key: FrontendOnboardingStageKey - label: 'Intent' | 'App details' | 'Organization' | 'Setup reached' + label: 'Intent' | 'App details' | 'App name' | 'App ID' | 'App icon' | 'Organization' | 'Organization details' | 'Setup reached' reached: number of_start_percent: number dropoff_percent: number @@ -187,6 +187,9 @@ export interface FrontendOnboardingAnalyticsLoaderCallbacks { const FUNNEL_STAGE_COLORS: Record = { intent: '#119eff', details: '#6366f1', + app_name: '#4f7cff', + app_id: '#6366f1', + app_icon: '#7c3aed', organization: '#8b5cf6', setup: '#10b981', } diff --git a/tests/admin-frontend-onboarding-dashboard.unit.test.ts b/tests/admin-frontend-onboarding-dashboard.unit.test.ts index 812fdaf477..50e287a397 100644 --- a/tests/admin-frontend-onboarding-dashboard.unit.test.ts +++ b/tests/admin-frontend-onboarding-dashboard.unit.test.ts @@ -102,8 +102,10 @@ describe('admin frontend onboarding dashboard', () => { ], v4: [ { key: 'intent', label: 'Intent', reached: 4, of_start_percent: 100, dropoff_percent: 0 }, - { key: 'details', label: 'App details', reached: 3, of_start_percent: 75, dropoff_percent: 25 }, - { key: 'organization', label: 'Organization', reached: 2, of_start_percent: 50, dropoff_percent: 1 / 3 * 100 }, + { key: 'app_name', label: 'App name', reached: 4, of_start_percent: 100, dropoff_percent: 0 }, + { key: 'app_id', label: 'App ID', reached: 3, of_start_percent: 75, dropoff_percent: 25 }, + { key: 'app_icon', label: 'App icon', reached: 2, of_start_percent: 50, dropoff_percent: 1 / 3 * 100 }, + { key: 'organization', label: 'Organization details', reached: 2, of_start_percent: 50, dropoff_percent: 0 }, { key: 'setup', label: 'Setup reached', reached: 1, of_start_percent: 25, dropoff_percent: 50 }, ], }, @@ -413,6 +415,14 @@ describe('admin frontend onboarding dashboard', () => { { label: 'Organization', value: 3, color: '#8b5cf6' }, { label: 'Setup reached', value: 2, color: '#10b981' }, ]) + expect(buildFrontendOnboardingFunnelStages(analytics.funnels.v4)).toEqual([ + { label: 'Intent', value: 4, color: '#119eff' }, + { label: 'App name', value: 4, color: '#4f7cff' }, + { label: 'App ID', value: 3, color: '#6366f1' }, + { label: 'App icon', value: 2, color: '#7c3aed' }, + { label: 'Organization details', value: 2, color: '#8b5cf6' }, + { label: 'Setup reached', value: 1, color: '#10b981' }, + ]) }) it.concurrent('adapts either selected funnel into ordered stage-to-stage conversions', () => { @@ -447,6 +457,7 @@ describe('admin frontend onboarding dashboard', () => { }, ]) expect(buildFrontendOnboardingFunnelSummaries(analytics.funnels.v2).map(stage => stage.reached)).toEqual([5, 4, 3, 2]) + expect(buildFrontendOnboardingFunnelSummaries(analytics.funnels.v4).map(stage => stage.reached)).toEqual([4, 4, 3, 2, 2, 1]) }) it.concurrent('shows zero conversion for every stage when the selected cohort is empty', () => { @@ -717,6 +728,7 @@ describe('admin frontend onboarding dashboard', () => { const cliOutcomeIndex = template.indexOf(`t('frontend-onboarding-setup-cli-outcomes-v2-v4')`) const dailyCliOutcomeIndex = template.indexOf(`t('frontend-onboarding-daily-setup-cli-outcomes-v2-v4')`) const legacyIndex = template.indexOf(`t('frontend-onboarding-funnel-v1-legacy')`) + expect(template.slice(v4FunnelIndex, intentDetailsChartIndex)).toContain('md:grid-cols-3 xl:grid-cols-6') expect(v4FunnelIndex).toBeLessThan(graphIndex) expect(v4FunnelIndex).toBeLessThan(intentDetailsChartIndex) expect(intentDetailsChartIndex).toBeLessThan(detailsOrganizationChartIndex) From 1d085cdae694d662a3508a5685b6997b1f3b9013 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Wed, 19 Aug 2026 18:21:49 +0200 Subject: [PATCH 6/8] test(admin): typecheck v4 funnel fixture --- tests/admin-frontend-onboarding-dashboard.unit.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/admin-frontend-onboarding-dashboard.unit.test.ts b/tests/admin-frontend-onboarding-dashboard.unit.test.ts index 50e287a397..2b0cf239f6 100644 --- a/tests/admin-frontend-onboarding-dashboard.unit.test.ts +++ b/tests/admin-frontend-onboarding-dashboard.unit.test.ts @@ -415,7 +415,7 @@ describe('admin frontend onboarding dashboard', () => { { label: 'Organization', value: 3, color: '#8b5cf6' }, { label: 'Setup reached', value: 2, color: '#10b981' }, ]) - expect(buildFrontendOnboardingFunnelStages(analytics.funnels.v4)).toEqual([ + expect(buildFrontendOnboardingFunnelStages(analytics.funnels.v4!)).toEqual([ { label: 'Intent', value: 4, color: '#119eff' }, { label: 'App name', value: 4, color: '#4f7cff' }, { label: 'App ID', value: 3, color: '#6366f1' }, @@ -457,7 +457,7 @@ describe('admin frontend onboarding dashboard', () => { }, ]) expect(buildFrontendOnboardingFunnelSummaries(analytics.funnels.v2).map(stage => stage.reached)).toEqual([5, 4, 3, 2]) - expect(buildFrontendOnboardingFunnelSummaries(analytics.funnels.v4).map(stage => stage.reached)).toEqual([4, 4, 3, 2, 2, 1]) + expect(buildFrontendOnboardingFunnelSummaries(analytics.funnels.v4!).map(stage => stage.reached)).toEqual([4, 4, 3, 2, 2, 1]) }) it.concurrent('shows zero conversion for every stage when the selected cohort is empty', () => { From c7feb99f01507a2fc5251be6c773f37b437af1c9 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Wed, 19 Aug 2026 20:11:21 +0200 Subject: [PATCH 7/8] fix(admin): preserve v4 journey graph metrics --- src/pages/admin/dashboard/frontend-onboarding.vue | 2 +- tests/admin-frontend-onboarding-dashboard.unit.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/admin/dashboard/frontend-onboarding.vue b/src/pages/admin/dashboard/frontend-onboarding.vue index 17bddfef6b..f462af3866 100644 --- a/src/pages/admin/dashboard/frontend-onboarding.vue +++ b/src/pages/admin/dashboard/frontend-onboarding.vue @@ -295,7 +295,7 @@ const onboardingGraphV4 = computed(() => { const { funnel, nodes: interactionNodes } = onboardingGraphSource.value const stage = (key: FrontendOnboardingStageKey) => funnel.find(item => item.key === key) const intent = stage('intent') - const details = stage('details') + const details = stage(onboardingGraphSource.value.version === 'v4' ? 'app_name' : 'details') const organization = stage('organization') const setup = stage('setup') const parentPercent = (current: number, previous: number) => previous > 0 ? current / previous * 100 : 0 diff --git a/tests/admin-frontend-onboarding-dashboard.unit.test.ts b/tests/admin-frontend-onboarding-dashboard.unit.test.ts index 2b0cf239f6..8074595c59 100644 --- a/tests/admin-frontend-onboarding-dashboard.unit.test.ts +++ b/tests/admin-frontend-onboarding-dashboard.unit.test.ts @@ -715,6 +715,7 @@ describe('admin frontend onboarding dashboard', () => { expect(source).toContain('buildFrontendOnboardingGraphMetrics') expect(source).toContain('visibleAnalytics.value?.v4_graph?.nodes') expect(source).toContain('visibleAnalytics.value?.v3_graph?.nodes') + expect(source).toContain("const details = stage(onboardingGraphSource.value.version === 'v4' ? 'app_name' : 'details')") expect(source).not.toContain('onboardingGraphV4Demo') expect(source).toContain('buildFrontendOnboardingFunnelSummaries') expect(template).toContain('summary.conversion_percent') From 7716271c7f6261c23c65a6e8627d20e9c523ef9b Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Wed, 19 Aug 2026 20:27:58 +0200 Subject: [PATCH 8/8] docs: align onboarding funnel PR workflow --- ...026-08-19-onboarding-v4-app-step-funnel.md | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/docs/superpowers/plans/2026-08-19-onboarding-v4-app-step-funnel.md b/docs/superpowers/plans/2026-08-19-onboarding-v4-app-step-funnel.md index f5791e1800..ff320af82a 100644 --- a/docs/superpowers/plans/2026-08-19-onboarding-v4-app-step-funnel.md +++ b/docs/superpowers/plans/2026-08-19-onboarding-v4-app-step-funnel.md @@ -107,7 +107,13 @@ Run: `bunx vitest run tests/frontend-onboarding-analytics-model.unit.test.ts` Expected: PASS with v4 returning six stages and v1-v3 returning four. -- [ ] **Step 6: Commit the model behavior** +- [ ] **Step 6: Run formatting and lint checks before the first commit** + +Run: `bun lint:fix && bun lint:backend` + +Expected: both commands exit 0. + +- [ ] **Step 7: Commit the model behavior** ```bash git add tests/frontend-onboarding-analytics-model.unit.test.ts supabase/functions/_backend/utils/frontend_onboarding_analytics_model.ts @@ -239,43 +245,45 @@ git commit -m "feat(admin): render expanded onboarding v4 funnel" **Files:** - Verify all modified files -- [ ] **Step 1: Run formatting and lint checks** - -Run: `bun lint && bun lint:backend` - -Expected: both commands exit 0. - -- [ ] **Step 2: Run backend and frontend typechecks** +- [ ] **Step 1: Run backend and frontend typechecks** Run: `bun run typecheck:backend && bun run typecheck:frontend` Expected: both commands exit 0. -- [ ] **Step 3: Run the full unit suite** +- [ ] **Step 2: Run the full unit suite** Run: `bun test:unit` Expected: all unit tests pass. -- [ ] **Step 4: Run the production build** +- [ ] **Step 3: Run the production build** Run: `CHOKIDAR_USEPOLLING=true bun run build` Expected: production build exits 0. If generated declarations change without semantic source changes, restore them to `HEAD` and confirm the worktree contains only intended files. -- [ ] **Step 5: Push and create the pull request** +- [ ] **Step 4: Push and create the pull request** ```bash git push -u origin wolny/onboarding-v4-app-step-funnel -gh pr create --base main --head wolny/onboarding-v4-app-step-funnel --title "feat(admin): expand onboarding v4 funnel stages" --body "## Summary +gh pr create --base main --head wolny/onboarding-v4-app-step-funnel --title "feat(admin): expand onboarding v4 funnel stages" --body "## Summary (AI generated) - expand only the onboarding v4 funnel to Intent, App name, App ID, App icon, Organization details, and Setup reached - source the three new stages from console.capgo.app onboarding_step_viewed events and rank de-duplicated v4 attempts by the furthest expanded stage - keep v1-v3 funnels and existing daily conversion charts unchanged -## Verification +## Motivation (AI generated) + +- expose where users drop off inside the app-creation portion of onboarding v4 instead of grouping those interactions into one App details stage + +## Business Impact (AI generated) + +- make onboarding experiments measurable at each app-creation step while preserving historical v1-v3 reporting + +## Test Plan (AI generated) -- bun lint +- bun lint:fix - bun lint:backend - bun run typecheck:backend - bun run typecheck:frontend @@ -285,6 +293,6 @@ gh pr create --base main --head wolny/onboarding-v4-app-step-funnel --title "fea The PR body must summarize the six-stage model, v4-only scope, PostHog source, de-duplication change, and verification commands. Do not merge the PR. -- [ ] **Step 6: Run the `pr-ready` workflow** +- [ ] **Step 5: Run the `pr-ready` workflow** Observe checks, reviews, mergeability, and unresolved threads. Fix actionable findings, rerun affected local verification, push updates, and restart observation. Report stable-green only after two identical fully green observations at least five minutes apart.