+ + {{ t('onboarding-welcome-eyebrow') }} +
+ ++ {{ t('onboarding-welcome-description') }} +
+ +- {{ t('app-onboarding-v2-details-helper') }} +
+ {{ appDetailsStep === 'name' + ? t('app-onboarding-name-step-helper') + : appDetailsStep === 'app_id' + ? t('app-onboarding-app-id-step-helper') + : t('app-onboarding-icon-step-helper') }}
+ {{ appName.trim() || t('app-onboarding-preview-placeholder') }} +
++ {{ generatedAppId }} +
+- {{ t('app-onboarding-start-question') }} -
-- {{ hasImportedStoreMetadata - ? t('app-onboarding-store-imported-help') - : t('app-onboarding-store-help') }} -
-- {{ props.preOrg - ? t('app-onboarding-v2-appid-help') - : existingApp - ? t('app-onboarding-appid-help-existing') - : t('app-onboarding-appid-help-new') }} -
- +{{ suggestedAppId }}
+
+ - {{ t('app-onboarding-icon-help') }} -
-+ {{ t('app-onboarding-imported-icon-label') }} +
++ {{ storeAppNamePreview || appName }} +
++ {{ t('app-onboarding-imported-icon-helper') }} +
++ {{ t('app-onboarding-icon-help') }} +
++ {{ t('app-onboarding-import-icon-only-helper') }} +
+')
+ expect(iconImportPanel).toContain('class="d-btn min-h-11 w-full sm:w-auto"')
+ expect(iconImportPanel).not.toContain('sm:flex-row')
+ expect(onboardingSource).not.toContain('v-if="false"')
+ expect(onboardingSource).not.toContain('v-if="false && storeScreenshotPreview"')
+ })
+
+ it.concurrent('keeps the existing-app choice reachable for existing-organization creation', () => {
+ expect(onboardingSource).toContain('v-if="!props.preOrg && appDetailsStep === \'name\'" class="grid gap-3 sm:grid-cols-2"')
+ expect(onboardingSource).toContain('existingApp.value = props.preOrg ? true : null')
+ expect(onboardingSource).toContain("toast.error(t('app-onboarding-toast-existing-required'))")
+ const appChoice = onboardingSource.slice(
+ onboardingSource.indexOf('v-if="!props.preOrg && appDetailsStep === \'name\'"'),
+ onboardingSource.indexOf(''),
+ )
+ expect(appChoice.match(/class="d-btn group h-auto min-h-32/g)).toHaveLength(2)
+ })
+
+ it.concurrent('does not restore a skipped generated App ID as a manual choice', () => {
+ expect(onboardingSource).toContain("appId: selectedAppIdSource.value === 'generated' ? '' : generatedAppId.value")
+ })
+
+ it.concurrent('tracks every app-details page as a standard onboarding step', () => {
+ expect(onboardingSource).toContain("type AppDetailsAnalyticsStep = 'app_name' | 'app_id' | 'app_icon'")
+ expect(onboardingSource).toContain("name: 'app_name'")
+ expect(onboardingSource).toContain("app_id: 'app_id'")
+ expect(onboardingSource).toContain("icon: 'app_icon'")
+ expect(onboardingSource).toContain("completeAndViewAppDetailsStep('app_id'")
+ expect(onboardingSource).toContain("completeAndViewAppDetailsStep('icon'")
+ expect(onboardingSource).not.toContain('onboarding_app_details_step_viewed')
+ expect(onboardingSource).not.toContain('onboarding_app_details_step_completed')
+ })
+
+ it.concurrent('tracks app creation outcomes after the page-level funnel', () => {
+ expect(onboardingSource).toContain("trackDetailsEvent('onboarding_app_creation_started'")
+ expect(onboardingSource).toContain("trackDetailsEvent('onboarding_app_creation_succeeded'")
+ expect(onboardingSource).toContain("trackDetailsEvent('onboarding_app_creation_failed'")
+ expect(onboardingSource).toContain('app_id_source: creationAppIdSource')
+ expect(onboardingSource).toContain('icon_source: creationIconSource')
+ expect(onboardingSource).toContain('used_fallback: createResult.wasRetried')
+ })
+
+ it.concurrent('classifies restored draft icon data as a file in creation telemetry', () => {
+ const iconSource = onboardingSource.slice(
+ onboardingSource.indexOf('const selectedAppIconSource = computed'),
+ onboardingSource.indexOf('function createAiHelpPrompt()'),
)
- expect(storeImport).toContain(' {
+ expect(onboardingSource).toContain("'onboarding_store_icon_import_shown' : 'onboarding_store_icon_import_hidden'")
+ expect(onboardingSource).toContain("trackDetailsEvent('onboarding_store_icon_import_submitted'")
+ expect(onboardingSource).toContain("trackDetailsEvent('onboarding_store_icon_import_succeeded'")
+ expect(onboardingSource).toContain("trackDetailsEvent('onboarding_store_icon_import_failed'")
+ const resetStoreImportState = onboardingSource.slice(onboardingSource.indexOf('function resetStoreImportState()'), onboardingSource.indexOf('let resumeIconLoadRun'))
+ expect(resetStoreImportState).toContain('cancelPendingStoreIconImport()')
+ })
+
+ it.concurrent('invalidates the opposite import before either store request starts', () => {
+ const metadataImport = onboardingSource.slice(
+ onboardingSource.indexOf('async function importStoreMetadata()'),
+ onboardingSource.indexOf('function cancelPendingStoreIconImport()'),
+ )
+ expect(metadataImport).toContain('cancelPendingStoreIconImport()')
+ expect(metadataImport.indexOf('cancelPendingStoreIconImport()')).toBeLessThan(metadataImport.indexOf('const requestedRun = ++storeImportRun'))
+
+ const iconImport = onboardingSource.slice(
+ onboardingSource.indexOf('async function importStoreIcon()'),
+ onboardingSource.indexOf('function toggleStoreImport()'),
+ )
+ expect(iconImport).toContain('cancelPendingStoreImport()')
+ expect(iconImport.indexOf('cancelPendingStoreImport()')).toBeLessThan(iconImport.indexOf('const requestedRun = ++storeIconImportRun'))
+ })
+
+ it.concurrent('blocks app-details navigation while either store import is pending', () => {
+ expect(onboardingSource).toContain('const isAppDetailsNavigationPending = computed(() => (')
+ expect(onboardingSource).toContain('isSubmitting.value || isImportingStore.value || isImportingStoreIcon.value')
+ expect(onboardingSource.match(/:disabled="isAppDetailsNavigationPending"/g)).toHaveLength(2)
+ })
+
+ it.concurrent('continues to organization creation after the pre-org icon step', () => {
+ expect(onboardingSource).toContain("? props.preOrg\n ? t('app-onboarding-continue')\n : t('app-onboarding-finish-details')")
})
it.concurrent('does not send raw onboarding field values to analytics', () => {
- expect(onboardingSource).toContain("detailsFieldTracker.schedule('onboarding_app_name_entered', 'app_name'")
- expect(onboardingSource).toContain("detailsFieldTracker.schedule('onboarding_app_id_entered', 'app_id'")
- expect(onboardingSource).toContain("detailsFieldTracker.schedule('onboarding_store_url_entered', 'store_url'")
+ expect(onboardingSource).toContain("detailsFieldTracker.schedule('onboarding_app_name_entered', 'app_name', 'app_name'")
+ expect(onboardingSource).toContain("detailsFieldTracker.schedule('onboarding_app_id_entered', 'app_id', 'app_id'")
+ expect(onboardingSource).toContain("detailsFieldTracker.schedule('onboarding_store_url_entered', 'store_url', 'app_id'")
+ expect(onboardingSource).toContain("detailsFieldTracker.schedule('onboarding_store_icon_url_entered', 'icon_store_url', 'app_icon'")
+ expect(onboardingSource).toContain('@input="onIconStoreUrlInput"')
expect(onboardingSource).not.toContain('{ app_name:')
expect(onboardingSource).not.toContain('{ store_url:')
})
+ it.concurrent('maps the visual details section to page-level analytics steps', () => {
+ expect(onboardingSource).toContain('function analyticsStepFor(')
+ expect(onboardingSource).toContain('return APP_DETAILS_ANALYTICS_STEPS[detailsStep]')
+ expect(onboardingSource).toContain(`const initialStep: OnboardingAnalyticsStep = showPreOrgWelcome.value ? 'welcome' : analyticsStepFor(flowStep.value)`)
+ expect(onboardingSource).toContain('progressTracker.viewStep(initialStep)')
+ expect(onboardingSource).toContain('const previousAnalyticsStep = analyticsStepFor(previousStep)')
+ expect(onboardingSource).toContain('const nextAnalyticsStep = analyticsStepFor(nextStep)')
+ })
+
it.concurrent('tracks native app icon picker and upload outcomes', () => {
expect(iconInputSource).toContain("emit('pickerOpened')")
expect(iconInputSource).toContain("emit('pickerOpenFailed')")
@@ -42,6 +195,8 @@ describe('pre-organization onboarding v3', () => {
expect(onboardingSource).toContain("trackDetailsEvent('onboarding_app_icon_uploaded'")
expect(onboardingSource).toContain("trackDetailsEvent('onboarding_app_icon_upload_failed'")
expect(onboardingSource).toContain("parsedIconUrl.protocol === 'data:' && iconSourceUrl.startsWith('data:image/')")
+ expect(onboardingSource).toContain("const restoredLocalIconSource = localIconPreview.value.startsWith('data:image/')")
+ expect(onboardingSource).toContain('await uploadIcon(appId, restoredLocalIconSource || importedIconSource)')
})
it.concurrent('opens App ID guidance and tracks only the open action', () => {
@@ -49,6 +204,9 @@ describe('pre-organization onboarding v3', () => {
expect(appIdHelp).toContain("trackDetailsEvent('onboarding_app_id_help_opened')")
expect(appIdHelp).toContain('dialogStore.openDialog({')
expect(appIdHelp).not.toContain('onDialogDismiss')
+ expect(onboardingSource).toContain('class="mt-2 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm leading-6')
+ expect(onboardingSource).toContain('keypath="app-onboarding-app-id-generated-helper" tag="span"')
+ expect(onboardingSource).toContain("t('app-onboarding-app-id-learn-more')")
})
it.concurrent('imports organization details and branches to invitations above Solo', () => {
@@ -96,6 +254,37 @@ describe('pre-organization onboarding v3', () => {
expect(creation.indexOf('await createAppRecord(')).toBeLessThan(creation.indexOf('showOrganizationInvite.value = shouldInvite'))
})
+ it.concurrent('retries only app creation after an App ID conflict created the organization', () => {
+ const finishDetails = onboardingSource.slice(
+ onboardingSource.indexOf('function finishAppDetails()'),
+ onboardingSource.indexOf('function returnToAppIdAfterConflict()'),
+ )
+ expect(finishDetails).toContain('preOrgCreatedOrganizationId.value')
+ expect(finishDetails).toContain('completePreOrgAppCreation(preOrgCreatedOrganizationId.value, preOrgShouldInvite.value)')
+
+ const conflictRecovery = onboardingSource.slice(
+ onboardingSource.indexOf('function returnToAppIdAfterConflict()'),
+ onboardingSource.indexOf('async function uploadIcon('),
+ )
+ expect(conflictRecovery).toContain("flowStep.value = 'details'")
+ expect(conflictRecovery).toContain("appDetailsStep.value = 'app_id'")
+ expect(conflictRecovery).toContain("progressTracker?.viewStep('app_id', previousAnalyticsStep)")
+
+ const organizationCreation = onboardingSource.slice(
+ onboardingSource.indexOf('async function createOrganizationAndApp()'),
+ onboardingSource.indexOf('async function createAppRecord('),
+ )
+ expect(organizationCreation).toContain('preOrgCreatedOrganizationId.value = data.id')
+ expect(organizationCreation).toContain('await completePreOrgAppCreation(data.id, shouldInvite)')
+ expect(organizationCreation).toContain("await createAppRecord({ nextStep: shouldInvite ? 'organization' : 'setup' })")
+
+ const appCreation = onboardingSource.slice(
+ onboardingSource.indexOf('async function createAppRecord('),
+ onboardingSource.indexOf('async function seedDemoData()'),
+ )
+ expect(appCreation).toContain('returnToAppIdAfterConflict()')
+ })
+
it.concurrent('shows technical delegation unconditionally on pre-org setup', () => {
expect(onboardingSource).toContain("flowStep === 'setup' && createdApp")
expect(onboardingSource).toContain("!props.preOrg && flowStep === 'choice'")
diff --git a/tests/frontend-onboarding-analytics.unit.test.ts b/tests/frontend-onboarding-analytics.unit.test.ts
index f11d3e492e..7b0607d2cf 100644
--- a/tests/frontend-onboarding-analytics.unit.test.ts
+++ b/tests/frontend-onboarding-analytics.unit.test.ts
@@ -48,7 +48,13 @@ describe('buildFrontendOnboardingHogql', () => {
'2026-08-04T00:00:00.789Z',
)
- expect(query).toContain("event IN ('onboarding_step_viewed', 'onboarding_ai_instructions_copied', 'onboarding_app_id_entered'")
+ expect(query).toContain("event IN ('onboarding_step_viewed', 'onboarding_ai_instructions_copied'")
+ expect(query).toContain("'onboarding_app_id_entered'")
+ expect(query).toContain("'onboarding_app_creation_succeeded'")
+ expect(query).not.toContain("'onboarding_app_details_step_completed'")
+ expect(query).not.toContain("'onboarding_app_details_step_viewed'")
+ expect(query).toContain("'onboarding_store_icon_import_succeeded'")
+ expect(query).toContain("'onboarding_store_icon_url_entered'")
expect(query).toContain("'onboarding_organization_import_opened'")
expect(query).toContain("'onboarding_organization_invite_succeeded'")
expect(query).toContain("'onboarding_technical_invite_succeeded'")
@@ -68,7 +74,7 @@ describe('buildFrontendOnboardingHogql', () => {
expect(query).toContain('event = \'Builder Onboarding Step\'')
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 = \'details\'))')
+ 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 = \'organization\'))')
expect(query).toContain('toUnixTimestamp64Milli(minIf(timestamp, event = \'onboarding_step_viewed\' AND step = \'setup\'))')
expect(query).toContain('groupUniqArrayIf(tuple(event, toUnixTimestamp64Milli(timestamp)), event IN (')
diff --git a/tests/onboarding-details-field-debouncer.unit.test.ts b/tests/onboarding-details-field-debouncer.unit.test.ts
index edccf28427..a173326847 100644
--- a/tests/onboarding-details-field-debouncer.unit.test.ts
+++ b/tests/onboarding-details-field-debouncer.unit.test.ts
@@ -11,15 +11,15 @@ describe('onboarding details field analytics debounce', () => {
const emit = vi.fn()
const tracker = createOnboardingDetailsFieldDebouncer(emit)
- tracker.schedule('onboarding_app_name_entered', 'app_name', 'Example')
+ tracker.schedule('onboarding_app_name_entered', 'app_name', 'app_name', 'Example')
vi.advanceTimersByTime(750)
- tracker.schedule('onboarding_app_name_entered', 'app_name', ' Example App ')
+ tracker.schedule('onboarding_app_name_entered', 'app_name', 'app_name', ' Example App ')
vi.advanceTimersByTime(999)
expect(emit).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(emit).toHaveBeenCalledOnce()
- expect(emit).toHaveBeenCalledWith('onboarding_app_name_entered', { field_length: 11 })
+ expect(emit).toHaveBeenCalledWith('onboarding_app_name_entered', 'app_name', { field_length: 11 })
})
it('tracks fields independently, cancels empty values, and flushes pending values on dispose', () => {
@@ -27,20 +27,37 @@ describe('onboarding details field analytics debounce', () => {
const emit = vi.fn()
const tracker = createOnboardingDetailsFieldDebouncer(emit)
- tracker.schedule('onboarding_app_name_entered', 'app_name', 'Discard me')
- tracker.schedule('onboarding_app_name_entered', 'app_name', ' ')
- tracker.schedule('onboarding_app_id_entered', 'app_id', 'com.example.app')
- tracker.schedule('onboarding_store_url_entered', 'store_url', 'https://apps.apple.com/example')
+ tracker.schedule('onboarding_app_name_entered', 'app_name', 'app_name', 'Discard me')
+ tracker.schedule('onboarding_app_name_entered', 'app_name', 'app_name', ' ')
+ tracker.schedule('onboarding_app_id_entered', 'app_id', 'app_id', 'com.example.app')
+ tracker.schedule('onboarding_store_url_entered', 'store_url', 'app_id', 'https://apps.apple.com/example')
+ const iconStoreUrl = 'https://play.google.com/example'
+ tracker.schedule('onboarding_store_icon_url_entered', 'icon_store_url', 'app_icon', iconStoreUrl)
vi.advanceTimersByTime(1_000)
- expect(emit).toHaveBeenCalledTimes(2)
- expect(emit).toHaveBeenCalledWith('onboarding_app_id_entered', { field_length: 15 })
- expect(emit).toHaveBeenCalledWith('onboarding_store_url_entered', { field_length: 30 })
+ expect(emit).toHaveBeenCalledTimes(3)
+ expect(emit).toHaveBeenCalledWith('onboarding_app_id_entered', 'app_id', { field_length: 15 })
+ expect(emit).toHaveBeenCalledWith('onboarding_store_url_entered', 'app_id', { field_length: 30 })
+ expect(emit).toHaveBeenCalledWith('onboarding_store_icon_url_entered', 'app_icon', { field_length: iconStoreUrl.length })
- tracker.schedule('onboarding_app_name_entered', 'app_name', 'Never emitted')
+ tracker.schedule('onboarding_app_name_entered', 'app_name', 'app_name', 'Never emitted')
tracker.dispose()
vi.runAllTimers()
- expect(emit).toHaveBeenCalledTimes(3)
- expect(emit).toHaveBeenLastCalledWith('onboarding_app_name_entered', { field_length: 13 })
+ expect(emit).toHaveBeenCalledTimes(4)
+ expect(emit).toHaveBeenLastCalledWith('onboarding_app_name_entered', 'app_name', { field_length: 13 })
+ })
+
+ it('keeps the page step captured before a user continues during the debounce', () => {
+ vi.useFakeTimers()
+ const emit = vi.fn()
+ const tracker = createOnboardingDetailsFieldDebouncer(emit)
+ let currentPage: 'app_id' | 'app_name' = 'app_name'
+
+ tracker.schedule('onboarding_app_name_entered', 'app_name', currentPage, 'Example App')
+ currentPage = 'app_id'
+ vi.advanceTimersByTime(1_000)
+
+ expect(currentPage).toBe('app_id')
+ expect(emit).toHaveBeenCalledWith('onboarding_app_name_entered', 'app_name', { field_length: 11 })
})
})
diff --git a/tests/onboarding-progress-analytics.unit.test.ts b/tests/onboarding-progress-analytics.unit.test.ts
index 6d742b8692..4af929eb1e 100644
--- a/tests/onboarding-progress-analytics.unit.test.ts
+++ b/tests/onboarding-progress-analytics.unit.test.ts
@@ -3,6 +3,7 @@ import {
createOnboardingProgressTracker,
createOnboardingTelemetryIdentity,
ONBOARDING_ANALYTICS_VERSION,
+ resolveOnboardingAppIconSource,
} from '../src/utils/onboardingProgressAnalytics'
const steps = ['intent', 'details', 'organization', 'setup'] as const
@@ -17,6 +18,26 @@ const trackerIdentity = {
}
describe('onboarding progress analytics', () => {
+ it.concurrent('classifies restored draft icon data as a file source', () => {
+ expect(resolveOnboardingAppIconSource({
+ canUseStoreImportPreview: true,
+ hasSelectedIconFile: false,
+ localIconPreview: 'data:image/png;base64,restored-icon',
+ })).toBe('file')
+
+ expect(resolveOnboardingAppIconSource({
+ canUseStoreImportPreview: true,
+ hasSelectedIconFile: false,
+ localIconPreview: 'https://capgo.test/restored-icon.png',
+ })).toBe('store')
+
+ expect(resolveOnboardingAppIconSource({
+ canUseStoreImportPreview: false,
+ hasSelectedIconFile: false,
+ localIconPreview: '',
+ })).toBe('none')
+ })
+
it.concurrent('keeps the resumed attempt while continuing from saved progress', () => {
const capture = vi.fn()
const ids = [ATTEMPT_A2, RUN_R2_UUID]
@@ -271,7 +292,7 @@ describe('onboarding progress analytics', () => {
supaHost: 'https://supabase.capgo.test',
})
- tracker.trackDetailsEvent('onboarding_app_name_entered', { field_length: 11 })
+ tracker.trackDetailsEvent('onboarding_app_name_entered', 'details', { field_length: 11 })
expect(capture).toHaveBeenCalledWith(
'onboarding_app_name_entered',
@@ -287,6 +308,64 @@ describe('onboarding progress analytics', () => {
)
})
+ it.concurrent('associates app-details interactions with their page-level analytics step', () => {
+ const capture = vi.fn()
+ const pageSteps = ['intent', 'app_name', 'app_id', 'app_icon', 'organization', 'setup'] as const
+ const tracker = createOnboardingProgressTracker({
+ ...trackerIdentity,
+ capture,
+ flow: 'pre_org',
+ resumed: false,
+ steps: pageSteps,
+ supaHost: 'https://supabase.capgo.test',
+ })
+
+ tracker.viewStep('app_name')
+ capture.mockClear()
+ tracker.trackDetailsEvent('onboarding_app_name_entered', 'app_name', { field_length: 11 })
+
+ expect(capture).toHaveBeenCalledWith(
+ 'onboarding_app_name_entered',
+ 'https://supabase.capgo.test',
+ expect.objectContaining({
+ field_length: 11,
+ step: 'app_name',
+ step_index: 1,
+ total_steps: 6,
+ }),
+ )
+ })
+
+ it.concurrent('associates app creation outcomes with sanitized choice metadata', () => {
+ const capture = vi.fn()
+ const tracker = createOnboardingProgressTracker({
+ ...trackerIdentity,
+ capture,
+ flow: 'pre_org',
+ resumed: false,
+ steps,
+ supaHost: 'https://supabase.capgo.test',
+ })
+
+ tracker.trackDetailsEvent('onboarding_app_creation_succeeded', 'details', {
+ app_id_source: 'generated',
+ has_icon: true,
+ icon_source: 'store',
+ used_fallback: false,
+ })
+
+ expect(capture).toHaveBeenCalledWith(
+ 'onboarding_app_creation_succeeded',
+ 'https://supabase.capgo.test',
+ expect.objectContaining({
+ app_id_source: 'generated',
+ has_icon: true,
+ icon_source: 'store',
+ used_fallback: false,
+ }),
+ )
+ })
+
it.concurrent('associates organization interactions with the active attempt', () => {
const capture = vi.fn()
const tracker = createOnboardingProgressTracker({
diff --git a/tests/user-onboarding-progress.unit.test.ts b/tests/user-onboarding-progress.unit.test.ts
index 7ab54992e1..7a7901f575 100644
--- a/tests/user-onboarding-progress.unit.test.ts
+++ b/tests/user-onboarding-progress.unit.test.ts
@@ -22,6 +22,7 @@ describe('user onboarding progress', () => {
step: 'organization',
flow: 'pre_org',
intent: 'ota',
+ details_step: 'icon',
app_name: 'Acme',
app_id: 'com.acme.app',
existing_app: false,
@@ -34,6 +35,7 @@ describe('user onboarding progress', () => {
step: 'organization',
flow: 'pre_org',
intent: 'ota',
+ details_step: 'icon',
app_name: 'Acme',
app_id: 'com.acme.app',
existing_app: false,
@@ -66,6 +68,7 @@ describe('user onboarding progress', () => {
step: 'details',
flow: 'pre_org',
intent: 'builder',
+ detailsStep: 'app_id',
appName: ' Hello ',
appId: '',
existingApp: true,
@@ -82,6 +85,7 @@ describe('user onboarding progress', () => {
step: 'details',
flow: 'pre_org',
intent: 'builder',
+ details_step: 'app_id',
app_name: 'Hello',
existing_app: true,
existing_app_setup: 'import',
@@ -108,6 +112,13 @@ describe('user onboarding progress', () => {
intent: 'ota',
updatedAt: '2026-08-15T00:00:00.000Z',
}), 'pre_org')).toBe(true)
+ expect(shouldPromptOnboardingResume(buildUserOnboardingProgress({
+ status: 'in_progress',
+ step: 'details',
+ flow: 'existing_org',
+ detailsStep: 'app_id',
+ updatedAt: '2026-08-15T00:00:00.000Z',
+ }), 'existing_org')).toBe(true)
expect(shouldPromptOnboardingResume(buildUserOnboardingProgress({
status: 'in_progress',
step: 'organization',
From 8c6fc5b8a7e553af89071e011444ff37393da3d2 Mon Sep 17 00:00:00 2001
From: WcaleNieWolny <50914789+WcaleNieWolny@users.noreply.github.com>
Date: Tue, 18 Aug 2026 16:56:20 +0200
Subject: [PATCH 03/10] feat(onboarding): bump analytics version to v4 (#3120)
* feat(onboarding): bump analytics version to v4
* fix(analytics): surface onboarding v4
* test(admin): expect onboarding v4 charts
* fix(analytics): preserve v3 deduplicated funnel
---
messages/en.context.json | 6 ++
messages/en.json | 12 ++-
.../admin/dashboard/frontend-onboarding.vue | 99 ++++++++++---------
src/services/adminFrontendOnboarding.ts | 18 +++-
src/utils/onboardingProgressAnalytics.ts | 2 +-
.../frontend_onboarding_analytics_model.ts | 43 +++++---
...end_onboarding_daily_setup_cli_outcomes.ts | 8 +-
tests/admin-chart-card-collapse.unit.test.ts | 4 +-
...frontend-onboarding-dashboard.unit.test.ts | 96 ++++++++++++------
...nd-onboarding-analytics-model.unit.test.ts | 81 +++++++++------
...frontend-onboarding-analytics.unit.test.ts | 26 ++---
...ding-daily-setup-cli-outcomes.unit.test.ts | 4 +-
...onboarding-progress-analytics.unit.test.ts | 2 +-
13 files changed, 255 insertions(+), 146 deletions(-)
diff --git a/messages/en.context.json b/messages/en.context.json
index bb705f2a91..76508fb5f7 100644
--- a/messages/en.context.json
+++ b/messages/en.context.json
@@ -1492,6 +1492,7 @@
"frontend-onboarding-daily-setup-cli-no-copy-other-cli": "Used in Capgo web console areas: pages/admin/dashboard. Role: chart category label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"frontend-onboarding-daily-setup-cli-outcomes-description": "Used in Capgo web console areas: pages/admin/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"frontend-onboarding-daily-setup-cli-outcomes-v2-v3": "Used in Capgo web console areas: pages/admin/dashboard. Role: chart title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "frontend-onboarding-daily-setup-cli-outcomes-v2-v4": "Used in Capgo web console areas: pages/admin/dashboard. Role: chart title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"frontend-onboarding-daily-setup-cli-returning": "Used in Capgo web console areas: pages/admin/dashboard. Role: chart lifecycle label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"frontend-onboarding-deduplicate-by-user": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"frontend-onboarding-deduplicate-by-user-chart": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
@@ -1499,6 +1500,7 @@
"frontend-onboarding-funnel-v1-legacy": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"frontend-onboarding-funnel-v2": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"frontend-onboarding-funnel-v3": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "frontend-onboarding-funnel-v4": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"frontend-onboarding-graph-app-icon-picked": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"frontend-onboarding-graph-app-icon-uploaded": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"frontend-onboarding-graph-app-id-entered": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
@@ -1536,6 +1538,8 @@
"frontend-onboarding-graph-v2-description": "Used in Capgo web console areas: pages/admin/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"frontend-onboarding-graph-v3": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"frontend-onboarding-graph-v3-description": "Used in Capgo web console areas: pages/admin/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "frontend-onboarding-graph-v4": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "frontend-onboarding-graph-v4-description": "Used in Capgo web console areas: pages/admin/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"frontend-onboarding-largest-dropoff": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"frontend-onboarding-load-error": "Used in Capgo web console areas: pages/admin/dashboard. Role: error message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"frontend-onboarding-median-time": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
@@ -1546,11 +1550,13 @@
"frontend-onboarding-setup-cli-only": "Used in Capgo web console areas: pages/admin/dashboard. Role: chart category label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"frontend-onboarding-setup-cli-outcomes-description": "Used in Capgo web console areas: pages/admin/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"frontend-onboarding-setup-cli-outcomes-v2-v3": "Used in Capgo web console areas: pages/admin/dashboard. Role: chart title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "frontend-onboarding-setup-cli-outcomes-v2-v4": "Used in Capgo web console areas: pages/admin/dashboard. Role: chart title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"frontend-onboarding-setup-no-cli": "Used in Capgo web console areas: pages/admin/dashboard. Role: chart category label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"frontend-onboarding-transition": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"frontend-onboarding-version-1": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"frontend-onboarding-version-2": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"frontend-onboarding-version-3": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "frontend-onboarding-version-4": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"general": "Used in Capgo web console areas: constants. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"general-information": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"generated-new-apikey": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
diff --git a/messages/en.json b/messages/en.json
index e2f200d6bd..8b7dfb70cf 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2985,6 +2985,7 @@
"frontend-onboarding-version-1": "Onboarding v1",
"frontend-onboarding-version-2": "Onboarding v2",
"frontend-onboarding-version-3": "Onboarding v3",
+ "frontend-onboarding-version-4": "Onboarding v4",
"frontend-onboarding-attempts": "Onboarding attempts",
"frontend-onboarding-attempts-subtitle": "Unique onboarding attempts",
"frontend-onboarding-completed": "Onboarding completed",
@@ -2998,17 +2999,20 @@
"frontend-onboarding-deduplicate-by-user-chart": "De-duplicate by user: {chart}",
"frontend-onboarding-daily-conversion": "Conversion",
"frontend-onboarding-daily-conversion-attempts": "attempts",
- "frontend-onboarding-daily-details-to-organization": "Daily App details → Organization conversion (v3)",
- "frontend-onboarding-daily-intent-to-details": "Daily Intent → App details conversion (v1, v2, and v3)",
- "frontend-onboarding-daily-organization-to-setup": "Daily Organization → Setup reached conversion (v3)",
+ "frontend-onboarding-daily-details-to-organization": "Daily App details → Organization conversion (v4)",
+ "frontend-onboarding-daily-intent-to-details": "Daily Intent → App details conversion (v1–v4)",
+ "frontend-onboarding-daily-organization-to-setup": "Daily Organization → Setup reached conversion (v4)",
"frontend-onboarding-funnel-v2": "Onboarding funnel (v2)",
"frontend-onboarding-funnel-v3": "Frontend onboarding funnel (v3)",
+ "frontend-onboarding-funnel-v4": "Frontend onboarding funnel (v4)",
"frontend-onboarding-funnel-v1-legacy": "Onboarding funnel (v1, legacy)",
"frontend-onboarding-funnel-description": "Progress through the new-user app-creation wizard",
"frontend-onboarding-graph-v2": "Onboarding graph (v2)",
"frontend-onboarding-graph-v2-description": "Explore behavior inside each onboarding stage",
"frontend-onboarding-graph-v3": "Frontend onboarding graph (v3)",
"frontend-onboarding-graph-v3-description": "Explore behavior inside each onboarding stage",
+ "frontend-onboarding-graph-v4": "Frontend onboarding graph (v4)",
+ "frontend-onboarding-graph-v4-description": "Explore behavior inside each onboarding stage",
"frontend-onboarding-graph-stage-intent": "Intent",
"frontend-onboarding-graph-stage-app-details": "App details",
"frontend-onboarding-graph-stage-organization-details": "Organization details",
@@ -3043,11 +3047,13 @@
"frontend-onboarding-graph-percent-of-previous": "{percent}% of previous",
"frontend-onboarding-graph-percent-of-parent-stage": "{percent}% of parent stage",
"frontend-onboarding-setup-cli-outcomes-v2-v3": "Setup → CLI outcomes (v2 and v3)",
+ "frontend-onboarding-setup-cli-outcomes-v2-v4": "Setup → CLI outcomes (v2–v4)",
"frontend-onboarding-setup-cli-outcomes-description": "Unique people who reached setup, grouped by their CLI and copied AI instruction activity within 24 hours",
"frontend-onboarding-setup-cli-only": "Started CLI",
"frontend-onboarding-setup-cli-and-ai": "Started CLI + AI instructions",
"frontend-onboarding-setup-no-cli": "Didn't start CLI",
"frontend-onboarding-daily-setup-cli-outcomes-v2-v3": "Daily Setup → CLI outcomes (v2 and v3)",
+ "frontend-onboarding-daily-setup-cli-outcomes-v2-v4": "Daily Setup → CLI outcomes (v2–v4)",
"frontend-onboarding-daily-setup-cli-outcomes-description": "Each person is counted once per UTC day. Left: first-time Setup views; right: returning views. Actions are attributed for up to 24 hours.",
"frontend-onboarding-daily-setup-cli-first-time": "First-time",
"frontend-onboarding-daily-setup-cli-returning": "Returning",
diff --git a/src/pages/admin/dashboard/frontend-onboarding.vue b/src/pages/admin/dashboard/frontend-onboarding.vue
index c1815eecd3..1a59b21de2 100644
--- a/src/pages/admin/dashboard/frontend-onboarding.vue
+++ b/src/pages/admin/dashboard/frontend-onboarding.vue
@@ -47,9 +47,9 @@ const isReady = ref(false)
const analytics = ref(null)
const loadError = ref(false)
const deduplicateDailyAttempts = ref(false)
-const deduplicateV3Funnel = ref(false)
+const deduplicateV4Funnel = ref(false)
-const v3DetailsGraphDefinitions = [
+const v4DetailsGraphDefinitions = [
{ key: 'onboarding_app_name_entered' },
{ key: 'onboarding_app_id_entered' },
{ key: 'onboarding_app_id_help_opened' },
@@ -67,7 +67,7 @@ const v3DetailsGraphDefinitions = [
{ key: 'onboarding_app_icon_upload_failed', parentKey: 'onboarding_app_icon_picked' },
] as const
-const v3OrganizationGraphDefinitions = [
+const v4OrganizationGraphDefinitions = [
{ key: 'onboarding_organization_import_opened' },
{ key: 'onboarding_organization_import_submitted', parentKey: 'onboarding_organization_import_opened' },
{ key: 'onboarding_organization_import_succeeded', parentKey: 'onboarding_organization_import_submitted' },
@@ -78,12 +78,12 @@ const v3OrganizationGraphDefinitions = [
{ key: 'onboarding_organization_invite_continued', parentKey: 'onboarding_organization_invite_viewed' },
] as const
-const v3SetupGraphDefinitions = [
+const v4SetupGraphDefinitions = [
{ key: 'onboarding_technical_invite_opened' },
{ key: 'onboarding_technical_invite_succeeded', parentKey: 'onboarding_technical_invite_opened' },
] as const
-const v3DetailsGraphEventNodes = [
+const v4DetailsGraphEventNodes = [
{ id: 'app_name', eventKey: 'onboarding_app_name_entered', labelKey: 'frontend-onboarding-graph-app-name-entered', x: 820, y: 90, icon: 'app' },
{ id: 'app_id', eventKey: 'onboarding_app_id_entered', labelKey: 'frontend-onboarding-graph-app-id-entered', x: 820, y: 195, icon: 'file' },
{ id: 'learn_more', eventKey: 'onboarding_app_id_help_opened', labelKey: 'frontend-onboarding-graph-app-id-help-opened', x: 820, y: 300, icon: 'details' },
@@ -101,7 +101,7 @@ const v3DetailsGraphEventNodes = [
{ id: 'icon_upload_failed', eventKey: 'onboarding_app_icon_upload_failed', labelKey: 'frontend-onboarding-graph-icon-upload-failed', x: 1540, y: 870, icon: 'upload', tone: 'danger' },
] as const
-const v3OrganizationGraphEventNodes = [
+const v4OrganizationGraphEventNodes = [
{ id: 'organization_import_opened', eventKey: 'onboarding_organization_import_opened', labelKey: 'frontend-onboarding-graph-organization-import-opened', x: 2800, y: 160, icon: 'import' },
{ id: 'organization_import_submitted', eventKey: 'onboarding_organization_import_submitted', labelKey: 'frontend-onboarding-graph-organization-import-submitted', x: 3160, y: 160, icon: 'import' },
{ id: 'organization_import_succeeded', eventKey: 'onboarding_organization_import_succeeded', labelKey: 'frontend-onboarding-graph-organization-import-succeeded', x: 3520, y: 110, icon: 'success', tone: 'success' },
@@ -112,7 +112,7 @@ const v3OrganizationGraphEventNodes = [
{ id: 'organization_invite_continued', eventKey: 'onboarding_organization_invite_continued', labelKey: 'frontend-onboarding-graph-organization-invite-continued', x: 3880, y: 700, icon: 'setup' },
] as const
-const v3SetupGraphEventNodes = [
+const v4SetupGraphEventNodes = [
{ id: 'technical_invite_opened', eventKey: 'onboarding_technical_invite_opened', labelKey: 'frontend-onboarding-graph-technical-invite-opened', x: 4950, y: 450, icon: 'organization' },
{ id: 'technical_invite_succeeded', eventKey: 'onboarding_technical_invite_succeeded', labelKey: 'frontend-onboarding-graph-technical-invite-succeeded', x: 5300, y: 450, icon: 'success', tone: 'success' },
] as const
@@ -122,7 +122,10 @@ const loadAnalytics = createFrontendOnboardingAnalyticsLoader(
const result = await adminStore.fetchStats('frontend_onboarding_analytics')
if (result && (
!Array.isArray(result.deduplicated?.daily_attempts)
- || !Array.isArray(result.deduplicated?.funnels?.v3)
+ || (
+ !Array.isArray(result.deduplicated?.funnels?.v4)
+ && !Array.isArray(result.deduplicated?.funnels?.v3)
+ )
)) {
throw new Error('Frontend onboarding analytics response is missing deduplicated chart data')
}
@@ -151,26 +154,32 @@ const visibleAnalytics = computed(() => isLoadingStats.value ? null : analytics.
const displayedDailyAttempts = computed(() => deduplicateDailyAttempts.value
? visibleAnalytics.value?.deduplicated.daily_attempts ?? []
: visibleAnalytics.value?.daily_attempts ?? [])
-const displayedV3Funnel = computed(() => deduplicateV3Funnel.value
- ? visibleAnalytics.value?.deduplicated.funnels.v3 ?? []
- : visibleAnalytics.value?.funnels.v3 ?? [])
+const displayedV4Funnel = computed(() => {
+ if (!deduplicateV4Funnel.value)
+ return visibleAnalytics.value?.funnels.v4 ?? []
+
+ return visibleAnalytics.value?.deduplicated.funnels.v4
+ ?? visibleAnalytics.value?.deduplicated.funnels.v3
+ ?? []
+})
const kpis = computed(() => visibleAnalytics.value?.kpis)
const dailySeries = computed(() => buildFrontendOnboardingDailySeries(
displayedDailyAttempts.value,
t('frontend-onboarding-version-1'),
t('frontend-onboarding-version-2'),
t('frontend-onboarding-version-3'),
+ t('frontend-onboarding-version-4'),
))
const intentToDetailsDaily = computed(() => visibleAnalytics.value?.daily_conversions?.intent_to_details ?? [])
const detailsToOrganizationDaily = computed(() => visibleAnalytics.value?.daily_conversions?.details_to_organization ?? [])
const organizationToSetupDaily = computed(() => visibleAnalytics.value?.daily_conversions?.organization_to_setup ?? [])
const hasConversionData = (points: readonly { started: number }[]) => points.some(point => point.started > 0)
const v1FunnelStages = computed(() => buildFrontendOnboardingFunnelStages(visibleAnalytics.value?.funnels.v1 ?? []))
-const v3FunnelStages = computed(() => buildFrontendOnboardingFunnelStages(displayedV3Funnel.value))
+const v4FunnelStages = computed(() => buildFrontendOnboardingFunnelStages(displayedV4Funnel.value))
const v1FunnelSummaries = computed(() => buildFrontendOnboardingFunnelSummaries(visibleAnalytics.value?.funnels.v1 ?? []))
-const v3FunnelSummaries = computed(() => buildFrontendOnboardingFunnelSummaries(displayedV3Funnel.value))
+const v4FunnelSummaries = computed(() => buildFrontendOnboardingFunnelSummaries(displayedV4Funnel.value))
const hasDailyAttempts = computed(() => displayedDailyAttempts.value
- .some(day => day.v1_attempts > 0 || day.v2_attempts > 0 || day.v3_attempts > 0))
+ .some(day => day.v1_attempts > 0 || day.v2_attempts > 0 || day.v3_attempts > 0 || day.v4_attempts > 0))
const setupCliOutcomes = computed(() => visibleAnalytics.value?.v2_v3_setup_cli_outcomes ?? {
total_users: 0,
cli_only: 0,
@@ -211,22 +220,22 @@ const dailySetupCliSeries = computed(() => buildFrontendOnboardingDailySetupCliS
))
const hasDailySetupCliOutcomeData = computed(() => dailySetupCliSeries.value.length > 0)
-const onboardingGraphV3 = computed(() => {
- const funnel = visibleAnalytics.value?.funnels.v3 ?? []
- const stage = (key: FrontendOnboardingAnalytics['funnels']['v3'][number]['key']) => funnel.find(item => item.key === key)
+const onboardingGraphV4 = computed(() => {
+ const funnel = visibleAnalytics.value?.funnels.v4 ?? []
+ const stage = (key: FrontendOnboardingAnalytics['funnels']['v4'][number]['key']) => funnel.find(item => item.key === key)
const intent = stage('intent')
const details = stage('details')
const organization = stage('organization')
const setup = stage('setup')
const parentPercent = (current: number, previous: number) => previous > 0 ? current / previous * 100 : 0
- const interactionNodes = visibleAnalytics.value?.v3_graph.nodes ?? []
+ const interactionNodes = visibleAnalytics.value?.v4_graph.nodes ?? []
const graphMetrics = {
- ...buildFrontendOnboardingGraphMetrics(v3DetailsGraphDefinitions, interactionNodes, details?.reached),
- ...buildFrontendOnboardingGraphMetrics(v3OrganizationGraphDefinitions, interactionNodes, organization?.reached),
- ...buildFrontendOnboardingGraphMetrics(v3SetupGraphDefinitions, interactionNodes, setup?.reached),
+ ...buildFrontendOnboardingGraphMetrics(v4DetailsGraphDefinitions, interactionNodes, details?.reached),
+ ...buildFrontendOnboardingGraphMetrics(v4OrganizationGraphDefinitions, interactionNodes, organization?.reached),
+ ...buildFrontendOnboardingGraphMetrics(v4SetupGraphDefinitions, interactionNodes, setup?.reached),
}
const mapEventNodes = (
- nodes: readonly (typeof v3DetailsGraphEventNodes[number] | typeof v3OrganizationGraphEventNodes[number] | typeof v3SetupGraphEventNodes[number])[],
+ nodes: readonly (typeof v4DetailsGraphEventNodes[number] | typeof v4OrganizationGraphEventNodes[number] | typeof v4SetupGraphEventNodes[number])[],
levelLabelKey: string,
): AdminOnboardingJourneyNode[] => nodes.map((node) => {
const metric = graphMetrics[node.eventKey]
@@ -245,9 +254,9 @@ const onboardingGraphV3 = computed(() => {
}
})
const eventNodes = [
- ...mapEventNodes(v3DetailsGraphEventNodes, 'frontend-onboarding-graph-stage-app-details'),
- ...mapEventNodes(v3OrganizationGraphEventNodes, 'frontend-onboarding-graph-stage-organization-details'),
- ...mapEventNodes(v3SetupGraphEventNodes, 'frontend-onboarding-graph-stage-setup-reached'),
+ ...mapEventNodes(v4DetailsGraphEventNodes, 'frontend-onboarding-graph-stage-app-details'),
+ ...mapEventNodes(v4OrganizationGraphEventNodes, 'frontend-onboarding-graph-stage-organization-details'),
+ ...mapEventNodes(v4SetupGraphEventNodes, 'frontend-onboarding-graph-stage-setup-reached'),
]
return {
@@ -380,7 +389,7 @@ const largestDropoffSubtitle = computed(() => {
if (!dropoff)
return t('frontend-onboarding-no-dropoff')
- const stages = visibleAnalytics.value?.funnels.v3 ?? []
+ const stages = visibleAnalytics.value?.funnels.v4 ?? []
const from = stages.find(stage => stage.key === dropoff.from)?.label ?? dropoff.from
const to = stages.find(stage => stage.key === dropoff.to)?.label ?? dropoff.to
return t('frontend-onboarding-transition', { from, to })
@@ -421,7 +430,7 @@ displayStore.defaultBack = '/dashboard'
{{ t('frontend-onboarding') }}
- {{ t('frontend-onboarding-version-3') }}
+ {{ t('frontend-onboarding-version-4') }}
@@ -475,14 +484,14 @@ displayStore.defaultBack = '/dashboard'
- {{ t('frontend-onboarding-funnel-v3') }}
+ {{ t('frontend-onboarding-funnel-v4') }}
{{ t('frontend-onboarding-funnel-description') }}
@@ -490,10 +499,10 @@ displayStore.defaultBack = '/dashboard'
-
+
-
+
{{ formatNumberValue(summary.conversion_percent) }}%
@@ -503,8 +512,8 @@ displayStore.defaultBack = '/dashboard'
@@ -554,28 +563,28 @@ displayStore.defaultBack = '/dashboard'
- {{ t('frontend-onboarding-graph-v3') }}
+ {{ t('frontend-onboarding-graph-v4') }}
- {{ t('frontend-onboarding-graph-v3-description') }}
+ {{ t('frontend-onboarding-graph-v4-description') }}
-
+
- {{ t('frontend-onboarding-setup-cli-outcomes-v2-v3') }}
+ {{ t('frontend-onboarding-setup-cli-outcomes-v2-v4') }}
{{ t('frontend-onboarding-setup-cli-outcomes-description') }}
@@ -604,15 +613,15 @@ displayStore.defaultBack = '/dashboard'
- {{ t('frontend-onboarding-daily-setup-cli-outcomes-v2-v3') }}
+ {{ t('frontend-onboarding-daily-setup-cli-outcomes-v2-v4') }}
{{ t('frontend-onboarding-daily-setup-cli-outcomes-description') }}
diff --git a/src/services/adminFrontendOnboarding.ts b/src/services/adminFrontendOnboarding.ts
index 256b04ca5d..82659f0fc5 100644
--- a/src/services/adminFrontendOnboarding.ts
+++ b/src/services/adminFrontendOnboarding.ts
@@ -66,6 +66,7 @@ export interface FrontendOnboardingAnalytics {
v1_attempts: number
v2_attempts: number
v3_attempts: number
+ v4_attempts: number
}>
deduplicated: {
daily_attempts: Array<{
@@ -73,9 +74,11 @@ export interface FrontendOnboardingAnalytics {
v1_attempts: number
v2_attempts: number
v3_attempts: number
+ v4_attempts: number
}>
funnels: {
v3: FrontendOnboardingFunnelStage[]
+ v4?: FrontendOnboardingFunnelStage[]
}
}
daily_conversions: {
@@ -87,6 +90,7 @@ export interface FrontendOnboardingAnalytics {
v1: FrontendOnboardingFunnelStage[]
v2: FrontendOnboardingFunnelStage[]
v3: FrontendOnboardingFunnelStage[]
+ v4: FrontendOnboardingFunnelStage[]
}
v2_graph: {
nodes: Array<{
@@ -100,6 +104,12 @@ export interface FrontendOnboardingAnalytics {
count: number
}>
}
+ v4_graph: {
+ nodes: Array<{
+ key: string
+ count: number
+ }>
+ }
v2_v3_setup_cli_outcomes: {
total_users: number
cli_only: number
@@ -194,6 +204,7 @@ export function buildFrontendOnboardingDailySeries(
v1Label: string,
v2Label: string,
v3Label: string,
+ v4Label: string,
): FrontendOnboardingDailySeries[] {
return [
{
@@ -211,6 +222,11 @@ export function buildFrontendOnboardingDailySeries(
color: '#10b981',
data: dailyAttempts.map(({ date, v3_attempts }) => ({ date, value: v3_attempts })),
},
+ {
+ label: v4Label,
+ color: '#f59e0b',
+ data: dailyAttempts.map(({ date, v4_attempts }) => ({ date, value: v4_attempts })),
+ },
]
}
@@ -289,7 +305,7 @@ export function buildFrontendOnboardingFunnelSummaries(
export function buildFrontendOnboardingGraphMetrics(
definitions: readonly FrontendOnboardingGraphMetricDefinition[],
- nodes: readonly FrontendOnboardingAnalytics['v3_graph']['nodes'][number][],
+ nodes: readonly FrontendOnboardingAnalytics['v4_graph']['nodes'][number][],
appDetailsCount: number | undefined,
): Record {
const counts = new Map(nodes.map(node => [node.key, node.count]))
diff --git a/src/utils/onboardingProgressAnalytics.ts b/src/utils/onboardingProgressAnalytics.ts
index effca386c9..a1db8581d8 100644
--- a/src/utils/onboardingProgressAnalytics.ts
+++ b/src/utils/onboardingProgressAnalytics.ts
@@ -1,6 +1,6 @@
import { pushEvent } from '~/services/posthog'
-export const ONBOARDING_ANALYTICS_VERSION = 3
+export const ONBOARDING_ANALYTICS_VERSION = 4
export type OnboardingAnalyticsFlow = 'pre_org' | 'existing_org'
export type OnboardingAnalyticsStep = 'welcome' | 'intent' | 'details' | 'app_name' | 'app_id' | 'app_icon' | 'organization' | 'choice' | 'install' | 'setup'
diff --git a/supabase/functions/_backend/utils/frontend_onboarding_analytics_model.ts b/supabase/functions/_backend/utils/frontend_onboarding_analytics_model.ts
index 8a66ba38b7..41f6505dea 100644
--- a/supabase/functions/_backend/utils/frontend_onboarding_analytics_model.ts
+++ b/supabase/functions/_backend/utils/frontend_onboarding_analytics_model.ts
@@ -1,4 +1,4 @@
-export const FRONTEND_ONBOARDING_VERSIONS = [1, 2, 3] as const
+export const FRONTEND_ONBOARDING_VERSIONS = [1, 2, 3, 4] as const
export type FrontendOnboardingVersion = typeof FRONTEND_ONBOARDING_VERSIONS[number]
export const FRONTEND_ONBOARDING_FOLLOWUP_MS = 24 * 60 * 60 * 1000
export const FRONTEND_ONBOARDING_PRODUCTION_HOST = ['console', 'capgo', 'app'].join('.')
@@ -57,6 +57,7 @@ export interface FrontendOnboardingDailyAttempt {
v1_attempts: number
v2_attempts: number
v3_attempts: number
+ v4_attempts: number
}
export interface FrontendOnboardingDailyConversion {
@@ -80,6 +81,7 @@ export interface FrontendOnboardingAnalytics {
daily_attempts: FrontendOnboardingDailyAttempt[]
funnels: {
v3: FrontendOnboardingFunnelStage[]
+ v4: FrontendOnboardingFunnelStage[]
}
}
daily_conversions: {
@@ -91,6 +93,7 @@ export interface FrontendOnboardingAnalytics {
v1: FrontendOnboardingFunnelStage[]
v2: FrontendOnboardingFunnelStage[]
v3: FrontendOnboardingFunnelStage[]
+ v4: FrontendOnboardingFunnelStage[]
}
v2_graph: {
nodes: Array<{ key: string, count: number }>
@@ -98,6 +101,9 @@ export interface FrontendOnboardingAnalytics {
v3_graph: {
nodes: Array<{ key: string, count: number }>
}
+ v4_graph: {
+ nodes: Array<{ key: string, count: number }>
+ }
v2_v3_setup_cli_outcomes: FrontendOnboardingSetupCliOutcomes
}
@@ -248,7 +254,7 @@ function eachUtcDate(startMs: number, endMs: number): string[] {
}
function buildDailyAttempts(attempts: FrontendOnboardingAttempt[], startMs: number, endMs: number): FrontendOnboardingDailyAttempt[] {
- const emptyCounts = () => ({ v1_attempts: 0, v2_attempts: 0, v3_attempts: 0 })
+ const emptyCounts = () => ({ v1_attempts: 0, v2_attempts: 0, v3_attempts: 0, v4_attempts: 0 })
const attemptsByDate = new Map>()
for (const attempt of attempts) {
const date = utcDate(attempt.intentMs)
@@ -321,7 +327,7 @@ function buildInteractionGraph(attempts: FrontendOnboardingAttempt[]): Array<{ k
.map(([key, count]) => ({ key, count }))
}
-function buildV2V3SetupCliOutcomes(attempts: FrontendOnboardingAttempt[]): FrontendOnboardingSetupCliOutcomes {
+function buildSetupCliOutcomes(attempts: FrontendOnboardingAttempt[]): FrontendOnboardingSetupCliOutcomes {
const outcomesByPerson = new Map()
for (const attempt of attempts) {
@@ -384,38 +390,45 @@ export function buildFrontendOnboardingAnalytics(
const currentV1Attempts = currentAttempts.filter(attempt => attempt.onboardingVersion === 1)
const currentV2Attempts = currentAttempts.filter(attempt => attempt.onboardingVersion === 2)
const currentV3Attempts = currentAttempts.filter(attempt => attempt.onboardingVersion === 3)
- const currentV2AndV3Attempts = currentAttempts.filter(attempt => attempt.onboardingVersion === 2 || attempt.onboardingVersion === 3)
- const currentV3ConversionAttempts = attempts.filter(attempt => attempt.onboardingVersion === 3
+ const currentV4Attempts = currentAttempts.filter(attempt => attempt.onboardingVersion === 4)
+ const currentSetupCliOutcomeAttempts = currentAttempts.filter(attempt => attempt.onboardingVersion >= 2)
+ const currentV4ConversionAttempts = attempts.filter(attempt => attempt.onboardingVersion === 4
&& attempt.intentMs >= currentStartMs - FRONTEND_ONBOARDING_FOLLOWUP_MS
&& attempt.intentMs < currentEndMs)
- const previousV3Attempts = previousAttempts.filter(attempt => attempt.onboardingVersion === 3)
- const currentV3 = summarizePeriod(currentV3Attempts)
- const previousV3 = summarizePeriod(previousV3Attempts)
+ const previousV4Attempts = previousAttempts.filter(attempt => attempt.onboardingVersion === 4)
+ const currentV4 = summarizePeriod(currentV4Attempts)
+ const previousV4 = summarizePeriod(previousV4Attempts)
const deduplicatedCurrentAttempts = selectDeduplicatedAttempts(currentAttempts)
const deduplicatedCurrentV3Attempts = selectDeduplicatedAttempts(currentV3Attempts)
+ const deduplicatedCurrentV4Attempts = selectDeduplicatedAttempts(currentV4Attempts)
return {
kpis: {
- ...currentV3.kpis,
- comparison: comparePeriods(currentV3.kpis, previousV3.kpis),
+ ...currentV4.kpis,
+ comparison: comparePeriods(currentV4.kpis, previousV4.kpis),
},
daily_attempts: buildDailyAttempts(currentAttempts, currentStartMs, currentEndMs),
deduplicated: {
daily_attempts: buildDailyAttempts(deduplicatedCurrentAttempts, currentStartMs, currentEndMs),
- funnels: { v3: buildFunnel(deduplicatedCurrentV3Attempts) },
+ funnels: {
+ v3: buildFunnel(deduplicatedCurrentV3Attempts),
+ v4: buildFunnel(deduplicatedCurrentV4Attempts),
+ },
},
daily_conversions: {
intent_to_details: buildDailyConversion(attempts, currentStartMs, currentEndMs, 'intent', 'details'),
- details_to_organization: buildDailyConversion(currentV3ConversionAttempts, currentStartMs, currentEndMs, 'details', 'organization'),
- organization_to_setup: buildDailyConversion(currentV3ConversionAttempts, currentStartMs, currentEndMs, 'organization', 'setup'),
+ details_to_organization: buildDailyConversion(currentV4ConversionAttempts, currentStartMs, currentEndMs, 'details', 'organization'),
+ organization_to_setup: buildDailyConversion(currentV4ConversionAttempts, currentStartMs, currentEndMs, 'organization', 'setup'),
},
funnels: {
v1: buildFunnel(currentV1Attempts),
v2: buildFunnel(currentV2Attempts),
- v3: currentV3.funnel,
+ v3: buildFunnel(currentV3Attempts),
+ v4: currentV4.funnel,
},
v2_graph: { nodes: buildInteractionGraph(currentV2Attempts) },
v3_graph: { nodes: buildInteractionGraph(currentV3Attempts) },
- v2_v3_setup_cli_outcomes: buildV2V3SetupCliOutcomes(currentV2AndV3Attempts),
+ v4_graph: { nodes: buildInteractionGraph(currentV4Attempts) },
+ v2_v3_setup_cli_outcomes: buildSetupCliOutcomes(currentSetupCliOutcomeAttempts),
}
}
diff --git a/supabase/functions/_backend/utils/frontend_onboarding_daily_setup_cli_outcomes.ts b/supabase/functions/_backend/utils/frontend_onboarding_daily_setup_cli_outcomes.ts
index ee5ab26941..2b865d53a4 100644
--- a/supabase/functions/_backend/utils/frontend_onboarding_daily_setup_cli_outcomes.ts
+++ b/supabase/functions/_backend/utils/frontend_onboarding_daily_setup_cli_outcomes.ts
@@ -3,7 +3,7 @@ import type {
FrontendOnboardingDailySetupCliEvent,
FrontendOnboardingDailySetupCliEventKind,
} from './frontend_onboarding_daily_setup_cli_outcomes_model.ts'
-import { FRONTEND_ONBOARDING_PRODUCTION_HOST } from './frontend_onboarding_analytics_model.ts'
+import { FRONTEND_ONBOARDING_PRODUCTION_HOST, FRONTEND_ONBOARDING_VERSIONS } from './frontend_onboarding_analytics_model.ts'
import { cloudlogErr } from './logging.ts'
import { queryPosthogHogql } from './posthog_read.ts'
@@ -70,6 +70,8 @@ export function buildFrontendOnboardingDailySetupCliHogql(
endDate: string,
followupEndDate: string,
): string {
+ const setupVersionAllowlist = FRONTEND_ONBOARDING_VERSIONS.filter(version => version >= 2).join(', ')
+
return `
WITH setup_people AS (
SELECT DISTINCT
@@ -78,7 +80,7 @@ export function buildFrontendOnboardingDailySetupCliHogql(
WHERE event = 'onboarding_step_viewed'
AND JSONExtractString(toString(properties), 'flow') = 'pre_org'
AND JSONExtractString(toString(properties), '$host') = ${sqlStr(FRONTEND_ONBOARDING_PRODUCTION_HOST)}
- AND toIntOrZero(toString(properties.onboarding_version)) IN (2, 3)
+ AND toIntOrZero(toString(properties.onboarding_version)) IN (${setupVersionAllowlist})
AND JSONExtractString(toString(properties), 'step') = 'setup'
AND timestamp >= parseDateTimeBestEffort(${sqlStr(startDate)})
AND timestamp < parseDateTimeBestEffort(${sqlStr(endDate)})
@@ -105,7 +107,7 @@ export function buildFrontendOnboardingDailySetupCliHogql(
selected_events.event IN ('onboarding_step_viewed', 'onboarding_cli_command_copied', 'onboarding_ai_instructions_copied')
AND JSONExtractString(toString(selected_events.properties), 'flow') = 'pre_org'
AND JSONExtractString(toString(selected_events.properties), '$host') = ${sqlStr(FRONTEND_ONBOARDING_PRODUCTION_HOST)}
- AND toIntOrZero(toString(selected_events.properties.onboarding_version)) IN (2, 3)
+ AND toIntOrZero(toString(selected_events.properties.onboarding_version)) IN (${setupVersionAllowlist})
AND JSONExtractString(toString(selected_events.properties), 'step') = 'setup'
)
)
diff --git a/tests/admin-chart-card-collapse.unit.test.ts b/tests/admin-chart-card-collapse.unit.test.ts
index fbc13c82f0..013ec9dadc 100644
--- a/tests/admin-chart-card-collapse.unit.test.ts
+++ b/tests/admin-chart-card-collapse.unit.test.ts
@@ -388,8 +388,8 @@ describe('admin chart card minimize control', () => {
it('uses the collapsible card for every custom frontend onboarding graph', () => {
const source = readFileSync(resolve(process.cwd(), 'src/pages/admin/dashboard/frontend-onboarding.vue'), 'utf8')
- expect(source).toContain(`:title="t('frontend-onboarding-funnel-v3')"`)
- expect(source).toContain(`:title="t('frontend-onboarding-graph-v3')"`)
+ expect(source).toContain(`:title="t('frontend-onboarding-funnel-v4')"`)
+ expect(source).toContain(`:title="t('frontend-onboarding-graph-v4')"`)
expect(source).toContain(`:title="t('frontend-onboarding-funnel-v1-legacy')"`)
expect(source.match(/ {
},
},
daily_attempts: [
- { date: '2026-08-10', v1_attempts: 6, v2_attempts: 3, v3_attempts: 2 },
- { date: '2026-08-09', v1_attempts: 4, v2_attempts: 2, v3_attempts: 1 },
+ { date: '2026-08-10', v1_attempts: 6, v2_attempts: 3, v3_attempts: 2, v4_attempts: 4 },
+ { date: '2026-08-09', v1_attempts: 4, v2_attempts: 2, v3_attempts: 1, v4_attempts: 2 },
],
daily_conversions: {
intent_to_details: [
@@ -95,15 +95,24 @@ describe('admin frontend onboarding dashboard', () => {
{ key: 'organization', label: 'Organization', reached: 2, of_start_percent: 50, dropoff_percent: 1 / 3 * 100 },
{ key: 'setup', label: 'Setup reached', reached: 1, of_start_percent: 25, dropoff_percent: 50 },
],
+ 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: 'setup', label: 'Setup reached', reached: 1, of_start_percent: 25, dropoff_percent: 50 },
+ ],
},
deduplicated: {
daily_attempts: [
- { date: '2026-08-10', v1_attempts: 5, v2_attempts: 2, v3_attempts: 1 },
+ { date: '2026-08-10', v1_attempts: 5, v2_attempts: 2, v3_attempts: 1, v4_attempts: 3 },
],
funnels: {
v3: [
{ key: 'intent', label: 'Intent', reached: 3, of_start_percent: 100, dropoff_percent: 0 },
],
+ v4: [
+ { key: 'intent', label: 'Intent', reached: 3, of_start_percent: 100, dropoff_percent: 0 },
+ ],
},
},
v2_graph: {
@@ -120,6 +129,13 @@ describe('admin frontend onboarding dashboard', () => {
{ key: 'import_clicked', count: 1 },
],
},
+ v4_graph: {
+ nodes: [
+ { key: 'details', count: 4 },
+ { key: 'store_opened', count: 3 },
+ { key: 'import_clicked', count: 2 },
+ ],
+ },
v2_v3_setup_cli_outcomes: {
total_users: 2,
cli_only: 1,
@@ -162,7 +178,7 @@ describe('admin frontend onboarding dashboard', () => {
}
it.concurrent('adapts split daily attempts into ordered version chart series', () => {
- expect(buildFrontendOnboardingDailySeries(analytics.daily_attempts, 'V1', 'V2', 'V3')).toEqual([
+ expect(buildFrontendOnboardingDailySeries(analytics.daily_attempts, 'V1', 'V2', 'V3', 'V4')).toEqual([
{
label: 'V1',
color: '#a78bfa',
@@ -187,11 +203,20 @@ describe('admin frontend onboarding dashboard', () => {
{ date: '2026-08-09', value: 1 },
],
},
+ {
+ label: 'V4',
+ color: '#f59e0b',
+ data: [
+ { date: '2026-08-10', value: 4 },
+ { date: '2026-08-09', value: 2 },
+ ],
+ },
])
- expect(buildFrontendOnboardingDailySeries([], 'V1', 'V2', 'V3')).toEqual([
+ expect(buildFrontendOnboardingDailySeries([], 'V1', 'V2', 'V3', 'V4')).toEqual([
{ label: 'V1', color: '#a78bfa', data: [] },
{ label: 'V2', color: '#06b6d4', data: [] },
{ label: 'V3', color: '#10b981', data: [] },
+ { label: 'V4', color: '#f59e0b', data: [] },
])
})
@@ -594,7 +619,7 @@ describe('admin frontend onboarding dashboard', () => {
expect(source).toContain('const visibleAnalytics = computed(() => isLoadingStats.value ? null : analytics.value)')
})
- it.concurrent('uses the existing admin dashboard components for v2 and the legacy funnel', async () => {
+ it.concurrent('uses the existing admin dashboard components for v4 and the legacy funnel', async () => {
const source = await readFile(new URL('../src/pages/admin/dashboard/frontend-onboarding.vue', import.meta.url), 'utf8')
const template = source.slice(source.indexOf(''))
@@ -607,10 +632,10 @@ describe('admin frontend onboarding dashboard', () => {
expect(source.match(/)/g)).toHaveLength(3)
expect(source.match(/)/g)).toHaveLength(2)
expect(source.match(/)/g)).toHaveLength(1)
- expect(source).toContain(' ')
- expect(source).toContain(' ')
+ expect(source).toContain(' ')
+ expect(source).toContain(' ')
expect(source).toContain(' ')
- expect(source).not.toContain(' ')
+ expect(source).not.toContain(' ')
expect(source).not.toContain(' ')
expect(source).toContain('orientation="vertical"')
expect(source).toContain(':values="setupCliOutcomeValues"')
@@ -622,26 +647,27 @@ describe('admin frontend onboarding dashboard', () => {
expect(source).not.toContain('v-if="isLoadingStats" class="grid min-h-72 place-items-center"')
expect(source).toContain(`t('frontend-onboarding-version-2')`)
expect(source).toContain(`t('frontend-onboarding-version-3')`)
- expect(source).toContain(`t('frontend-onboarding-funnel-v3')`)
+ expect(source).toContain(`t('frontend-onboarding-version-4')`)
+ expect(source).toContain(`t('frontend-onboarding-funnel-v4')`)
expect(source).toContain(`t('frontend-onboarding-funnel-v1-legacy')`)
expect(source).not.toContain(`t('frontend-onboarding-demo-data')`)
expect(source).toContain('buildFrontendOnboardingGraphMetrics')
- expect(source).toContain('visibleAnalytics.value?.v3_graph.nodes')
- expect(source).not.toContain('onboardingGraphV3Demo')
+ expect(source).toContain('visibleAnalytics.value?.v4_graph.nodes')
+ expect(source).not.toContain('onboardingGraphV4Demo')
expect(source).toContain('buildFrontendOnboardingFunnelSummaries')
expect(template).toContain('summary.conversion_percent')
expect(template).not.toContain('of_start_percent')
- const v3FunnelIndex = source.indexOf(`t('frontend-onboarding-funnel-v3')`)
+ const v4FunnelIndex = source.indexOf(`t('frontend-onboarding-funnel-v4')`)
const intentDetailsChartIndex = source.indexOf(`t('frontend-onboarding-daily-intent-to-details')`)
const detailsOrganizationChartIndex = source.indexOf(`t('frontend-onboarding-daily-details-to-organization')`)
const organizationSetupChartIndex = source.indexOf(`t('frontend-onboarding-daily-organization-to-setup')`)
- const graphIndex = source.indexOf(`t('frontend-onboarding-graph-v3')`)
- const cliOutcomeIndex = source.indexOf(`t('frontend-onboarding-setup-cli-outcomes-v2-v3')`)
- const dailyCliOutcomeIndex = source.indexOf(`t('frontend-onboarding-daily-setup-cli-outcomes-v2-v3')`)
+ const graphIndex = source.indexOf(`t('frontend-onboarding-graph-v4')`)
+ const cliOutcomeIndex = source.indexOf(`t('frontend-onboarding-setup-cli-outcomes-v2-v4')`)
+ const dailyCliOutcomeIndex = source.indexOf(`t('frontend-onboarding-daily-setup-cli-outcomes-v2-v4')`)
const legacyIndex = source.indexOf(`t('frontend-onboarding-funnel-v1-legacy')`)
- expect(v3FunnelIndex).toBeLessThan(graphIndex)
- expect(v3FunnelIndex).toBeLessThan(intentDetailsChartIndex)
+ expect(v4FunnelIndex).toBeLessThan(graphIndex)
+ expect(v4FunnelIndex).toBeLessThan(intentDetailsChartIndex)
expect(intentDetailsChartIndex).toBeLessThan(detailsOrganizationChartIndex)
expect(detailsOrganizationChartIndex).toBeLessThan(organizationSetupChartIndex)
expect(organizationSetupChartIndex).toBeLessThan(graphIndex)
@@ -687,30 +713,32 @@ describe('admin frontend onboarding dashboard', () => {
expect(funnelChart).toContain('class="d-loading d-loading-spinner d-loading-lg text-primary"')
})
- it.concurrent('shows independent local de-duplicate controls on the expanded daily attempts and v3 funnel cards', async () => {
+ it.concurrent('shows independent local de-duplicate controls on the expanded daily attempts and v4 funnel cards', async () => {
const source = await readFile(new URL('../src/pages/admin/dashboard/frontend-onboarding.vue', import.meta.url), 'utf8')
const control = await readFile(new URL('../src/components/admin/AdminChartDeduplicateControl.vue', import.meta.url), 'utf8')
const dailyAttemptsIndex = source.indexOf(`t('frontend-onboarding-daily-attempts')`)
- const v3FunnelIndex = source.indexOf(`t('frontend-onboarding-funnel-v3')`)
+ const v4FunnelIndex = source.indexOf(`t('frontend-onboarding-funnel-v4')`)
const nextChartIndex = source.indexOf(`t('frontend-onboarding-daily-intent-to-details')`)
expect(source).toContain('const deduplicateDailyAttempts = ref(false)')
- expect(source).toContain('const deduplicateV3Funnel = ref(false)')
+ expect(source).toContain('const deduplicateV4Funnel = ref(false)')
expect(source).toContain('const displayedDailyAttempts = computed')
expect(source).toContain('deduplicateDailyAttempts.value')
expect(source).toContain('visibleAnalytics.value?.deduplicated.daily_attempts')
- expect(source).toContain('const displayedV3Funnel = computed')
- expect(source).toContain('deduplicateV3Funnel.value')
+ expect(source).toContain('const displayedV4Funnel = computed')
+ expect(source).toContain('deduplicateV4Funnel.value')
+ expect(source).toContain('visibleAnalytics.value?.deduplicated.funnels.v4')
expect(source).toContain('visibleAnalytics.value?.deduplicated.funnels.v3')
expect(source).toContain('buildFrontendOnboardingDailySeries(\n displayedDailyAttempts.value')
- expect(source).toContain('buildFrontendOnboardingFunnelStages(displayedV3Funnel.value)')
- expect(source).toContain('buildFrontendOnboardingFunnelSummaries(displayedV3Funnel.value)')
+ expect(source).toContain('buildFrontendOnboardingFunnelStages(displayedV4Funnel.value)')
+ expect(source).toContain('buildFrontendOnboardingFunnelSummaries(displayedV4Funnel.value)')
expect(source.match(/deduplicateDailyAttempts/g)).toHaveLength(3)
- expect(source.match(/deduplicateV3Funnel/g)).toHaveLength(3)
- expect(source.slice(dailyAttemptsIndex, v3FunnelIndex)).toContain(`:chart-label="t('frontend-onboarding-daily-attempts')"`)
- expect(source.slice(v3FunnelIndex, nextChartIndex)).toContain(`:chart-label="t('frontend-onboarding-funnel-v3')"`)
+ expect(source.match(/deduplicateV4Funnel/g)).toHaveLength(3)
+ expect(source.slice(dailyAttemptsIndex, v4FunnelIndex)).toContain(`:chart-label="t('frontend-onboarding-daily-attempts')"`)
+ expect(source.slice(v4FunnelIndex, nextChartIndex)).toContain(`:chart-label="t('frontend-onboarding-funnel-v4')"`)
expect(source).toContain('const result = await adminStore.fetchStats(\'frontend_onboarding_analytics\')')
expect(source).toContain('!Array.isArray(result.deduplicated?.daily_attempts)')
+ expect(source).toContain('!Array.isArray(result.deduplicated?.funnels?.v4)')
expect(source).toContain('!Array.isArray(result.deduplicated?.funnels?.v3)')
expect(source).toContain('throw new Error(\'Frontend onboarding analytics response is missing deduplicated chart data\')')
expect(control).toContain('data-test="deduplicate-by-user"')
@@ -765,6 +793,7 @@ describe('admin frontend onboarding dashboard', () => {
expect(messages['frontend-onboarding-version-1']).toBe('Onboarding v1')
expect(messages['frontend-onboarding-version-2']).toBe('Onboarding v2')
expect(messages['frontend-onboarding-version-3']).toBe('Onboarding v3')
+ expect(messages['frontend-onboarding-version-4']).toBe('Onboarding v4')
expect(messages['frontend-onboarding-attempts']).toBe('Onboarding attempts')
expect(messages['frontend-onboarding-attempts-subtitle']).toBe('Unique onboarding attempts')
expect(messages['frontend-onboarding-completed']).toBe('Onboarding completed')
@@ -775,21 +804,26 @@ describe('admin frontend onboarding dashboard', () => {
expect(messages['frontend-onboarding-daily-attempts']).toBe('Daily onboarding attempts')
expect(messages['frontend-onboarding-deduplicate-by-user']).toBe('De-duplicate by user')
expect(messages['frontend-onboarding-deduplicate-by-user-chart']).toBe('De-duplicate by user: {chart}')
- expect(messages['frontend-onboarding-daily-intent-to-details']).toBe('Daily Intent → App details conversion (v1, v2, and v3)')
- expect(messages['frontend-onboarding-daily-details-to-organization']).toBe('Daily App details → Organization conversion (v3)')
- expect(messages['frontend-onboarding-daily-organization-to-setup']).toBe('Daily Organization → Setup reached conversion (v3)')
+ expect(messages['frontend-onboarding-daily-intent-to-details']).toBe('Daily Intent → App details conversion (v1–v4)')
+ expect(messages['frontend-onboarding-daily-details-to-organization']).toBe('Daily App details → Organization conversion (v4)')
+ expect(messages['frontend-onboarding-daily-organization-to-setup']).toBe('Daily Organization → Setup reached conversion (v4)')
expect(messages['frontend-onboarding-daily-conversion']).toBe('Conversion')
expect(messages['frontend-onboarding-daily-conversion-attempts']).toBe('attempts')
expect(messages['frontend-onboarding-funnel-v2']).toBe('Onboarding funnel (v2)')
+ expect(messages['frontend-onboarding-funnel-v4']).toBe('Frontend onboarding funnel (v4)')
expect(messages['frontend-onboarding-funnel-v1-legacy']).toBe('Onboarding funnel (v1, legacy)')
expect(messages['frontend-onboarding-graph-v2']).toBe('Onboarding graph (v2)')
+ expect(messages['frontend-onboarding-graph-v4']).toBe('Frontend onboarding graph (v4)')
+ expect(messages['frontend-onboarding-graph-v4-description']).toBe('Explore behavior inside each onboarding stage')
expect(messages['frontend-onboarding-setup-cli-outcomes-v2-v3']).toBe('Setup → CLI outcomes (v2 and v3)')
+ expect(messages['frontend-onboarding-setup-cli-outcomes-v2-v4']).toBe('Setup → CLI outcomes (v2–v4)')
expect(messages['frontend-onboarding-setup-cli-outcomes-description']).toBe('Unique people who reached setup, grouped by their CLI and copied AI instruction activity within 24 hours')
expect(messages['frontend-onboarding-setup-cli-only']).toBe('Started CLI')
expect(messages['frontend-onboarding-setup-cli-and-ai']).toBe('Started CLI + AI instructions')
expect(messages['frontend-onboarding-setup-no-cli']).toBe('Didn\'t start CLI')
expect(messages['frontend-onboarding-people']).toBe('people')
expect(messages['frontend-onboarding-daily-setup-cli-outcomes-v2-v3']).toBe('Daily Setup → CLI outcomes (v2 and v3)')
+ expect(messages['frontend-onboarding-daily-setup-cli-outcomes-v2-v4']).toBe('Daily Setup → CLI outcomes (v2–v4)')
expect(messages['frontend-onboarding-daily-setup-cli-outcomes-description']).toBe('Each person is counted once per UTC day. Left: first-time Setup views; right: returning views. Actions are attributed for up to 24 hours.')
expect(messages['frontend-onboarding-daily-setup-cli-first-time']).toBe('First-time')
expect(messages['frontend-onboarding-daily-setup-cli-returning']).toBe('Returning')
diff --git a/tests/frontend-onboarding-analytics-model.unit.test.ts b/tests/frontend-onboarding-analytics-model.unit.test.ts
index f99f518b05..39c9265f8d 100644
--- a/tests/frontend-onboarding-analytics-model.unit.test.ts
+++ b/tests/frontend-onboarding-analytics-model.unit.test.ts
@@ -14,7 +14,7 @@ const CURRENT_END_MS = CURRENT_START_MS + 2 * DAY_MS
function attempt(overrides: Partial & Pick): FrontendOnboardingAttempt {
return {
- onboardingVersion: 3,
+ onboardingVersion: 4,
personId: overrides.attemptId,
aiInstructionsCopiedMs: [],
cliStartedMs: [],
@@ -27,7 +27,28 @@ function attempt(overrides: Partial & Pick {
- it.concurrent('builds v3 KPIs, split daily attempts, and per-version monotonic funnels', () => {
+ it.concurrent('surfaces v4 as the current analytics cohort', () => {
+ const analytics = buildFrontendOnboardingAnalytics([
+ attempt({
+ attemptId: 'v4-complete',
+ onboardingVersion: 4 as FrontendOnboardingAttempt['onboardingVersion'],
+ intentMs: CURRENT_START_MS,
+ detailsMs: CURRENT_START_MS + MINUTE_MS,
+ 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 }],
+ }),
+ ], CURRENT_START_MS, CURRENT_END_MS)
+
+ expect(FRONTEND_ONBOARDING_VERSIONS).toEqual([1, 2, 3, 4])
+ expect(analytics.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.v4_graph.nodes).toEqual([{ key: 'onboarding_app_name_entered', count: 1 }])
+ })
+
+ it.concurrent('builds v4 KPIs, split daily attempts, and per-version monotonic funnels', () => {
const analytics = buildFrontendOnboardingAnalytics([
attempt({
attemptId: 'complete',
@@ -62,11 +83,11 @@ describe('buildFrontendOnboardingAnalytics', () => {
largest_dropoff: { from: 'details', to: 'organization', percentage: 1 / 3 * 100 },
})
expect(analytics.daily_attempts).toEqual([
- { date: '2026-08-01', v1_attempts: 0, v2_attempts: 0, v3_attempts: 2 },
- { date: '2026-08-02', v1_attempts: 1, v2_attempts: 0, v3_attempts: 2 },
+ { 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.v3.map(stage => stage.reached)).toEqual([4, 3, 2, 2])
- expect(analytics.funnels.v3).toEqual([
+ expect(analytics.funnels.v4.map(stage => stage.reached)).toEqual([4, 3, 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 },
@@ -75,7 +96,7 @@ describe('buildFrontendOnboardingAnalytics', () => {
expect(analytics.funnels.v1.map(stage => stage.reached)).toEqual([1, 0, 0, 0])
})
- it.concurrent('builds range-level daily and v3-only de-duplicated variants', () => {
+ it.concurrent('builds range-level daily and v4-only de-duplicated variants', () => {
const analytics = buildFrontendOnboardingAnalytics([
attempt({
attemptId: 'furthest-v1',
@@ -85,7 +106,7 @@ describe('buildFrontendOnboardingAnalytics', () => {
setupMs: CURRENT_START_MS + 2 * MINUTE_MS,
}),
attempt({
- attemptId: 'furthest-v3',
+ attemptId: 'furthest-v4',
personId: 'furthest',
intentMs: CURRENT_START_MS + DAY_MS + MINUTE_MS,
organizationMs: CURRENT_START_MS + DAY_MS + 2 * MINUTE_MS,
@@ -98,7 +119,7 @@ describe('buildFrontendOnboardingAnalytics', () => {
detailsMs: CURRENT_START_MS + 4 * MINUTE_MS,
}),
attempt({
- attemptId: 'latest-v3',
+ attemptId: 'latest-v4',
personId: 'latest',
intentMs: CURRENT_START_MS + DAY_MS + 3 * MINUTE_MS,
detailsMs: CURRENT_START_MS + DAY_MS + 4 * MINUTE_MS,
@@ -116,10 +137,10 @@ describe('buildFrontendOnboardingAnalytics', () => {
], CURRENT_START_MS, CURRENT_END_MS)
expect(analytics.deduplicated.daily_attempts).toEqual([
- { date: '2026-08-01', v1_attempts: 1, v2_attempts: 0, v3_attempts: 1 },
- { date: '2026-08-02', v1_attempts: 0, v2_attempts: 0, v3_attempts: 2 },
+ { 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.v3.map(stage => stage.reached)).toEqual([4, 2, 1, 0])
+ expect(analytics.deduplicated.funnels.v4.map(stage => stage.reached)).toEqual([4, 2, 1, 0])
})
it.concurrent('keeps blank and namespaced person identities separate', () => {
@@ -138,8 +159,8 @@ describe('buildFrontendOnboardingAnalytics', () => {
], CURRENT_START_MS, CURRENT_END_MS)
expect(analytics.deduplicated.daily_attempts).toEqual([
- { date: '2026-08-01', v1_attempts: 1, v2_attempts: 0, v3_attempts: 1 },
- { date: '2026-08-02', v1_attempts: 0, v2_attempts: 0, v3_attempts: 0 },
+ { 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: 0 },
])
})
@@ -155,7 +176,7 @@ describe('buildFrontendOnboardingAnalytics', () => {
}),
], CURRENT_START_MS, CURRENT_END_MS)
- expect(analytics.funnels.v3.map(stage => stage.reached)).toEqual([1, 0, 0, 0])
+ expect(analytics.funnels.v4.map(stage => stage.reached)).toEqual([1, 0, 0, 0])
expect(analytics.kpis.median_completion_ms).toBeNull()
})
@@ -217,14 +238,16 @@ describe('buildFrontendOnboardingAnalytics', () => {
largest_dropoff: null,
})
expect(analytics.daily_attempts).toEqual([
- { date: '2026-08-01', v1_attempts: 0, v2_attempts: 0, v3_attempts: 0 },
- { date: '2026-08-02', v1_attempts: 0, v2_attempts: 0, v3_attempts: 0 },
+ { date: '2026-08-01', v1_attempts: 0, v2_attempts: 0, v3_attempts: 0, v4_attempts: 0 },
+ { date: '2026-08-02', v1_attempts: 0, v2_attempts: 0, v3_attempts: 0, v4_attempts: 0 },
])
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.v2_graph.nodes).toEqual([])
expect(analytics.v3_graph.nodes).toEqual([])
+ expect(analytics.v4_graph.nodes).toEqual([])
})
it.concurrent('reports no largest drop-off when every attempt reaches every stage', () => {
@@ -269,8 +292,8 @@ describe('buildFrontendOnboardingAnalytics', () => {
], startMs, endMs)
expect(analytics.daily_attempts).toEqual([
- { date: '2026-08-01', v1_attempts: 0, v2_attempts: 0, v3_attempts: 1 },
- { date: '2026-08-02', v1_attempts: 0, v2_attempts: 0, v3_attempts: 1 },
+ { 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: 1 },
])
})
@@ -279,7 +302,7 @@ describe('buildFrontendOnboardingAnalytics', () => {
const dayTwoOrganizationMs = CURRENT_START_MS + DAY_MS + MINUTE_MS
const analytics = buildFrontendOnboardingAnalytics([
attempt({
- attemptId: 'v3-crosses-midnight',
+ attemptId: 'v4-crosses-midnight',
intentMs: CURRENT_START_MS + 12 * 60 * MINUTE_MS,
detailsMs: dayOneDetailsMs,
organizationMs: dayTwoOrganizationMs,
@@ -406,10 +429,10 @@ describe('buildFrontendOnboardingAnalytics', () => {
expect(() => buildFrontendOnboardingAnalytics([], CURRENT_START_MS, Number.POSITIVE_INFINITY)).toThrow(RangeError)
})
- it.concurrent('counts each current v3 interaction event once and ignores older-version events', () => {
+ it.concurrent('counts each current v4 interaction event once and ignores older-version events', () => {
const analytics = buildFrontendOnboardingAnalytics([
attempt({
- attemptId: 'v3-repeated-events',
+ attemptId: 'v4-repeated-events',
intentMs: CURRENT_START_MS,
interactionEvents: [
{ key: 'organization_created', timestampMs: CURRENT_START_MS + MINUTE_MS },
@@ -418,7 +441,7 @@ describe('buildFrontendOnboardingAnalytics', () => {
],
}),
attempt({
- attemptId: 'v3-shared-event',
+ attemptId: 'v4-shared-event',
intentMs: CURRENT_START_MS + MINUTE_MS,
interactionEvents: [{ key: 'organization_created', timestampMs: CURRENT_START_MS + 2 * MINUTE_MS }],
}),
@@ -432,14 +455,14 @@ describe('buildFrontendOnboardingAnalytics', () => {
],
}),
attempt({
- attemptId: 'previous-v3-event',
+ attemptId: 'previous-v4-event',
intentMs: CURRENT_START_MS - DAY_MS,
interactionEvents: [{ key: 'previous_event', timestampMs: CURRENT_START_MS - DAY_MS + MINUTE_MS }],
}),
], CURRENT_START_MS, CURRENT_END_MS)
- expect(FRONTEND_ONBOARDING_VERSIONS).toEqual([1, 2, 3])
- expect(analytics.v3_graph.nodes).toEqual([
+ expect(FRONTEND_ONBOARDING_VERSIONS).toEqual([1, 2, 3, 4])
+ expect(analytics.v4_graph.nodes).toEqual([
{ key: 'app_created', count: 1 },
{ key: 'organization_created', count: 2 },
])
@@ -460,11 +483,11 @@ describe('buildFrontendOnboardingAnalytics', () => {
}),
], CURRENT_START_MS, CURRENT_END_MS)
- expect(analytics.funnels.v3.find(stage => stage.key === 'details')?.reached).toBe(1)
- expect(analytics.v3_graph.nodes).toEqual([{ key: 'at_boundary', count: 1 }])
+ expect(analytics.funnels.v4.find(stage => stage.key === 'details')?.reached).toBe(1)
+ expect(analytics.v4_graph.nodes).toEqual([{ key: 'at_boundary', count: 1 }])
})
- it.concurrent('classifies each setup-reaching v2 or v3 person once across mutually exclusive CLI outcomes', () => {
+ it.concurrent('classifies each setup-reaching v2, v3, or v4 person once across mutually exclusive CLI outcomes', () => {
const setupMs = CURRENT_START_MS + 10 * MINUTE_MS
const analytics = buildFrontendOnboardingAnalytics([
attempt({
diff --git a/tests/frontend-onboarding-analytics.unit.test.ts b/tests/frontend-onboarding-analytics.unit.test.ts
index 7b0607d2cf..bd1f368dbf 100644
--- a/tests/frontend-onboarding-analytics.unit.test.ts
+++ b/tests/frontend-onboarding-analytics.unit.test.ts
@@ -61,7 +61,7 @@ describe('buildFrontendOnboardingHogql', () => {
expect(query).toContain('JSONExtractString(toString(properties), \'flow\') = \'pre_org\'')
expect(query).toContain('JSONExtractString(toString(properties), \'$host\') = \'console.capgo.app\'')
expect(query).toContain('toIntOrZero(toString(properties.onboarding_version)) AS onboarding_version')
- expect(query).toContain('toIntOrZero(toString(properties.onboarding_version)) IN (1, 2, 3)')
+ expect(query).toContain('toIntOrZero(toString(properties.onboarding_version)) IN (1, 2, 3, 4)')
expect(query).not.toContain('toInt64OrZero')
expect(query).toContain('JSONExtractString(toString(properties), \'onboarding_attempt_id\')')
expect(query).toContain('JSONExtractString(toString(properties), \'step\')')
@@ -104,7 +104,7 @@ describe('assertFrontendOnboardingAttemptTotal', () => {
})
describe('getAdminFrontendOnboardingAnalytics', () => {
- it('maps grouped v1 and v3 rows, including repeated v3 interactions', async () => {
+ it('maps grouped v1, v2, and v4 rows, including repeated v4 interactions', async () => {
const start = '2026-08-01T00:00:00.000Z'
const intentMs = Date.parse(start) + 60 * 60 * 1000 + 123
const dailySetupMs = Date.parse(start) + 2 * 60 * 60 * 1000
@@ -114,8 +114,8 @@ describe('getAdminFrontendOnboardingAnalytics', () => {
failureReason: null,
rows: [{
attempt_id: 'attempt-1',
- person_id: 'person-v3',
- onboarding_version: 3,
+ person_id: 'person-v4',
+ onboarding_version: 4,
intent_ms: intentMs,
details_ms: intentMs + 555,
organization_ms: intentMs + 1_000,
@@ -177,16 +177,16 @@ describe('getAdminFrontendOnboardingAnalytics', () => {
expect(result).toMatchObject({
kpis: { attempts: 1, completed: 1, completion_rate: 100, median_completion_ms: 2_666 },
daily_attempts: [
- { date: '2026-08-01', v1_attempts: 1, v2_attempts: 1, v3_attempts: 1 },
- { date: '2026-08-02', v1_attempts: 0, v2_attempts: 0, v3_attempts: 0 },
+ { date: '2026-08-01', v1_attempts: 1, v2_attempts: 1, v3_attempts: 0, v4_attempts: 1 },
+ { date: '2026-08-02', v1_attempts: 0, v2_attempts: 0, v3_attempts: 0, v4_attempts: 0 },
],
- funnels: { v3: [
+ funnels: { v4: [
{ key: 'intent', reached: 1 },
{ key: 'details', reached: 1 },
{ key: 'organization', reached: 1 },
{ key: 'setup', reached: 1 },
] },
- v3_graph: { nodes: [{ key: 'onboarding_app_id_entered', count: 1 }] },
+ v4_graph: { nodes: [{ key: 'onboarding_app_id_entered', count: 1 }] },
v2_graph: { nodes: [{ key: 'onboarding_app_name_entered', count: 1 }] },
v2_v3_setup_cli_outcomes: {
total_users: 2,
@@ -318,10 +318,10 @@ describe('getAdminFrontendOnboardingAnalytics', () => {
{ attempt_id: 'zero-intent', onboarding_version: 2, intent_ms: 0, details_ms: startMs + 2_000, total_attempts: 10 },
{ attempt_id: false, onboarding_version: 2, intent_ms: startMs + 1_000, details_ms: startMs + 2_000, total_attempts: 10 },
{ attempt_id: ['array'], onboarding_version: 2, intent_ms: startMs + 1_000, details_ms: startMs + 2_000, total_attempts: 10 },
- { attempt_id: 'unknown-version', onboarding_version: 4, intent_ms: startMs + 1_000, total_attempts: 10 },
+ { attempt_id: 'unknown-version', onboarding_version: 5, intent_ms: startMs + 1_000, total_attempts: 10 },
{ attempt_id: 'string-version', onboarding_version: '3', intent_ms: startMs + 1_000, total_attempts: 10 },
- { attempt_id: 'boolean-step', onboarding_version: 3, intent_ms: startMs + 2_000, details_ms: true, organization_ms: [], setup_ms: {}, interaction_events: 'not-an-array', total_attempts: 10 },
- { attempt_id: 'valid', onboarding_version: 3, intent_ms: String(startMs + 1_000), details_ms: undefined, organization_ms: 0, setup_ms: 'not-a-number', interaction_events: [[' valid ', startMs + 2_000], ['', startMs + 2_000], ['missing-time'], 42, null], total_attempts: 10 },
+ { attempt_id: 'boolean-step', onboarding_version: 4, intent_ms: startMs + 2_000, details_ms: true, organization_ms: [], setup_ms: {}, interaction_events: 'not-an-array', total_attempts: 10 },
+ { attempt_id: 'valid', onboarding_version: 4, intent_ms: String(startMs + 1_000), details_ms: undefined, organization_ms: 0, setup_ms: 'not-a-number', interaction_events: [[' valid ', startMs + 2_000], ['', startMs + 2_000], ['missing-time'], 42, null], total_attempts: 10 },
],
})
@@ -333,13 +333,13 @@ describe('getAdminFrontendOnboardingAnalytics', () => {
expect(result).toMatchObject({
kpis: { attempts: 2, completed: 0, completion_rate: 0 },
- funnels: { v3: [
+ funnels: { v4: [
{ key: 'intent', reached: 2 },
{ key: 'details', reached: 0 },
{ key: 'organization', reached: 0 },
{ key: 'setup', reached: 0 },
] },
- v3_graph: { nodes: [{ key: 'valid', count: 1 }] },
+ v4_graph: { nodes: [{ key: 'valid', count: 1 }] },
})
expect(queryPosthogHogqlMock).toHaveBeenCalledTimes(2)
})
diff --git a/tests/frontend-onboarding-daily-setup-cli-outcomes.unit.test.ts b/tests/frontend-onboarding-daily-setup-cli-outcomes.unit.test.ts
index 04dab8297a..43d0a78db0 100644
--- a/tests/frontend-onboarding-daily-setup-cli-outcomes.unit.test.ts
+++ b/tests/frontend-onboarding-daily-setup-cli-outcomes.unit.test.ts
@@ -55,7 +55,7 @@ describe('buildFrontendOnboardingDailySetupCliHogql', () => {
expect(setupPeople).toContain('WHERE event = \'onboarding_step_viewed\'')
expect(setupPeople).toContain('JSONExtractString(toString(properties), \'flow\') = \'pre_org\'')
expect(setupPeople).toContain('JSONExtractString(toString(properties), \'$host\') = \'console.capgo.app\'')
- expect(setupPeople).toContain('toIntOrZero(toString(properties.onboarding_version)) IN (2, 3)')
+ expect(setupPeople).toContain('toIntOrZero(toString(properties.onboarding_version)) IN (2, 3, 4)')
expect(setupPeople).toContain('JSONExtractString(toString(properties), \'step\') = \'setup\'')
expect(setupPeople).toContain('timestamp >= parseDateTimeBestEffort(\'2026-08-01T00:00:00.123Z\')')
expect(setupPeople).toContain('timestamp < parseDateTimeBestEffort(\'2026-08-03T00:00:00.456Z\')')
@@ -84,7 +84,7 @@ describe('buildFrontendOnboardingDailySetupCliHogql', () => {
expect(selectedSetupCopyBranch).toContain('selected_events.event IN (\'onboarding_step_viewed\', \'onboarding_cli_command_copied\', \'onboarding_ai_instructions_copied\')')
expect(selectedSetupCopyBranch).toContain('JSONExtractString(toString(selected_events.properties), \'flow\') = \'pre_org\'')
expect(selectedSetupCopyBranch).toContain('JSONExtractString(toString(selected_events.properties), \'$host\') = \'console.capgo.app\'')
- expect(selectedSetupCopyBranch).toContain('toIntOrZero(toString(selected_events.properties.onboarding_version)) IN (2, 3)')
+ expect(selectedSetupCopyBranch).toContain('toIntOrZero(toString(selected_events.properties.onboarding_version)) IN (2, 3, 4)')
expect(selectedSetupCopyBranch).toContain('JSONExtractString(toString(selected_events.properties), \'step\') = \'setup\'')
expect(query).toContain('count() OVER () AS total_events')
diff --git a/tests/onboarding-progress-analytics.unit.test.ts b/tests/onboarding-progress-analytics.unit.test.ts
index 4af929eb1e..b6ad2df05e 100644
--- a/tests/onboarding-progress-analytics.unit.test.ts
+++ b/tests/onboarding-progress-analytics.unit.test.ts
@@ -194,7 +194,7 @@ describe('onboarding progress analytics', () => {
tracker.viewStep('intent')
- expect(ONBOARDING_ANALYTICS_VERSION).toBe(3)
+ expect(ONBOARDING_ANALYTICS_VERSION).toBe(4)
expect(capture).toHaveBeenCalledOnce()
expect(capture).toHaveBeenCalledWith(
'onboarding_step_viewed',
From 85c7d2333706faf898b77e67e88f5a10f26d074e Mon Sep 17 00:00:00 2001
From: WcaleNieWolny
Date: Tue, 18 Aug 2026 17:22:25 +0200
Subject: [PATCH 04/10] fix(db): restamp onboarding details migration
---
...ql => 20260818152131_user_onboarding_details_step_comment.sql} | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename supabase/migrations/{20260817183727_user_onboarding_details_step_comment.sql => 20260818152131_user_onboarding_details_step_comment.sql} (100%)
diff --git a/supabase/migrations/20260817183727_user_onboarding_details_step_comment.sql b/supabase/migrations/20260818152131_user_onboarding_details_step_comment.sql
similarity index 100%
rename from supabase/migrations/20260817183727_user_onboarding_details_step_comment.sql
rename to supabase/migrations/20260818152131_user_onboarding_details_step_comment.sql
From e629716f305d3be94ab5727e56b0601738ac6fed Mon Sep 17 00:00:00 2001
From: WcaleNieWolny
Date: Tue, 18 Aug 2026 17:52:20 +0200
Subject: [PATCH 05/10] fix(onboarding): address v4 rollout review
---
messages/en.json | 3 ++
.../dashboard/AppOnboardingFlow.vue | 5 +-
.../admin/dashboard/frontend-onboarding.vue | 47 ++++++++++++++-----
src/services/adminFrontendOnboarding.ts | 18 ++++---
.../frontend_onboarding_analytics_model.ts | 7 ++-
...frontend-onboarding-dashboard.unit.test.ts | 21 ++++++++-
tests/app-onboarding-v3.unit.test.ts | 6 ++-
...nd-onboarding-analytics-model.unit.test.ts | 21 +++++++--
...frontend-onboarding-analytics.unit.test.ts | 12 +++++
9 files changed, 112 insertions(+), 28 deletions(-)
diff --git a/messages/en.json b/messages/en.json
index 8b7dfb70cf..0e15670923 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -3040,6 +3040,9 @@
"frontend-onboarding-graph-organization-invite-opened": "Invitation opened",
"frontend-onboarding-graph-organization-invite-succeeded": "Invitation succeeded",
"frontend-onboarding-graph-organization-invite-continued": "Invitation step continued",
+ "frontend-onboarding-graph-app-creation-started": "App creation started",
+ "frontend-onboarding-graph-app-creation-succeeded": "App creation succeeded",
+ "frontend-onboarding-graph-app-creation-failed": "App creation failed",
"frontend-onboarding-graph-technical-invite-opened": "Technical invite opened",
"frontend-onboarding-graph-technical-invite-succeeded": "Technical invite succeeded",
"frontend-onboarding-graph-percent-of-level": "{percent}% of {level}",
diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue
index 48e8fbf371..b4d8e2b1f9 100644
--- a/src/components/dashboard/AppOnboardingFlow.vue
+++ b/src/components/dashboard/AppOnboardingFlow.vue
@@ -801,11 +801,14 @@ function resetStoreImportState() {
cancelPendingStoreImport()
cancelPendingStoreIconImport()
storeUrl.value = ''
+ iconStoreUrl.value = ''
storeIconPreview.value = ''
storeAppNamePreview.value = ''
useImportedStoreIcon.value = false
importedStoreAppId.value = ''
isImportingStore.value = false
+ isStoreImportOpen.value = false
+ isStoreIconImportOpen.value = false
}
let resumeIconLoadRun = 0
@@ -2280,7 +2283,7 @@ defineExpose({
-
+
deduplicateDailyAttempts.value
? visibleAnalytics.value?.deduplicated.daily_attempts ?? []
: visibleAnalytics.value?.daily_attempts ?? [])
const displayedV4Funnel = computed(() => {
- if (!deduplicateV4Funnel.value)
- return visibleAnalytics.value?.funnels.v4 ?? []
+ if (!deduplicateV4Funnel.value) {
+ return visibleAnalytics.value?.funnels.v4
+ ?? visibleAnalytics.value?.funnels.v3
+ ?? []
+ }
return visibleAnalytics.value?.deduplicated.funnels.v4
?? visibleAnalytics.value?.deduplicated.funnels.v3
@@ -179,13 +189,15 @@ const v4FunnelStages = computed(() => buildFrontendOnboardingFunnelStages(displa
const v1FunnelSummaries = computed(() => buildFrontendOnboardingFunnelSummaries(visibleAnalytics.value?.funnels.v1 ?? []))
const v4FunnelSummaries = computed(() => buildFrontendOnboardingFunnelSummaries(displayedV4Funnel.value))
const hasDailyAttempts = computed(() => displayedDailyAttempts.value
- .some(day => day.v1_attempts > 0 || day.v2_attempts > 0 || day.v3_attempts > 0 || day.v4_attempts > 0))
-const setupCliOutcomes = computed(() => visibleAnalytics.value?.v2_v3_setup_cli_outcomes ?? {
- total_users: 0,
- cli_only: 0,
- cli_and_ai_instructions: 0,
- no_cli: 0,
-})
+ .some(day => day.v1_attempts > 0 || day.v2_attempts > 0 || day.v3_attempts > 0 || (day.v4_attempts ?? 0) > 0))
+const setupCliOutcomes = computed(() => visibleAnalytics.value?.v2_v4_setup_cli_outcomes
+ ?? visibleAnalytics.value?.v2_v3_setup_cli_outcomes
+ ?? {
+ total_users: 0,
+ cli_only: 0,
+ cli_and_ai_instructions: 0,
+ no_cli: 0,
+ })
const setupCliOutcomeLabels = computed(() => [
t('frontend-onboarding-setup-cli-only'),
t('frontend-onboarding-setup-cli-and-ai'),
@@ -221,14 +233,18 @@ const dailySetupCliSeries = computed(() => buildFrontendOnboardingDailySetupCliS
const hasDailySetupCliOutcomeData = computed(() => dailySetupCliSeries.value.length > 0)
const onboardingGraphV4 = computed(() => {
- const funnel = visibleAnalytics.value?.funnels.v4 ?? []
- const stage = (key: FrontendOnboardingAnalytics['funnels']['v4'][number]['key']) => funnel.find(item => item.key === key)
+ const funnel = visibleAnalytics.value?.funnels.v4
+ ?? visibleAnalytics.value?.funnels.v3
+ ?? []
+ const stage = (key: FrontendOnboardingStageKey) => funnel.find(item => item.key === key)
const intent = stage('intent')
const details = stage('details')
const organization = stage('organization')
const setup = stage('setup')
const parentPercent = (current: number, previous: number) => previous > 0 ? current / previous * 100 : 0
- const interactionNodes = visibleAnalytics.value?.v4_graph.nodes ?? []
+ const interactionNodes = visibleAnalytics.value?.v4_graph?.nodes
+ ?? visibleAnalytics.value?.v3_graph?.nodes
+ ?? []
const graphMetrics = {
...buildFrontendOnboardingGraphMetrics(v4DetailsGraphDefinitions, interactionNodes, details?.reached),
...buildFrontendOnboardingGraphMetrics(v4OrganizationGraphDefinitions, interactionNodes, organization?.reached),
@@ -344,10 +360,15 @@ const onboardingGraphV4 = computed(() => {
{ from: 'organization_invite_viewed', to: 'organization_invite_opened', style: 'branch' },
{ from: 'organization_invite_opened', to: 'organization_invite_succeeded', style: 'branch' },
{ from: 'organization_invite_viewed', to: 'organization_invite_continued', style: 'branch' },
+ { from: 'organization', to: 'app_creation_started', style: 'branch' },
+ { from: 'app_creation_started', to: 'app_creation_succeeded', style: 'branch' },
+ { from: 'app_creation_started', to: 'app_creation_failed', style: 'branch' },
{ from: 'organization_import_succeeded', toPoint: { x: 4200, y: 110 }, style: 'dotted' },
{ from: 'organization_import_failed', toPoint: { x: 4200, y: 210 }, style: 'dotted' },
{ from: 'organization_invite_continued', toPoint: { x: 4200, y: 700 }, style: 'dotted' },
- { fromPoint: { x: 4200, y: 110 }, toPoint: { x: 4200, y: 700 }, style: 'dotted', arrow: false },
+ { from: 'app_creation_succeeded', toPoint: { x: 4200, y: 800 }, style: 'dotted' },
+ { from: 'app_creation_failed', toPoint: { x: 4200, y: 900 }, style: 'dotted' },
+ { fromPoint: { x: 4200, y: 110 }, toPoint: { x: 4200, y: 900 }, style: 'dotted', arrow: false },
{ from: 'organization', toPoint: { x: 4200, y: 540 }, style: 'primary' },
{ fromPoint: { x: 4200, y: 540 }, to: 'setup', style: 'primary' },
{ from: 'setup', to: 'technical_invite_opened', style: 'branch' },
diff --git a/src/services/adminFrontendOnboarding.ts b/src/services/adminFrontendOnboarding.ts
index 82659f0fc5..4b8df87815 100644
--- a/src/services/adminFrontendOnboarding.ts
+++ b/src/services/adminFrontendOnboarding.ts
@@ -66,7 +66,7 @@ export interface FrontendOnboardingAnalytics {
v1_attempts: number
v2_attempts: number
v3_attempts: number
- v4_attempts: number
+ v4_attempts?: number
}>
deduplicated: {
daily_attempts: Array<{
@@ -74,7 +74,7 @@ export interface FrontendOnboardingAnalytics {
v1_attempts: number
v2_attempts: number
v3_attempts: number
- v4_attempts: number
+ v4_attempts?: number
}>
funnels: {
v3: FrontendOnboardingFunnelStage[]
@@ -90,7 +90,7 @@ export interface FrontendOnboardingAnalytics {
v1: FrontendOnboardingFunnelStage[]
v2: FrontendOnboardingFunnelStage[]
v3: FrontendOnboardingFunnelStage[]
- v4: FrontendOnboardingFunnelStage[]
+ v4?: FrontendOnboardingFunnelStage[]
}
v2_graph: {
nodes: Array<{
@@ -104,7 +104,7 @@ export interface FrontendOnboardingAnalytics {
count: number
}>
}
- v4_graph: {
+ v4_graph?: {
nodes: Array<{
key: string
count: number
@@ -116,6 +116,12 @@ export interface FrontendOnboardingAnalytics {
cli_and_ai_instructions: number
no_cli: number
}
+ v2_v4_setup_cli_outcomes?: {
+ total_users: number
+ cli_only: number
+ cli_and_ai_instructions: number
+ no_cli: number
+ }
daily_setup_cli_outcomes: FrontendOnboardingDailySetupCliOutcomePoint[]
posthog_configured: boolean
posthog_connected: boolean
@@ -225,7 +231,7 @@ export function buildFrontendOnboardingDailySeries(
{
label: v4Label,
color: '#f59e0b',
- data: dailyAttempts.map(({ date, v4_attempts }) => ({ date, value: v4_attempts })),
+ data: dailyAttempts.map(({ date, v4_attempts = 0 }) => ({ date, value: v4_attempts })),
},
]
}
@@ -305,7 +311,7 @@ export function buildFrontendOnboardingFunnelSummaries(
export function buildFrontendOnboardingGraphMetrics(
definitions: readonly FrontendOnboardingGraphMetricDefinition[],
- nodes: readonly FrontendOnboardingAnalytics['v4_graph']['nodes'][number][],
+ nodes: readonly { key: string, count: number }[],
appDetailsCount: number | undefined,
): Record {
const counts = new Map(nodes.map(node => [node.key, node.count]))
diff --git a/supabase/functions/_backend/utils/frontend_onboarding_analytics_model.ts b/supabase/functions/_backend/utils/frontend_onboarding_analytics_model.ts
index 41f6505dea..0575846728 100644
--- a/supabase/functions/_backend/utils/frontend_onboarding_analytics_model.ts
+++ b/supabase/functions/_backend/utils/frontend_onboarding_analytics_model.ts
@@ -105,6 +105,7 @@ export interface FrontendOnboardingAnalytics {
nodes: Array<{ key: string, count: number }>
}
v2_v3_setup_cli_outcomes: FrontendOnboardingSetupCliOutcomes
+ v2_v4_setup_cli_outcomes: FrontendOnboardingSetupCliOutcomes
}
const DAY_MS = 24 * 60 * 60 * 1000
@@ -391,7 +392,8 @@ export function buildFrontendOnboardingAnalytics(
const currentV2Attempts = currentAttempts.filter(attempt => attempt.onboardingVersion === 2)
const currentV3Attempts = currentAttempts.filter(attempt => attempt.onboardingVersion === 3)
const currentV4Attempts = currentAttempts.filter(attempt => attempt.onboardingVersion === 4)
- const currentSetupCliOutcomeAttempts = currentAttempts.filter(attempt => attempt.onboardingVersion >= 2)
+ const currentV2V3SetupCliOutcomeAttempts = currentAttempts.filter(attempt => attempt.onboardingVersion === 2 || attempt.onboardingVersion === 3)
+ const currentV2V4SetupCliOutcomeAttempts = currentAttempts.filter(attempt => attempt.onboardingVersion >= 2)
const currentV4ConversionAttempts = attempts.filter(attempt => attempt.onboardingVersion === 4
&& attempt.intentMs >= currentStartMs - FRONTEND_ONBOARDING_FOLLOWUP_MS
&& attempt.intentMs < currentEndMs)
@@ -429,6 +431,7 @@ export function buildFrontendOnboardingAnalytics(
v2_graph: { nodes: buildInteractionGraph(currentV2Attempts) },
v3_graph: { nodes: buildInteractionGraph(currentV3Attempts) },
v4_graph: { nodes: buildInteractionGraph(currentV4Attempts) },
- v2_v3_setup_cli_outcomes: buildSetupCliOutcomes(currentSetupCliOutcomeAttempts),
+ v2_v3_setup_cli_outcomes: buildSetupCliOutcomes(currentV2V3SetupCliOutcomeAttempts),
+ v2_v4_setup_cli_outcomes: buildSetupCliOutcomes(currentV2V4SetupCliOutcomeAttempts),
}
}
diff --git a/tests/admin-frontend-onboarding-dashboard.unit.test.ts b/tests/admin-frontend-onboarding-dashboard.unit.test.ts
index c2bdb0d454..a74a63ad9a 100644
--- a/tests/admin-frontend-onboarding-dashboard.unit.test.ts
+++ b/tests/admin-frontend-onboarding-dashboard.unit.test.ts
@@ -218,6 +218,11 @@ describe('admin frontend onboarding dashboard', () => {
{ label: 'V3', color: '#10b981', data: [] },
{ label: 'V4', color: '#f59e0b', data: [] },
])
+ expect(buildFrontendOnboardingDailySeries([
+ { date: '2026-08-08', v1_attempts: 1, v2_attempts: 2, v3_attempts: 3 },
+ ], 'V1', 'V2', 'V3', 'V4')[3]?.data).toEqual([
+ { date: '2026-08-08', value: 0 },
+ ])
})
it.concurrent('builds paired stacks only for Setup CLI outcome categories present in the range', () => {
@@ -639,7 +644,8 @@ describe('admin frontend onboarding dashboard', () => {
expect(source).not.toContain(' ')
expect(source).toContain('orientation="vertical"')
expect(source).toContain(':values="setupCliOutcomeValues"')
- expect(source).toContain('visibleAnalytics.value?.v2_v3_setup_cli_outcomes')
+ expect(source).toContain('visibleAnalytics.value?.v2_v4_setup_cli_outcomes')
+ expect(source).toContain('?? visibleAnalytics.value?.v2_v3_setup_cli_outcomes')
expect(source).toContain('buildFrontendOnboardingDailySetupCliSeries')
expect(source).toContain('visibleAnalytics.value?.daily_setup_cli_outcomes')
expect(source).toContain(':series="dailySetupCliSeries"')
@@ -652,7 +658,8 @@ describe('admin frontend onboarding dashboard', () => {
expect(source).toContain(`t('frontend-onboarding-funnel-v1-legacy')`)
expect(source).not.toContain(`t('frontend-onboarding-demo-data')`)
expect(source).toContain('buildFrontendOnboardingGraphMetrics')
- expect(source).toContain('visibleAnalytics.value?.v4_graph.nodes')
+ expect(source).toContain('visibleAnalytics.value?.v4_graph?.nodes')
+ expect(source).toContain('?? visibleAnalytics.value?.v3_graph?.nodes')
expect(source).not.toContain('onboardingGraphV4Demo')
expect(source).toContain('buildFrontendOnboardingFunnelSummaries')
expect(template).toContain('summary.conversion_percent')
@@ -690,6 +697,9 @@ describe('admin frontend onboarding dashboard', () => {
expect(source).toContain(`fromPoint: { x: 2130, y: 90 }, toPoint: { x: 2130, y: 870 }, style: 'dotted', arrow: false`)
expect(source).toContain(`fromPoint: { x: 2130, y: 540 }, to: 'organization', style: 'primary'`)
for (const eventKey of [
+ 'onboarding_app_creation_started',
+ 'onboarding_app_creation_succeeded',
+ 'onboarding_app_creation_failed',
'onboarding_organization_import_opened',
'onboarding_organization_import_submitted',
'onboarding_organization_import_succeeded',
@@ -705,6 +715,9 @@ describe('admin frontend onboarding dashboard', () => {
}
expect(source).toContain(`from: 'organization', to: 'organization_import_opened', style: 'branch'`)
expect(source).toContain(`from: 'organization', to: 'organization_invite_viewed', style: 'branch'`)
+ expect(source).toContain(`from: 'organization', to: 'app_creation_started', style: 'branch'`)
+ expect(source).toContain(`from: 'app_creation_started', to: 'app_creation_succeeded', style: 'branch'`)
+ expect(source).toContain(`from: 'app_creation_started', to: 'app_creation_failed', style: 'branch'`)
expect(source).toContain(`from: 'setup', to: 'technical_invite_opened', style: 'branch'`)
const statsCard = await readFile(new URL('../src/components/admin/AdminStatsCard.vue', import.meta.url), 'utf8')
@@ -729,6 +742,7 @@ describe('admin frontend onboarding dashboard', () => {
expect(source).toContain('deduplicateV4Funnel.value')
expect(source).toContain('visibleAnalytics.value?.deduplicated.funnels.v4')
expect(source).toContain('visibleAnalytics.value?.deduplicated.funnels.v3')
+ expect(source).toContain('visibleAnalytics.value?.funnels.v4\n ?? visibleAnalytics.value?.funnels.v3')
expect(source).toContain('buildFrontendOnboardingDailySeries(\n displayedDailyAttempts.value')
expect(source).toContain('buildFrontendOnboardingFunnelStages(displayedV4Funnel.value)')
expect(source).toContain('buildFrontendOnboardingFunnelSummaries(displayedV4Funnel.value)')
@@ -852,6 +866,9 @@ describe('admin frontend onboarding dashboard', () => {
expect(messages['frontend-onboarding-graph-organization-invite-opened']).toBe('Invitation opened')
expect(messages['frontend-onboarding-graph-organization-invite-succeeded']).toBe('Invitation succeeded')
expect(messages['frontend-onboarding-graph-organization-invite-continued']).toBe('Invitation step continued')
+ expect(messages['frontend-onboarding-graph-app-creation-started']).toBe('App creation started')
+ expect(messages['frontend-onboarding-graph-app-creation-succeeded']).toBe('App creation succeeded')
+ expect(messages['frontend-onboarding-graph-app-creation-failed']).toBe('App creation failed')
expect(messages['frontend-onboarding-graph-technical-invite-opened']).toBe('Technical invite opened')
expect(messages['frontend-onboarding-graph-technical-invite-succeeded']).toBe('Technical invite succeeded')
expect(messages['frontend-onboarding-graph-percent-of-level']).toBe('{percent}% of {level}')
diff --git a/tests/app-onboarding-v3.unit.test.ts b/tests/app-onboarding-v3.unit.test.ts
index 3564fe2b79..e5ae8f1aa3 100644
--- a/tests/app-onboarding-v3.unit.test.ts
+++ b/tests/app-onboarding-v3.unit.test.ts
@@ -35,6 +35,7 @@ describe('pre-organization onboarding v3', () => {
expect(onboardingSource).toContain('id="app-onboarding-v2-store-url"')
expect(onboardingSource).toContain("const isStoreImportOpen = ref(false)")
expect(onboardingSource).toContain('data-test="app-onboarding-toggle-store-import"')
+ expect(onboardingSource).toContain('v-if="appDetailsStep === \'app_id\' && (props.preOrg || existingApp === true)"')
expect(onboardingSource).toContain(':aria-expanded="isStoreImportOpen"')
expect(onboardingSource).toContain('v-if="isStoreImportOpen" id="app-onboarding-store-import-panel"')
expect(onboardingSource).toContain("trackDetailsEvent(isStoreImportOpen.value ? 'onboarding_store_import_shown' : 'onboarding_store_import_hidden')")
@@ -60,7 +61,7 @@ describe('pre-organization onboarding v3', () => {
it.concurrent('lets icon re-import change only the selected icon', () => {
const iconImport = onboardingSource.slice(
onboardingSource.indexOf('async function importStoreIcon()'),
- onboardingSource.indexOf('function onSelectIconFormKit('),
+ onboardingSource.indexOf('function toggleStoreImport()'),
)
expect(iconImport).toContain("invokeCapgoApi('app/store-metadata'")
expect(iconImport).toContain('storeIconPreview.value = importedIcon')
@@ -140,6 +141,9 @@ describe('pre-organization onboarding v3', () => {
expect(onboardingSource).toContain("trackDetailsEvent('onboarding_store_icon_import_failed'")
const resetStoreImportState = onboardingSource.slice(onboardingSource.indexOf('function resetStoreImportState()'), onboardingSource.indexOf('let resumeIconLoadRun'))
expect(resetStoreImportState).toContain('cancelPendingStoreIconImport()')
+ expect(resetStoreImportState).toContain("iconStoreUrl.value = ''")
+ expect(resetStoreImportState).toContain('isStoreImportOpen.value = false')
+ expect(resetStoreImportState).toContain('isStoreIconImportOpen.value = false')
})
it.concurrent('invalidates the opposite import before either store request starts', () => {
diff --git a/tests/frontend-onboarding-analytics-model.unit.test.ts b/tests/frontend-onboarding-analytics-model.unit.test.ts
index 39c9265f8d..4960eaa782 100644
--- a/tests/frontend-onboarding-analytics-model.unit.test.ts
+++ b/tests/frontend-onboarding-analytics-model.unit.test.ts
@@ -538,13 +538,21 @@ describe('buildFrontendOnboardingAnalytics', () => {
aiInstructionsCopiedMs: [setupMs + 6 * MINUTE_MS],
}),
attempt({
- attemptId: 'v1-ignored',
- personId: 'person-v1',
- onboardingVersion: 1,
+ attemptId: 'v4-cli-only',
+ onboardingVersion: 4,
+ personId: 'person-v4-cli-only',
intentMs: CURRENT_START_MS + 6 * MINUTE_MS,
setupMs: setupMs + 6 * MINUTE_MS,
cliStartedMs: [setupMs + 7 * MINUTE_MS],
}),
+ attempt({
+ attemptId: 'v1-ignored',
+ personId: 'person-v1',
+ onboardingVersion: 1,
+ intentMs: CURRENT_START_MS + 7 * MINUTE_MS,
+ setupMs: setupMs + 7 * MINUTE_MS,
+ cliStartedMs: [setupMs + 8 * MINUTE_MS],
+ }),
], CURRENT_START_MS, CURRENT_END_MS)
expect(analytics.v2_v3_setup_cli_outcomes).toEqual({
@@ -553,6 +561,12 @@ describe('buildFrontendOnboardingAnalytics', () => {
cli_and_ai_instructions: 1,
no_cli: 2,
})
+ expect(analytics.v2_v4_setup_cli_outcomes).toEqual({
+ total_users: 6,
+ cli_only: 3,
+ cli_and_ai_instructions: 1,
+ no_cli: 2,
+ })
})
it.concurrent('ignores CLI and AI signals outside the 24 hours after setup', () => {
@@ -591,5 +605,6 @@ describe('buildFrontendOnboardingAnalytics', () => {
cli_and_ai_instructions: 1,
no_cli: 1,
})
+ expect(analytics.v2_v4_setup_cli_outcomes).toEqual(analytics.v2_v3_setup_cli_outcomes)
})
})
diff --git a/tests/frontend-onboarding-analytics.unit.test.ts b/tests/frontend-onboarding-analytics.unit.test.ts
index bd1f368dbf..8c8ce6b560 100644
--- a/tests/frontend-onboarding-analytics.unit.test.ts
+++ b/tests/frontend-onboarding-analytics.unit.test.ts
@@ -189,6 +189,12 @@ describe('getAdminFrontendOnboardingAnalytics', () => {
v4_graph: { nodes: [{ key: 'onboarding_app_id_entered', count: 1 }] },
v2_graph: { nodes: [{ key: 'onboarding_app_name_entered', count: 1 }] },
v2_v3_setup_cli_outcomes: {
+ total_users: 1,
+ cli_only: 0,
+ cli_and_ai_instructions: 1,
+ no_cli: 0,
+ },
+ v2_v4_setup_cli_outcomes: {
total_users: 2,
cli_only: 0,
cli_and_ai_instructions: 1,
@@ -213,6 +219,12 @@ describe('getAdminFrontendOnboardingAnalytics', () => {
},
])
expect(result.v2_v3_setup_cli_outcomes).toEqual({
+ total_users: 1,
+ cli_only: 0,
+ cli_and_ai_instructions: 1,
+ no_cli: 0,
+ })
+ expect(result.v2_v4_setup_cli_outcomes).toEqual({
total_users: 2,
cli_only: 0,
cli_and_ai_instructions: 1,
From ebe358a4697bc8f42ee43da4b1f4373a60e24a04 Mon Sep 17 00:00:00 2001
From: WcaleNieWolny
Date: Tue, 18 Aug 2026 17:57:08 +0200
Subject: [PATCH 06/10] fix(i18n): add v4 graph translation context
---
messages/en.context.json | 3 +++
1 file changed, 3 insertions(+)
diff --git a/messages/en.context.json b/messages/en.context.json
index 76508fb5f7..04b14d16ca 100644
--- a/messages/en.context.json
+++ b/messages/en.context.json
@@ -1501,6 +1501,9 @@
"frontend-onboarding-funnel-v2": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"frontend-onboarding-funnel-v3": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"frontend-onboarding-funnel-v4": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "frontend-onboarding-graph-app-creation-failed": "Used in Capgo web console areas: pages/admin/dashboard. Role: analytics graph event label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "frontend-onboarding-graph-app-creation-started": "Used in Capgo web console areas: pages/admin/dashboard. Role: analytics graph event label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "frontend-onboarding-graph-app-creation-succeeded": "Used in Capgo web console areas: pages/admin/dashboard. Role: analytics graph event label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"frontend-onboarding-graph-app-icon-picked": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"frontend-onboarding-graph-app-icon-uploaded": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"frontend-onboarding-graph-app-id-entered": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
From 638feeb51c470f3fcba98350a19422d3a9f953c3 Mon Sep 17 00:00:00 2001
From: WcaleNieWolny
Date: Tue, 18 Aug 2026 18:40:55 +0200
Subject: [PATCH 07/10] fix(admin): label onboarding fallback analytics
---
.../admin/dashboard/frontend-onboarding.vue | 81 +++++++++++++------
...frontend-onboarding-dashboard.unit.test.ts | 52 ++++++++----
2 files changed, 93 insertions(+), 40 deletions(-)
diff --git a/src/pages/admin/dashboard/frontend-onboarding.vue b/src/pages/admin/dashboard/frontend-onboarding.vue
index d95345469a..3ef8a6cac6 100644
--- a/src/pages/admin/dashboard/frontend-onboarding.vue
+++ b/src/pages/admin/dashboard/frontend-onboarding.vue
@@ -161,17 +161,31 @@ const visibleAnalytics = computed(() => isLoadingStats.value ? null : analytics.
const displayedDailyAttempts = computed(() => deduplicateDailyAttempts.value
? visibleAnalytics.value?.deduplicated.daily_attempts ?? []
: visibleAnalytics.value?.daily_attempts ?? [])
-const displayedV4Funnel = computed(() => {
- if (!deduplicateV4Funnel.value) {
- return visibleAnalytics.value?.funnels.v4
- ?? visibleAnalytics.value?.funnels.v3
- ?? []
- }
-
- return visibleAnalytics.value?.deduplicated.funnels.v4
- ?? visibleAnalytics.value?.deduplicated.funnels.v3
- ?? []
+const rawLatestFunnel = computed(() => {
+ const funnels = visibleAnalytics.value?.funnels
+ const v4 = funnels?.v4
+ return {
+ version: v4 === undefined ? 'v3' : 'v4',
+ stages: v4 ?? funnels?.v3 ?? [],
+ } as const
})
+const displayedLatestFunnel = computed(() => {
+ const funnels = deduplicateV4Funnel.value
+ ? visibleAnalytics.value?.deduplicated.funnels
+ : visibleAnalytics.value?.funnels
+ const v4 = funnels?.v4
+ return {
+ version: v4 === undefined ? 'v3' : 'v4',
+ stages: v4 ?? funnels?.v3 ?? [],
+ } as const
+})
+const displayedV4Funnel = computed(() => displayedLatestFunnel.value.stages)
+const latestVersionLabel = computed(() => t(rawLatestFunnel.value.version === 'v4'
+ ? 'frontend-onboarding-version-4'
+ : 'frontend-onboarding-version-3'))
+const displayedFunnelTitle = computed(() => t(displayedLatestFunnel.value.version === 'v4'
+ ? 'frontend-onboarding-funnel-v4'
+ : 'frontend-onboarding-funnel-v3'))
const kpis = computed(() => visibleAnalytics.value?.kpis)
const dailySeries = computed(() => buildFrontendOnboardingDailySeries(
displayedDailyAttempts.value,
@@ -232,19 +246,38 @@ const dailySetupCliSeries = computed(() => buildFrontendOnboardingDailySetupCliS
))
const hasDailySetupCliOutcomeData = computed(() => dailySetupCliSeries.value.length > 0)
+const onboardingGraphSource = computed(() => {
+ const v4Funnel = visibleAnalytics.value?.funnels.v4
+ const v4Nodes = visibleAnalytics.value?.v4_graph?.nodes
+ if (v4Funnel !== undefined && v4Nodes !== undefined) {
+ return {
+ version: 'v4',
+ funnel: v4Funnel,
+ nodes: v4Nodes,
+ } as const
+ }
+
+ return {
+ version: 'v3',
+ funnel: visibleAnalytics.value?.funnels.v3 ?? [],
+ nodes: visibleAnalytics.value?.v3_graph?.nodes ?? [],
+ } as const
+})
+const graphTitle = computed(() => t(onboardingGraphSource.value.version === 'v4'
+ ? 'frontend-onboarding-graph-v4'
+ : 'frontend-onboarding-graph-v3'))
+const graphDescription = computed(() => t(onboardingGraphSource.value.version === 'v4'
+ ? 'frontend-onboarding-graph-v4-description'
+ : 'frontend-onboarding-graph-v3-description'))
+
const onboardingGraphV4 = computed(() => {
- const funnel = visibleAnalytics.value?.funnels.v4
- ?? visibleAnalytics.value?.funnels.v3
- ?? []
+ 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 organization = stage('organization')
const setup = stage('setup')
const parentPercent = (current: number, previous: number) => previous > 0 ? current / previous * 100 : 0
- const interactionNodes = visibleAnalytics.value?.v4_graph?.nodes
- ?? visibleAnalytics.value?.v3_graph?.nodes
- ?? []
const graphMetrics = {
...buildFrontendOnboardingGraphMetrics(v4DetailsGraphDefinitions, interactionNodes, details?.reached),
...buildFrontendOnboardingGraphMetrics(v4OrganizationGraphDefinitions, interactionNodes, organization?.reached),
@@ -410,7 +443,7 @@ const largestDropoffSubtitle = computed(() => {
if (!dropoff)
return t('frontend-onboarding-no-dropoff')
- const stages = visibleAnalytics.value?.funnels.v4 ?? []
+ const stages = rawLatestFunnel.value.stages
const from = stages.find(stage => stage.key === dropoff.from)?.label ?? dropoff.from
const to = stages.find(stage => stage.key === dropoff.to)?.label ?? dropoff.to
return t('frontend-onboarding-transition', { from, to })
@@ -451,7 +484,7 @@ displayStore.defaultBack = '/dashboard'
{{ t('frontend-onboarding') }}
- {{ t('frontend-onboarding-version-4') }}
+ {{ latestVersionLabel }}
@@ -506,13 +539,13 @@ displayStore.defaultBack = '/dashboard'
- {{ t('frontend-onboarding-funnel-v4') }}
+ {{ displayedFunnelTitle }}
{{ t('frontend-onboarding-funnel-description') }}
@@ -534,7 +567,7 @@ displayStore.defaultBack = '/dashboard'
@@ -585,16 +618,16 @@ displayStore.defaultBack = '/dashboard'
- {{ t('frontend-onboarding-graph-v4') }}
+ {{ graphTitle }}
- {{ t('frontend-onboarding-graph-v4-description') }}
+ {{ graphDescription }}
diff --git a/tests/admin-frontend-onboarding-dashboard.unit.test.ts b/tests/admin-frontend-onboarding-dashboard.unit.test.ts
index a74a63ad9a..a2a593cc45 100644
--- a/tests/admin-frontend-onboarding-dashboard.unit.test.ts
+++ b/tests/admin-frontend-onboarding-dashboard.unit.test.ts
@@ -654,25 +654,25 @@ describe('admin frontend onboarding dashboard', () => {
expect(source).toContain(`t('frontend-onboarding-version-2')`)
expect(source).toContain(`t('frontend-onboarding-version-3')`)
expect(source).toContain(`t('frontend-onboarding-version-4')`)
- expect(source).toContain(`t('frontend-onboarding-funnel-v4')`)
+ expect(source).toContain(`'frontend-onboarding-funnel-v4'`)
expect(source).toContain(`t('frontend-onboarding-funnel-v1-legacy')`)
expect(source).not.toContain(`t('frontend-onboarding-demo-data')`)
expect(source).toContain('buildFrontendOnboardingGraphMetrics')
expect(source).toContain('visibleAnalytics.value?.v4_graph?.nodes')
- expect(source).toContain('?? visibleAnalytics.value?.v3_graph?.nodes')
+ expect(source).toContain('visibleAnalytics.value?.v3_graph?.nodes')
expect(source).not.toContain('onboardingGraphV4Demo')
expect(source).toContain('buildFrontendOnboardingFunnelSummaries')
expect(template).toContain('summary.conversion_percent')
expect(template).not.toContain('of_start_percent')
- const v4FunnelIndex = source.indexOf(`t('frontend-onboarding-funnel-v4')`)
- const intentDetailsChartIndex = source.indexOf(`t('frontend-onboarding-daily-intent-to-details')`)
- const detailsOrganizationChartIndex = source.indexOf(`t('frontend-onboarding-daily-details-to-organization')`)
- const organizationSetupChartIndex = source.indexOf(`t('frontend-onboarding-daily-organization-to-setup')`)
- const graphIndex = source.indexOf(`t('frontend-onboarding-graph-v4')`)
- const cliOutcomeIndex = source.indexOf(`t('frontend-onboarding-setup-cli-outcomes-v2-v4')`)
- const dailyCliOutcomeIndex = source.indexOf(`t('frontend-onboarding-daily-setup-cli-outcomes-v2-v4')`)
- const legacyIndex = source.indexOf(`t('frontend-onboarding-funnel-v1-legacy')`)
+ const v4FunnelIndex = template.indexOf('chart-id="funnel-v4"')
+ const intentDetailsChartIndex = template.indexOf(`t('frontend-onboarding-daily-intent-to-details')`)
+ const detailsOrganizationChartIndex = template.indexOf(`t('frontend-onboarding-daily-details-to-organization')`)
+ const organizationSetupChartIndex = template.indexOf(`t('frontend-onboarding-daily-organization-to-setup')`)
+ const graphIndex = template.indexOf('chart-id="journey-graph-v4"')
+ 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(v4FunnelIndex).toBeLessThan(graphIndex)
expect(v4FunnelIndex).toBeLessThan(intentDetailsChartIndex)
expect(intentDetailsChartIndex).toBeLessThan(detailsOrganizationChartIndex)
@@ -682,7 +682,7 @@ describe('admin frontend onboarding dashboard', () => {
expect(cliOutcomeIndex).toBeLessThan(dailyCliOutcomeIndex)
expect(dailyCliOutcomeIndex).toBeLessThan(legacyIndex)
- const dailyCliOutcomeSection = source.slice(dailyCliOutcomeIndex, legacyIndex)
+ const dailyCliOutcomeSection = template.slice(dailyCliOutcomeIndex, legacyIndex)
expect(dailyCliOutcomeSection).toContain(':has-data="hasDailySetupCliOutcomeData"')
expect(dailyCliOutcomeSection).toContain(`t('frontend-onboarding-daily-setup-cli-outcomes-description')`)
expect(dailyCliOutcomeSection).toContain(' {
const source = await readFile(new URL('../src/pages/admin/dashboard/frontend-onboarding.vue', import.meta.url), 'utf8')
const control = await readFile(new URL('../src/components/admin/AdminChartDeduplicateControl.vue', import.meta.url), 'utf8')
const dailyAttemptsIndex = source.indexOf(`t('frontend-onboarding-daily-attempts')`)
- const v4FunnelIndex = source.indexOf(`t('frontend-onboarding-funnel-v4')`)
+ const v4FunnelIndex = source.indexOf('chart-id="funnel-v4"')
const nextChartIndex = source.indexOf(`t('frontend-onboarding-daily-intent-to-details')`)
expect(source).toContain('const deduplicateDailyAttempts = ref(false)')
@@ -740,16 +740,15 @@ describe('admin frontend onboarding dashboard', () => {
expect(source).toContain('visibleAnalytics.value?.deduplicated.daily_attempts')
expect(source).toContain('const displayedV4Funnel = computed')
expect(source).toContain('deduplicateV4Funnel.value')
- expect(source).toContain('visibleAnalytics.value?.deduplicated.funnels.v4')
- expect(source).toContain('visibleAnalytics.value?.deduplicated.funnels.v3')
- expect(source).toContain('visibleAnalytics.value?.funnels.v4\n ?? visibleAnalytics.value?.funnels.v3')
+ expect(source).toContain('visibleAnalytics.value?.deduplicated.funnels')
+ expect(source).toContain('stages: v4 ?? funnels?.v3 ?? []')
expect(source).toContain('buildFrontendOnboardingDailySeries(\n displayedDailyAttempts.value')
expect(source).toContain('buildFrontendOnboardingFunnelStages(displayedV4Funnel.value)')
expect(source).toContain('buildFrontendOnboardingFunnelSummaries(displayedV4Funnel.value)')
expect(source.match(/deduplicateDailyAttempts/g)).toHaveLength(3)
expect(source.match(/deduplicateV4Funnel/g)).toHaveLength(3)
expect(source.slice(dailyAttemptsIndex, v4FunnelIndex)).toContain(`:chart-label="t('frontend-onboarding-daily-attempts')"`)
- expect(source.slice(v4FunnelIndex, nextChartIndex)).toContain(`:chart-label="t('frontend-onboarding-funnel-v4')"`)
+ expect(source.slice(v4FunnelIndex, nextChartIndex)).toContain(':chart-label="displayedFunnelTitle"')
expect(source).toContain('const result = await adminStore.fetchStats(\'frontend_onboarding_analytics\')')
expect(source).toContain('!Array.isArray(result.deduplicated?.daily_attempts)')
expect(source).toContain('!Array.isArray(result.deduplicated?.funnels?.v4)')
@@ -763,6 +762,27 @@ describe('admin frontend onboarding dashboard', () => {
expect(control).toContain(`t('frontend-onboarding-deduplicate-by-user')`)
})
+ it.concurrent('labels v3 fallback data and resolves drop-off labels from the same funnel source', async () => {
+ const source = await readFile(new URL('../src/pages/admin/dashboard/frontend-onboarding.vue', import.meta.url), 'utf8')
+ const template = source.slice(source.indexOf(''))
+
+ expect(source).toContain('const rawLatestFunnel = computed')
+ expect(source).toContain("version: v4 === undefined ? 'v3' : 'v4'")
+ expect(source).toContain('const displayedLatestFunnel = computed')
+ expect(source).toContain('const displayedFunnelTitle = computed')
+ expect(source).toContain(`t(displayedLatestFunnel.value.version === 'v4'`)
+ expect(source).toContain('const onboardingGraphSource = computed')
+ expect(source).toContain('const graphTitle = computed')
+ expect(source).toContain(`t(onboardingGraphSource.value.version === 'v4'`)
+ expect(source).toContain('const stages = rawLatestFunnel.value.stages')
+ expect(source).not.toContain('const stages = visibleAnalytics.value?.funnels.v4 ?? []')
+ expect(template).toContain(':title="displayedFunnelTitle"')
+ expect(template).toContain('{{ displayedFunnelTitle }}')
+ expect(template).toContain(':chart-label="displayedFunnelTitle"')
+ expect(template).toContain(':title="graphTitle"')
+ expect(template).toContain('{{ graphTitle }}')
+ })
+
it.concurrent('shows request failures instead of zero analytics', async () => {
const source = await readFile(new URL('../src/pages/admin/dashboard/frontend-onboarding.vue', import.meta.url), 'utf8')
const template = source.slice(source.indexOf(''))
From a5da508389235f64912af3b772f3fbc06da280dd Mon Sep 17 00:00:00 2001
From: WcaleNieWolny
Date: Tue, 18 Aug 2026 18:46:01 +0200
Subject: [PATCH 08/10] test(admin): expect dynamic onboarding titles
---
tests/admin-chart-card-collapse.unit.test.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/tests/admin-chart-card-collapse.unit.test.ts b/tests/admin-chart-card-collapse.unit.test.ts
index 013ec9dadc..c9c305a4bb 100644
--- a/tests/admin-chart-card-collapse.unit.test.ts
+++ b/tests/admin-chart-card-collapse.unit.test.ts
@@ -388,8 +388,8 @@ describe('admin chart card minimize control', () => {
it('uses the collapsible card for every custom frontend onboarding graph', () => {
const source = readFileSync(resolve(process.cwd(), 'src/pages/admin/dashboard/frontend-onboarding.vue'), 'utf8')
- expect(source).toContain(`:title="t('frontend-onboarding-funnel-v4')"`)
- expect(source).toContain(`:title="t('frontend-onboarding-graph-v4')"`)
+ expect(source).toContain(':title="displayedFunnelTitle"')
+ expect(source).toContain(':title="graphTitle"')
expect(source).toContain(`:title="t('frontend-onboarding-funnel-v1-legacy')"`)
expect(source.match(/
Date: Tue, 18 Aug 2026 19:02:45 +0200
Subject: [PATCH 09/10] fix(onboarding): preserve v3 analytics compatibility
---
messages/en.context.json | 42 ++++-----
.../dashboard/AppOnboardingFlow.vue | 5 +-
.../dashboard/AppOnboardingWelcome.vue | 50 +----------
.../admin/dashboard/frontend-onboarding.vue | 9 +-
src/services/adminFrontendOnboarding.ts | 32 ++++---
.../frontend_onboarding_analytics_model.ts | 21 +++++
...frontend-onboarding-dashboard.unit.test.ts | 2 +
tests/app-onboarding-v3.unit.test.ts | 90 ++++++-------------
tests/app-onboarding-welcome.unit.test.ts | 8 +-
...nd-onboarding-analytics-model.unit.test.ts | 62 +++++++++----
...frontend-onboarding-analytics.unit.test.ts | 6 +-
11 files changed, 153 insertions(+), 174 deletions(-)
diff --git a/messages/en.context.json b/messages/en.context.json
index 04b14d16ca..c099337711 100644
--- a/messages/en.context.json
+++ b/messages/en.context.json
@@ -3053,25 +3053,25 @@
"your-role-in-org": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"your-usage": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"zip-bundle": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
- "app-onboarding-icon-step-helper": "Used in app onboarding details wizard. Role: helper text.",
- "app-onboarding-icon-step-title": "Used in app onboarding details wizard. Role: step title.",
- "app-onboarding-import-icon-only-button": "Used in app onboarding details wizard. Role: button label.",
- "app-onboarding-import-icon-only-helper": "Used in app onboarding details wizard. Role: helper text explaining that store re-import changes only the icon.",
- "app-onboarding-import-icon-only-title": "Used in app onboarding details wizard. Role: section title.",
- "app-onboarding-imported-icon-alt": "Used in app onboarding details wizard. Role: image alternative text.",
- "app-onboarding-imported-icon-helper": "Used in app onboarding details wizard. Role: helper text.",
- "app-onboarding-imported-icon-label": "Used in app onboarding details wizard. Role: status label.",
- "app-onboarding-imported-icon-selected": "Used in app onboarding details wizard. Role: selected button label.",
- "app-onboarding-name-step-helper": "Used in app onboarding details wizard. Role: helper text.",
- "app-onboarding-name-step-title": "Used in app onboarding details wizard. Role: step title.",
- "app-onboarding-remove-icon": "Used in app onboarding details wizard. Role: button label.",
- "app-onboarding-skip-app-id": "Used in app onboarding details wizard. Role: button label that accepts Capgo's generated App ID.",
- "app-onboarding-toast-store-icon-error": "Used in app onboarding details wizard. Role: error toast.",
- "app-onboarding-use-different-icon": "Used in app onboarding details wizard. Role: section label.",
- "app-onboarding-use-imported-icon": "Used in app onboarding details wizard. Role: button label.",
- "app-onboarding-app-id-generated-helper": "Used in app onboarding details wizard. Role: helper text. Preserve the {appId} placeholder.",
- "app-onboarding-app-id-learn-more": "Used in app onboarding details wizard. Role: button opening guidance about Capgo App IDs.",
- "app-onboarding-app-id-step-helper": "Used in app onboarding details wizard. Role: helper text.",
- "app-onboarding-app-id-step-title": "Used in app onboarding details wizard. Role: step title.",
- "app-onboarding-finish-details": "Used in app onboarding details wizard. Role: final creation button label."
+ "app-onboarding-icon-step-helper": "Used in app onboarding details wizard. Role: helper text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "app-onboarding-icon-step-title": "Used in app onboarding details wizard. Role: step title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "app-onboarding-import-icon-only-button": "Used in app onboarding details wizard. Role: button label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "app-onboarding-import-icon-only-helper": "Used in app onboarding details wizard. Role: helper text explaining that store re-import changes only the icon. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "app-onboarding-import-icon-only-title": "Used in app onboarding details wizard. Role: section title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "app-onboarding-imported-icon-alt": "Used in app onboarding details wizard. Role: image alternative text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "app-onboarding-imported-icon-helper": "Used in app onboarding details wizard. Role: helper text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "app-onboarding-imported-icon-label": "Used in app onboarding details wizard. Role: status label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "app-onboarding-imported-icon-selected": "Used in app onboarding details wizard. Role: selected button label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "app-onboarding-name-step-helper": "Used in app onboarding details wizard. Role: helper text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "app-onboarding-name-step-title": "Used in app onboarding details wizard. Role: step title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "app-onboarding-remove-icon": "Used in app onboarding details wizard. Role: button label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "app-onboarding-skip-app-id": "Used in app onboarding details wizard. Role: button label that accepts Capgo's generated App ID. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "app-onboarding-toast-store-icon-error": "Used in app onboarding details wizard. Role: error toast. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "app-onboarding-use-different-icon": "Used in app onboarding details wizard. Role: section label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "app-onboarding-use-imported-icon": "Used in app onboarding details wizard. Role: button label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "app-onboarding-app-id-generated-helper": "Used in app onboarding details wizard. Role: helper text. Preserve the {appId} placeholder. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "app-onboarding-app-id-learn-more": "Used in app onboarding details wizard. Role: button opening guidance about Capgo App IDs. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "app-onboarding-app-id-step-helper": "Used in app onboarding details wizard. Role: helper text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "app-onboarding-app-id-step-title": "Used in app onboarding details wizard. Role: step title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "app-onboarding-finish-details": "Used in app onboarding details wizard. Role: final creation button label. Translate for UI; keep Capgo product names, code, and placeholders unchanged."
}
diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue
index b4d8e2b1f9..1f94bcbc47 100644
--- a/src/components/dashboard/AppOnboardingFlow.vue
+++ b/src/components/dashboard/AppOnboardingFlow.vue
@@ -614,8 +614,9 @@ function showWelcomeOnDesktop() {
function continueFromWelcome() {
const nextStep = flowStep.value
- progressTracker?.completeStep('welcome', { nextStep })
- progressTracker?.viewStep(nextStep, 'welcome')
+ const nextAnalyticsStep = analyticsStepFor(nextStep)
+ progressTracker?.completeStep('welcome', { nextStep: nextAnalyticsStep })
+ progressTracker?.viewStep(nextAnalyticsStep, 'welcome')
welcomePending.value = false
}
diff --git a/src/components/dashboard/AppOnboardingWelcome.vue b/src/components/dashboard/AppOnboardingWelcome.vue
index 31ba8eddaf..d80b000125 100644
--- a/src/components/dashboard/AppOnboardingWelcome.vue
+++ b/src/components/dashboard/AppOnboardingWelcome.vue
@@ -42,12 +42,12 @@ const { t } = useI18n()
{{ t('onboarding-welcome-cta') }}
-
+
@@ -157,28 +157,7 @@ const { t } = useI18n()
}
.onboarding-welcome-primary {
- position: relative;
- z-index: 1;
- display: inline-flex;
- min-height: 3.375rem;
- align-items: center;
- justify-content: center;
- gap: 0.75rem;
- overflow: hidden;
- border: 1px solid rgb(29 78 216 / 88%);
- border-radius: 0.875rem;
- padding: 0 1.5625rem;
- background: linear-gradient(135deg, #3b82f6, #2563eb 64%, #1d4ed8);
- box-shadow:
- 0 0.5rem 1.25rem rgb(37 99 235 / 16%),
- inset 0 1px 0 rgb(255 255 255 / 28%);
- color: white;
- cursor: pointer;
- font-size: 0.9375rem;
- font-weight: 750;
- transition:
- filter 160ms ease,
- transform 160ms ease;
+ isolation: isolate;
}
.onboarding-welcome-primary::before {
@@ -198,24 +177,6 @@ const { t } = useI18n()
z-index: 1;
}
-.onboarding-welcome-primary:hover {
- filter: brightness(1.04);
- transform: translateY(-1px);
-}
-
-.onboarding-welcome-primary:focus-visible {
- outline: 3px solid rgb(59 130 246 / 35%);
- outline-offset: 5px;
-}
-
-.onboarding-welcome-arrow {
- transition: transform 160ms ease;
-}
-
-.onboarding-welcome-primary:hover .onboarding-welcome-arrow {
- transform: translateX(3px);
-}
-
:global(.dark) .onboarding-welcome {
background:
radial-gradient(circle at 50% 40%, rgb(59 130 246 / 18%), transparent 29rem),
@@ -292,10 +253,5 @@ const { t } = useI18n()
.onboarding-welcome-primary::before {
animation: none;
}
-
- .onboarding-welcome-primary,
- .onboarding-welcome-arrow {
- transition: none;
- }
}
diff --git a/src/pages/admin/dashboard/frontend-onboarding.vue b/src/pages/admin/dashboard/frontend-onboarding.vue
index 3ef8a6cac6..5be90f9e0f 100644
--- a/src/pages/admin/dashboard/frontend-onboarding.vue
+++ b/src/pages/admin/dashboard/frontend-onboarding.vue
@@ -186,7 +186,7 @@ const latestVersionLabel = computed(() => t(rawLatestFunnel.value.version === 'v
const displayedFunnelTitle = computed(() => t(displayedLatestFunnel.value.version === 'v4'
? 'frontend-onboarding-funnel-v4'
: 'frontend-onboarding-funnel-v3'))
-const kpis = computed(() => visibleAnalytics.value?.kpis)
+const kpis = computed(() => visibleAnalytics.value?.v4_kpis ?? visibleAnalytics.value?.kpis)
const dailySeries = computed(() => buildFrontendOnboardingDailySeries(
displayedDailyAttempts.value,
t('frontend-onboarding-version-1'),
@@ -194,9 +194,10 @@ const dailySeries = computed(() => buildFrontendOnboardingDailySeries(
t('frontend-onboarding-version-3'),
t('frontend-onboarding-version-4'),
))
-const intentToDetailsDaily = computed(() => visibleAnalytics.value?.daily_conversions?.intent_to_details ?? [])
-const detailsToOrganizationDaily = computed(() => visibleAnalytics.value?.daily_conversions?.details_to_organization ?? [])
-const organizationToSetupDaily = computed(() => visibleAnalytics.value?.daily_conversions?.organization_to_setup ?? [])
+const latestDailyConversions = computed(() => visibleAnalytics.value?.v4_daily_conversions ?? visibleAnalytics.value?.daily_conversions)
+const intentToDetailsDaily = computed(() => latestDailyConversions.value?.intent_to_details ?? [])
+const detailsToOrganizationDaily = computed(() => latestDailyConversions.value?.details_to_organization ?? [])
+const organizationToSetupDaily = computed(() => latestDailyConversions.value?.organization_to_setup ?? [])
const hasConversionData = (points: readonly { started: number }[]) => points.some(point => point.started > 0)
const v1FunnelStages = computed(() => buildFrontendOnboardingFunnelStages(visibleAnalytics.value?.funnels.v1 ?? []))
const v4FunnelStages = computed(() => buildFrontendOnboardingFunnelStages(displayedV4Funnel.value))
diff --git a/src/services/adminFrontendOnboarding.ts b/src/services/adminFrontendOnboarding.ts
index 4b8df87815..1f6bf15cfb 100644
--- a/src/services/adminFrontendOnboarding.ts
+++ b/src/services/adminFrontendOnboarding.ts
@@ -52,15 +52,24 @@ export interface FrontendOnboardingDailyConversionPoint {
conversion_percent: number | null
}
+export interface FrontendOnboardingKpis {
+ attempts: number
+ completed: number
+ completion_rate: number
+ median_completion_ms: number | null
+ largest_dropoff: FrontendOnboardingLargestDropoff | null
+ comparison: FrontendOnboardingComparison
+}
+
+export interface FrontendOnboardingDailyConversions {
+ intent_to_details: FrontendOnboardingDailyConversionPoint[]
+ details_to_organization: FrontendOnboardingDailyConversionPoint[]
+ organization_to_setup: FrontendOnboardingDailyConversionPoint[]
+}
+
export interface FrontendOnboardingAnalytics {
- kpis: {
- attempts: number
- completed: number
- completion_rate: number
- median_completion_ms: number | null
- largest_dropoff: FrontendOnboardingLargestDropoff | null
- comparison: FrontendOnboardingComparison
- }
+ kpis: FrontendOnboardingKpis
+ v4_kpis?: FrontendOnboardingKpis
daily_attempts: Array<{
date: string
v1_attempts: number
@@ -81,11 +90,8 @@ export interface FrontendOnboardingAnalytics {
v4?: FrontendOnboardingFunnelStage[]
}
}
- daily_conversions: {
- intent_to_details: FrontendOnboardingDailyConversionPoint[]
- details_to_organization: FrontendOnboardingDailyConversionPoint[]
- organization_to_setup: FrontendOnboardingDailyConversionPoint[]
- }
+ daily_conversions: FrontendOnboardingDailyConversions
+ v4_daily_conversions?: FrontendOnboardingDailyConversions
funnels: {
v1: FrontendOnboardingFunnelStage[]
v2: FrontendOnboardingFunnelStage[]
diff --git a/supabase/functions/_backend/utils/frontend_onboarding_analytics_model.ts b/supabase/functions/_backend/utils/frontend_onboarding_analytics_model.ts
index 0575846728..21605f40ae 100644
--- a/supabase/functions/_backend/utils/frontend_onboarding_analytics_model.ts
+++ b/supabase/functions/_backend/utils/frontend_onboarding_analytics_model.ts
@@ -76,6 +76,7 @@ export interface FrontendOnboardingSetupCliOutcomes {
export interface FrontendOnboardingAnalytics {
kpis: FrontendOnboardingPeriodKpis & { comparison: FrontendOnboardingComparison }
+ v4_kpis: FrontendOnboardingPeriodKpis & { comparison: FrontendOnboardingComparison }
daily_attempts: FrontendOnboardingDailyAttempt[]
deduplicated: {
daily_attempts: FrontendOnboardingDailyAttempt[]
@@ -89,6 +90,11 @@ export interface FrontendOnboardingAnalytics {
details_to_organization: FrontendOnboardingDailyConversion[]
organization_to_setup: FrontendOnboardingDailyConversion[]
}
+ v4_daily_conversions: {
+ intent_to_details: FrontendOnboardingDailyConversion[]
+ details_to_organization: FrontendOnboardingDailyConversion[]
+ organization_to_setup: FrontendOnboardingDailyConversion[]
+ }
funnels: {
v1: FrontendOnboardingFunnelStage[]
v2: FrontendOnboardingFunnelStage[]
@@ -394,10 +400,16 @@ export function buildFrontendOnboardingAnalytics(
const currentV4Attempts = currentAttempts.filter(attempt => attempt.onboardingVersion === 4)
const currentV2V3SetupCliOutcomeAttempts = currentAttempts.filter(attempt => attempt.onboardingVersion === 2 || attempt.onboardingVersion === 3)
const currentV2V4SetupCliOutcomeAttempts = currentAttempts.filter(attempt => attempt.onboardingVersion >= 2)
+ const currentV3ConversionAttempts = attempts.filter(attempt => attempt.onboardingVersion === 3
+ && attempt.intentMs >= currentStartMs - FRONTEND_ONBOARDING_FOLLOWUP_MS
+ && attempt.intentMs < currentEndMs)
const currentV4ConversionAttempts = attempts.filter(attempt => attempt.onboardingVersion === 4
&& attempt.intentMs >= currentStartMs - FRONTEND_ONBOARDING_FOLLOWUP_MS
&& 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 deduplicatedCurrentAttempts = selectDeduplicatedAttempts(currentAttempts)
@@ -406,6 +418,10 @@ export function buildFrontendOnboardingAnalytics(
return {
kpis: {
+ ...currentV3.kpis,
+ comparison: comparePeriods(currentV3.kpis, previousV3.kpis),
+ },
+ v4_kpis: {
...currentV4.kpis,
comparison: comparePeriods(currentV4.kpis, previousV4.kpis),
},
@@ -418,6 +434,11 @@ export function buildFrontendOnboardingAnalytics(
},
},
daily_conversions: {
+ intent_to_details: buildDailyConversion(attempts, currentStartMs, currentEndMs, 'intent', 'details'),
+ details_to_organization: buildDailyConversion(currentV3ConversionAttempts, currentStartMs, currentEndMs, 'details', 'organization'),
+ organization_to_setup: buildDailyConversion(currentV3ConversionAttempts, currentStartMs, currentEndMs, 'organization', 'setup'),
+ },
+ v4_daily_conversions: {
intent_to_details: buildDailyConversion(attempts, currentStartMs, currentEndMs, 'intent', 'details'),
details_to_organization: buildDailyConversion(currentV4ConversionAttempts, currentStartMs, currentEndMs, 'details', 'organization'),
organization_to_setup: buildDailyConversion(currentV4ConversionAttempts, currentStartMs, currentEndMs, 'organization', 'setup'),
diff --git a/tests/admin-frontend-onboarding-dashboard.unit.test.ts b/tests/admin-frontend-onboarding-dashboard.unit.test.ts
index a2a593cc45..0204744753 100644
--- a/tests/admin-frontend-onboarding-dashboard.unit.test.ts
+++ b/tests/admin-frontend-onboarding-dashboard.unit.test.ts
@@ -622,6 +622,8 @@ describe('admin frontend onboarding dashboard', () => {
expect(onLoadingCallback).toContain('if (!value)')
expect(onLoadingCallback).toContain('isLoading.value = false')
expect(source).toContain('const visibleAnalytics = computed(() => isLoadingStats.value ? null : analytics.value)')
+ expect(source).toContain('visibleAnalytics.value?.v4_kpis ?? visibleAnalytics.value?.kpis')
+ expect(source).toContain('visibleAnalytics.value?.v4_daily_conversions ?? visibleAnalytics.value?.daily_conversions')
})
it.concurrent('uses the existing admin dashboard components for v4 and the legacy funnel', async () => {
diff --git a/tests/app-onboarding-v3.unit.test.ts b/tests/app-onboarding-v3.unit.test.ts
index e5ae8f1aa3..fbbc3eb27f 100644
--- a/tests/app-onboarding-v3.unit.test.ts
+++ b/tests/app-onboarding-v3.unit.test.ts
@@ -4,6 +4,14 @@ import { describe, expect, it } from 'vitest'
const onboardingSource = readFileSync(new URL('../src/components/dashboard/AppOnboardingFlow.vue', import.meta.url), 'utf8')
const iconInputSource = readFileSync(new URL('../src/components/dashboard/AppOnboardingIconInput.vue', import.meta.url), 'utf8')
+function sliceBetween(source: string, startMarker: string, endMarker: string) {
+ const start = source.indexOf(startMarker)
+ const end = source.indexOf(endMarker)
+ expect(start, `missing marker: ${startMarker}`).toBeGreaterThan(-1)
+ expect(end, `missing or misplaced marker: ${endMarker}`).toBeGreaterThan(start)
+ return source.slice(start, end)
+}
+
describe('pre-organization onboarding v3', () => {
it.concurrent('splits app details into name, App ID, and icon screens', () => {
expect(onboardingSource).toContain("type AppDetailsStep = 'name' | 'app_id' | 'icon'")
@@ -21,10 +29,7 @@ describe('pre-organization onboarding v3', () => {
})
it.concurrent('validates the generated or entered App ID before either action advances', () => {
- const appIdContinuation = onboardingSource.slice(
- onboardingSource.indexOf('function continueFromAppId()'),
- onboardingSource.indexOf('function viewPreviousAppDetailsStep()'),
- )
+ const appIdContinuation = sliceBetween(onboardingSource, 'function continueFromAppId()', 'function viewPreviousAppDetailsStep()')
expect(appIdContinuation).toContain('if (!ensureValidAppId())')
expect(appIdContinuation).toContain('function skipAppId()')
expect(appIdContinuation).toContain('continueFromAppId()')
@@ -44,10 +49,7 @@ describe('pre-organization onboarding v3', () => {
expect(onboardingSource).toContain("trackDetailsEvent('onboarding_store_import_succeeded'")
expect(onboardingSource).toContain("trackDetailsEvent('onboarding_store_import_failed'")
expect(onboardingSource).not.toContain("{ store_url: requestedUrl }")
- const storeImport = onboardingSource.slice(
- onboardingSource.indexOf('id="app-onboarding-v2-store-url"'),
- onboardingSource.indexOf(""),
- )
+ const storeImport = sliceBetween(onboardingSource, 'id="app-onboarding-v2-store-url"', "")
expect(storeImport).toContain('class="d-btn min-h-11 w-full sm:w-auto"')
})
@@ -59,10 +61,7 @@ describe('pre-organization onboarding v3', () => {
})
it.concurrent('lets icon re-import change only the selected icon', () => {
- const iconImport = onboardingSource.slice(
- onboardingSource.indexOf('async function importStoreIcon()'),
- onboardingSource.indexOf('function toggleStoreImport()'),
- )
+ const iconImport = sliceBetween(onboardingSource, 'async function importStoreIcon()', 'function toggleStoreImport()')
expect(iconImport).toContain("invokeCapgoApi('app/store-metadata'")
expect(iconImport).toContain('storeIconPreview.value = importedIcon')
expect(iconImport).toContain('selectImportedIcon(false)')
@@ -78,10 +77,7 @@ describe('pre-organization onboarding v3', () => {
expect(onboardingSource).toContain(':aria-expanded="isStoreIconImportOpen"')
expect(onboardingSource).toContain('v-if="isStoreIconImportOpen" id="app-onboarding-icon-store-import-panel"')
expect(onboardingSource).toContain('class="mb-6 mt-5 overflow-hidden rounded-xl')
- const iconImportPanel = onboardingSource.slice(
- onboardingSource.indexOf('id="app-onboarding-icon-store-import-panel"'),
- onboardingSource.indexOf('')
expect(iconImportPanel).toContain('class="d-btn min-h-11 w-full sm:w-auto"')
expect(iconImportPanel).not.toContain('sm:flex-row')
@@ -93,10 +89,7 @@ describe('pre-organization onboarding v3', () => {
expect(onboardingSource).toContain('v-if="!props.preOrg && appDetailsStep === \'name\'" class="grid gap-3 sm:grid-cols-2"')
expect(onboardingSource).toContain('existingApp.value = props.preOrg ? true : null')
expect(onboardingSource).toContain("toast.error(t('app-onboarding-toast-existing-required'))")
- const appChoice = onboardingSource.slice(
- onboardingSource.indexOf('v-if="!props.preOrg && appDetailsStep === \'name\'"'),
- onboardingSource.indexOf(''),
- )
+ const appChoice = sliceBetween(onboardingSource, 'v-if="!props.preOrg && appDetailsStep === \'name\'"', '')
expect(appChoice.match(/class="d-btn group h-auto min-h-32/g)).toHaveLength(2)
})
@@ -125,10 +118,7 @@ describe('pre-organization onboarding v3', () => {
})
it.concurrent('classifies restored draft icon data as a file in creation telemetry', () => {
- const iconSource = onboardingSource.slice(
- onboardingSource.indexOf('const selectedAppIconSource = computed'),
- onboardingSource.indexOf('function createAiHelpPrompt()'),
- )
+ const iconSource = sliceBetween(onboardingSource, 'const selectedAppIconSource = computed', 'function createAiHelpPrompt()')
expect(iconSource).toContain('resolveOnboardingAppIconSource({')
expect(iconSource).toContain('hasSelectedIconFile: Boolean(selectedIconFile.value)')
expect(iconSource).toContain('localIconPreview: localIconPreview.value')
@@ -139,7 +129,7 @@ describe('pre-organization onboarding v3', () => {
expect(onboardingSource).toContain("trackDetailsEvent('onboarding_store_icon_import_submitted'")
expect(onboardingSource).toContain("trackDetailsEvent('onboarding_store_icon_import_succeeded'")
expect(onboardingSource).toContain("trackDetailsEvent('onboarding_store_icon_import_failed'")
- const resetStoreImportState = onboardingSource.slice(onboardingSource.indexOf('function resetStoreImportState()'), onboardingSource.indexOf('let resumeIconLoadRun'))
+ const resetStoreImportState = sliceBetween(onboardingSource, 'function resetStoreImportState()', 'let resumeIconLoadRun')
expect(resetStoreImportState).toContain('cancelPendingStoreIconImport()')
expect(resetStoreImportState).toContain("iconStoreUrl.value = ''")
expect(resetStoreImportState).toContain('isStoreImportOpen.value = false')
@@ -147,17 +137,11 @@ describe('pre-organization onboarding v3', () => {
})
it.concurrent('invalidates the opposite import before either store request starts', () => {
- const metadataImport = onboardingSource.slice(
- onboardingSource.indexOf('async function importStoreMetadata()'),
- onboardingSource.indexOf('function cancelPendingStoreIconImport()'),
- )
+ const metadataImport = sliceBetween(onboardingSource, 'async function importStoreMetadata()', 'function cancelPendingStoreIconImport()')
expect(metadataImport).toContain('cancelPendingStoreIconImport()')
expect(metadataImport.indexOf('cancelPendingStoreIconImport()')).toBeLessThan(metadataImport.indexOf('const requestedRun = ++storeImportRun'))
- const iconImport = onboardingSource.slice(
- onboardingSource.indexOf('async function importStoreIcon()'),
- onboardingSource.indexOf('function toggleStoreImport()'),
- )
+ const iconImport = sliceBetween(onboardingSource, 'async function importStoreIcon()', 'function toggleStoreImport()')
expect(iconImport).toContain('cancelPendingStoreImport()')
expect(iconImport.indexOf('cancelPendingStoreImport()')).toBeLessThan(iconImport.indexOf('const requestedRun = ++storeIconImportRun'))
})
@@ -169,7 +153,8 @@ describe('pre-organization onboarding v3', () => {
})
it.concurrent('continues to organization creation after the pre-org icon step', () => {
- expect(onboardingSource).toContain("? props.preOrg\n ? t('app-onboarding-continue')\n : t('app-onboarding-finish-details')")
+ expect(onboardingSource).toContain("t('app-onboarding-continue')")
+ expect(onboardingSource).toContain("t('app-onboarding-finish-details')")
})
it.concurrent('does not send raw onboarding field values to analytics', () => {
@@ -204,7 +189,7 @@ describe('pre-organization onboarding v3', () => {
})
it.concurrent('opens App ID guidance and tracks only the open action', () => {
- const appIdHelp = onboardingSource.slice(onboardingSource.indexOf('function openAppIdHelp()'), onboardingSource.indexOf('function applyAppIdSuggestion('))
+ const appIdHelp = sliceBetween(onboardingSource, 'function openAppIdHelp()', 'function applyAppIdSuggestion(')
expect(appIdHelp).toContain("trackDetailsEvent('onboarding_app_id_help_opened')")
expect(appIdHelp).toContain('dialogStore.openDialog({')
expect(appIdHelp).not.toContain('onDialogDismiss')
@@ -231,10 +216,7 @@ describe('pre-organization onboarding v3', () => {
expect(onboardingSource).toContain("organizationWebsiteInput.value = ''")
expect(onboardingSource).toContain("t('organization-onboarding-imported-logo-label')")
expect(onboardingSource).toContain("t('organization-onboarding-website-import-success')")
- const deletion = onboardingSource.slice(
- onboardingSource.indexOf('function deleteImportedOrganizationDetails()'),
- onboardingSource.indexOf('async function uploadImportedOrganizationLogo('),
- )
+ const deletion = sliceBetween(onboardingSource, 'function deleteImportedOrganizationDetails()', 'async function uploadImportedOrganizationLogo(')
expect(deletion).not.toContain('orgNameInput')
})
@@ -249,53 +231,35 @@ describe('pre-organization onboarding v3', () => {
it.concurrent('creates both records before exposing invitations', () => {
expect(onboardingSource).toContain('async function createOrganizationAndApp()')
expect(onboardingSource).toContain('async function createAppRecord(')
- const creation = onboardingSource.slice(
- onboardingSource.indexOf('async function createOrganizationAndApp()'),
- onboardingSource.indexOf('async function createAppRecord('),
- )
+ const creation = sliceBetween(onboardingSource, 'async function createOrganizationAndApp()', 'async function createAppRecord(')
expect(creation).toContain('await createAppRecord(')
expect(creation).toContain('showOrganizationInvite.value = shouldInvite')
expect(creation.indexOf('await createAppRecord(')).toBeLessThan(creation.indexOf('showOrganizationInvite.value = shouldInvite'))
})
it.concurrent('retries only app creation after an App ID conflict created the organization', () => {
- const finishDetails = onboardingSource.slice(
- onboardingSource.indexOf('function finishAppDetails()'),
- onboardingSource.indexOf('function returnToAppIdAfterConflict()'),
- )
+ const finishDetails = sliceBetween(onboardingSource, 'function finishAppDetails()', 'function returnToAppIdAfterConflict()')
expect(finishDetails).toContain('preOrgCreatedOrganizationId.value')
expect(finishDetails).toContain('completePreOrgAppCreation(preOrgCreatedOrganizationId.value, preOrgShouldInvite.value)')
- const conflictRecovery = onboardingSource.slice(
- onboardingSource.indexOf('function returnToAppIdAfterConflict()'),
- onboardingSource.indexOf('async function uploadIcon('),
- )
+ const conflictRecovery = sliceBetween(onboardingSource, 'function returnToAppIdAfterConflict()', 'async function uploadIcon(')
expect(conflictRecovery).toContain("flowStep.value = 'details'")
expect(conflictRecovery).toContain("appDetailsStep.value = 'app_id'")
expect(conflictRecovery).toContain("progressTracker?.viewStep('app_id', previousAnalyticsStep)")
- const organizationCreation = onboardingSource.slice(
- onboardingSource.indexOf('async function createOrganizationAndApp()'),
- onboardingSource.indexOf('async function createAppRecord('),
- )
+ const organizationCreation = sliceBetween(onboardingSource, 'async function createOrganizationAndApp()', 'async function createAppRecord(')
expect(organizationCreation).toContain('preOrgCreatedOrganizationId.value = data.id')
expect(organizationCreation).toContain('await completePreOrgAppCreation(data.id, shouldInvite)')
expect(organizationCreation).toContain("await createAppRecord({ nextStep: shouldInvite ? 'organization' : 'setup' })")
- const appCreation = onboardingSource.slice(
- onboardingSource.indexOf('async function createAppRecord('),
- onboardingSource.indexOf('async function seedDemoData()'),
- )
+ const appCreation = sliceBetween(onboardingSource, 'async function createAppRecord(', 'async function seedDemoData()')
expect(appCreation).toContain('returnToAppIdAfterConflict()')
})
it.concurrent('shows technical delegation unconditionally on pre-org setup', () => {
expect(onboardingSource).toContain("flowStep === 'setup' && createdApp")
expect(onboardingSource).toContain("!props.preOrg && flowStep === 'choice'")
- const setup = onboardingSource.slice(
- onboardingSource.indexOf("flowStep === 'setup' && createdApp"),
- onboardingSource.indexOf("!props.preOrg && flowStep === 'choice'"),
- )
+ const setup = sliceBetween(onboardingSource, "flowStep === 'setup' && createdApp", "!props.preOrg && flowStep === 'choice'")
expect(setup).toContain(' {
expect(source).toContain('data-test="onboarding-welcome-continue"')
expect(source).toContain('data-test="onboarding-welcome-brand"')
expect(source).toContain('src="/favicon.svg"')
+ expect(source).toContain('class="d-btn d-btn-primary')
expect(source).toContain('onboarding-welcome-glow-shell')
expect(source).toContain('onboarding-glow-drift')
expect(source).not.toContain('conic-gradient')
@@ -64,9 +65,10 @@ describe('app onboarding welcome', () => {
expect(analyticsSource).toContain(`export type OnboardingAnalyticsStep = 'welcome' | 'intent'`)
expect(initializeTracking).toContain(`trackedSteps.unshift('welcome')`)
expect(initializeTracking).toContain(`progressTracker.viewStep(initialStep)`)
- expect(continueFromWelcome).toContain(`completeStep('welcome', { nextStep })`)
- expect(continueFromWelcome).toContain(`viewStep(nextStep, 'welcome')`)
- expect(continueFromWelcome.indexOf(`completeStep('welcome', { nextStep })`)).toBeLessThan(continueFromWelcome.indexOf(`viewStep(nextStep, 'welcome')`))
+ expect(continueFromWelcome).toContain('const nextAnalyticsStep = analyticsStepFor(nextStep)')
+ expect(continueFromWelcome).toContain(`completeStep('welcome', { nextStep: nextAnalyticsStep })`)
+ expect(continueFromWelcome).toContain(`viewStep(nextAnalyticsStep, 'welcome')`)
+ expect(continueFromWelcome.indexOf(`completeStep('welcome', { nextStep: nextAnalyticsStep })`)).toBeLessThan(continueFromWelcome.indexOf(`viewStep(nextAnalyticsStep, 'welcome')`))
})
it.concurrent('uses the focused naked onboarding layout with persistent account controls', async () => {
diff --git a/tests/frontend-onboarding-analytics-model.unit.test.ts b/tests/frontend-onboarding-analytics-model.unit.test.ts
index 4960eaa782..ddf81acb4b 100644
--- a/tests/frontend-onboarding-analytics-model.unit.test.ts
+++ b/tests/frontend-onboarding-analytics-model.unit.test.ts
@@ -27,6 +27,30 @@ function attempt(overrides: Partial & Pick {
+ it.concurrent('preserves legacy v3 fields and exposes v4 analytics additively', () => {
+ const analytics = buildFrontendOnboardingAnalytics([
+ attempt({
+ attemptId: 'v3-details-only',
+ onboardingVersion: 3,
+ intentMs: CURRENT_START_MS,
+ detailsMs: CURRENT_START_MS + MINUTE_MS,
+ }),
+ attempt({
+ attemptId: 'v4-complete',
+ onboardingVersion: 4,
+ intentMs: CURRENT_START_MS + 2 * MINUTE_MS,
+ detailsMs: CURRENT_START_MS + 3 * MINUTE_MS,
+ organizationMs: CURRENT_START_MS + 4 * MINUTE_MS,
+ setupMs: CURRENT_START_MS + 5 * MINUTE_MS,
+ }),
+ ], CURRENT_START_MS, CURRENT_END_MS)
+
+ expect(analytics.kpis).toMatchObject({ attempts: 1, completed: 0, completion_rate: 0 })
+ expect(analytics.v4_kpis).toMatchObject({ attempts: 1, completed: 1, completion_rate: 100 })
+ expect(analytics.daily_conversions.details_to_organization[0]).toMatchObject({ started: 1, converted: 0 })
+ expect(analytics.v4_daily_conversions.details_to_organization[0]).toMatchObject({ started: 1, converted: 1 })
+ })
+
it.concurrent('surfaces v4 as the current analytics cohort', () => {
const analytics = buildFrontendOnboardingAnalytics([
attempt({
@@ -41,7 +65,7 @@ describe('buildFrontendOnboardingAnalytics', () => {
], CURRENT_START_MS, CURRENT_END_MS)
expect(FRONTEND_ONBOARDING_VERSIONS).toEqual([1, 2, 3, 4])
- expect(analytics.kpis).toMatchObject({ attempts: 1, completed: 1, completion_rate: 100 })
+ 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])
@@ -75,7 +99,7 @@ describe('buildFrontendOnboardingAnalytics', () => {
}),
], CURRENT_START_MS, CURRENT_END_MS)
- expect(analytics.kpis).toMatchObject({
+ expect(analytics.v4_kpis).toMatchObject({
attempts: 4,
completed: 2,
completion_rate: 50,
@@ -177,7 +201,7 @@ describe('buildFrontendOnboardingAnalytics', () => {
], CURRENT_START_MS, CURRENT_END_MS)
expect(analytics.funnels.v4.map(stage => stage.reached)).toEqual([1, 0, 0, 0])
- expect(analytics.kpis.median_completion_ms).toBeNull()
+ expect(analytics.v4_kpis.median_completion_ms).toBeNull()
})
it.concurrent('uses the immediately preceding cohort for comparisons', () => {
@@ -206,7 +230,7 @@ describe('buildFrontendOnboardingAnalytics', () => {
}),
], CURRENT_START_MS, CURRENT_END_MS)
- expect(analytics.kpis.comparison).toEqual({
+ expect(analytics.v4_kpis.comparison).toEqual({
attempts_percent: 0,
completion_rate_points: 50,
median_completion_ms: -300_000,
@@ -219,7 +243,7 @@ describe('buildFrontendOnboardingAnalytics', () => {
attempt({ attemptId: 'current-intent', intentMs: CURRENT_START_MS }),
], CURRENT_START_MS, CURRENT_END_MS)
- expect(analytics.kpis.comparison).toEqual({
+ expect(analytics.v4_kpis.comparison).toEqual({
attempts_percent: null,
completion_rate_points: null,
median_completion_ms: null,
@@ -230,7 +254,7 @@ describe('buildFrontendOnboardingAnalytics', () => {
it.concurrent('returns zero KPIs, zero-filled days, and an empty funnel for an empty cohort', () => {
const analytics = buildFrontendOnboardingAnalytics([], CURRENT_START_MS, CURRENT_END_MS)
- expect(analytics.kpis).toMatchObject({
+ expect(analytics.v4_kpis).toMatchObject({
attempts: 0,
completed: 0,
completion_rate: 0,
@@ -264,8 +288,8 @@ describe('buildFrontendOnboardingAnalytics', () => {
}),
], CURRENT_START_MS, CURRENT_END_MS)
- expect(analytics.kpis.largest_dropoff).toBeNull()
- expect(analytics.kpis.comparison.largest_dropoff_points).toBe(-100)
+ expect(analytics.v4_kpis.largest_dropoff).toBeNull()
+ expect(analytics.v4_kpis.comparison.largest_dropoff_points).toBe(-100)
})
it.concurrent('includes the start boundary and 24-hour step boundary but excludes the end boundary', () => {
@@ -278,9 +302,9 @@ describe('buildFrontendOnboardingAnalytics', () => {
attempt({ attemptId: 'end-exclusive', intentMs: CURRENT_END_MS }),
], CURRENT_START_MS, CURRENT_END_MS)
- expect(analytics.kpis.attempts).toBe(1)
- expect(analytics.kpis.completed).toBe(1)
- expect(analytics.kpis.median_completion_ms).toBe(FRONTEND_ONBOARDING_FOLLOWUP_MS)
+ expect(analytics.v4_kpis.attempts).toBe(1)
+ expect(analytics.v4_kpis.completed).toBe(1)
+ expect(analytics.v4_kpis.median_completion_ms).toBe(FRONTEND_ONBOARDING_FOLLOWUP_MS)
})
it.concurrent('fills every UTC date crossed by a partial-day range', () => {
@@ -325,7 +349,7 @@ describe('buildFrontendOnboardingAnalytics', () => {
attempt({ attemptId: 'intent-only', intentMs: CURRENT_START_MS + 5 * MINUTE_MS }),
], CURRENT_START_MS, CURRENT_END_MS)
- expect(analytics.daily_conversions).toEqual({
+ expect(analytics.v4_daily_conversions).toEqual({
intent_to_details: [
{ date: '2026-08-01', started: 4, converted: 3, conversion_percent: 75 },
{ date: '2026-08-02', started: 0, converted: 0, conversion_percent: null },
@@ -356,19 +380,19 @@ describe('buildFrontendOnboardingAnalytics', () => {
}),
], CURRENT_START_MS, CURRENT_END_MS)
- expect(analytics.daily_conversions.intent_to_details[0]).toEqual({
+ expect(analytics.v4_daily_conversions.intent_to_details[0]).toEqual({
date: '2026-08-01',
started: 2,
converted: 1,
conversion_percent: 50,
})
- expect(analytics.daily_conversions.details_to_organization[1]).toEqual({
+ expect(analytics.v4_daily_conversions.details_to_organization[1]).toEqual({
date: '2026-08-02',
started: 1,
converted: 1,
conversion_percent: 100,
})
- expect(analytics.daily_conversions.organization_to_setup[1]).toEqual({
+ expect(analytics.v4_daily_conversions.organization_to_setup[1]).toEqual({
date: '2026-08-02',
started: 1,
converted: 1,
@@ -388,14 +412,14 @@ describe('buildFrontendOnboardingAnalytics', () => {
}),
], CURRENT_START_MS, CURRENT_END_MS)
- expect(analytics.daily_conversions.intent_to_details[0].started).toBe(0)
- expect(analytics.daily_conversions.details_to_organization[0]).toEqual({
+ expect(analytics.v4_daily_conversions.intent_to_details[0].started).toBe(0)
+ expect(analytics.v4_daily_conversions.details_to_organization[0]).toEqual({
date: '2026-08-01',
started: 1,
converted: 1,
conversion_percent: 100,
})
- expect(analytics.daily_conversions.organization_to_setup[0]).toEqual({
+ expect(analytics.v4_daily_conversions.organization_to_setup[0]).toEqual({
date: '2026-08-01',
started: 1,
converted: 1,
@@ -414,7 +438,7 @@ describe('buildFrontendOnboardingAnalytics', () => {
}),
], CURRENT_START_MS, CURRENT_END_MS)
- expect(analytics.kpis.comparison.median_completion_ms).toBeNull()
+ expect(analytics.v4_kpis.comparison.median_completion_ms).toBeNull()
})
it.concurrent.each([
diff --git a/tests/frontend-onboarding-analytics.unit.test.ts b/tests/frontend-onboarding-analytics.unit.test.ts
index 8c8ce6b560..c5e63ab66a 100644
--- a/tests/frontend-onboarding-analytics.unit.test.ts
+++ b/tests/frontend-onboarding-analytics.unit.test.ts
@@ -175,7 +175,8 @@ describe('getAdminFrontendOnboardingAnalytics', () => {
const result = await getAdminFrontendOnboardingAnalytics(createContext(), start, '2026-08-03T00:00:00.000Z')
expect(result).toMatchObject({
- kpis: { attempts: 1, completed: 1, completion_rate: 100, median_completion_ms: 2_666 },
+ kpis: { attempts: 0, completed: 0, completion_rate: 0, median_completion_ms: null },
+ v4_kpis: { attempts: 1, completed: 1, completion_rate: 100, median_completion_ms: 2_666 },
daily_attempts: [
{ date: '2026-08-01', v1_attempts: 1, v2_attempts: 1, v3_attempts: 0, v4_attempts: 1 },
{ date: '2026-08-02', v1_attempts: 0, v2_attempts: 0, v3_attempts: 0, v4_attempts: 0 },
@@ -203,6 +204,7 @@ describe('getAdminFrontendOnboardingAnalytics', () => {
posthog_configured: true,
posthog_connected: true,
})
+ expect(result.v4_daily_conversions.details_to_organization[0]).toMatchObject({ started: 1, converted: 1 })
expect(result.daily_setup_cli_outcomes).toEqual([
{
date: '2026-08-01',
@@ -344,7 +346,7 @@ describe('getAdminFrontendOnboardingAnalytics', () => {
)
expect(result).toMatchObject({
- kpis: { attempts: 2, completed: 0, completion_rate: 0 },
+ v4_kpis: { attempts: 2, completed: 0, completion_rate: 0 },
funnels: { v4: [
{ key: 'intent', reached: 2 },
{ key: 'details', reached: 0 },
From 731316078068222a813e780dd2e0f9e27b61612c Mon Sep 17 00:00:00 2001
From: WcaleNieWolny
Date: Tue, 18 Aug 2026 19:27:12 +0200
Subject: [PATCH 10/10] fix(onboarding): align analytics fallback sources
---
src/components/dashboard/AppOnboardingWelcome.vue | 2 +-
src/pages/admin/dashboard/frontend-onboarding.vue | 8 ++++++--
tests/admin-frontend-onboarding-dashboard.unit.test.ts | 4 ++--
tests/app-onboarding-welcome.unit.test.ts | 2 ++
4 files changed, 11 insertions(+), 5 deletions(-)
diff --git a/src/components/dashboard/AppOnboardingWelcome.vue b/src/components/dashboard/AppOnboardingWelcome.vue
index d80b000125..9bf3b137f7 100644
--- a/src/components/dashboard/AppOnboardingWelcome.vue
+++ b/src/components/dashboard/AppOnboardingWelcome.vue
@@ -42,7 +42,7 @@ const { t } = useI18n()
diff --git a/src/pages/admin/dashboard/frontend-onboarding.vue b/src/pages/admin/dashboard/frontend-onboarding.vue
index 5be90f9e0f..b3c63b2d60 100644
--- a/src/pages/admin/dashboard/frontend-onboarding.vue
+++ b/src/pages/admin/dashboard/frontend-onboarding.vue
@@ -186,7 +186,9 @@ const latestVersionLabel = computed(() => t(rawLatestFunnel.value.version === 'v
const displayedFunnelTitle = computed(() => t(displayedLatestFunnel.value.version === 'v4'
? 'frontend-onboarding-funnel-v4'
: 'frontend-onboarding-funnel-v3'))
-const kpis = computed(() => visibleAnalytics.value?.v4_kpis ?? visibleAnalytics.value?.kpis)
+const kpis = computed(() => rawLatestFunnel.value.version === 'v4'
+ ? visibleAnalytics.value?.v4_kpis ?? visibleAnalytics.value?.kpis
+ : visibleAnalytics.value?.kpis)
const dailySeries = computed(() => buildFrontendOnboardingDailySeries(
displayedDailyAttempts.value,
t('frontend-onboarding-version-1'),
@@ -194,7 +196,9 @@ const dailySeries = computed(() => buildFrontendOnboardingDailySeries(
t('frontend-onboarding-version-3'),
t('frontend-onboarding-version-4'),
))
-const latestDailyConversions = computed(() => visibleAnalytics.value?.v4_daily_conversions ?? visibleAnalytics.value?.daily_conversions)
+const latestDailyConversions = computed(() => rawLatestFunnel.value.version === 'v4'
+ ? visibleAnalytics.value?.v4_daily_conversions ?? visibleAnalytics.value?.daily_conversions
+ : visibleAnalytics.value?.daily_conversions)
const intentToDetailsDaily = computed(() => latestDailyConversions.value?.intent_to_details ?? [])
const detailsToOrganizationDaily = computed(() => latestDailyConversions.value?.details_to_organization ?? [])
const organizationToSetupDaily = computed(() => latestDailyConversions.value?.organization_to_setup ?? [])
diff --git a/tests/admin-frontend-onboarding-dashboard.unit.test.ts b/tests/admin-frontend-onboarding-dashboard.unit.test.ts
index 0204744753..6337b5ef18 100644
--- a/tests/admin-frontend-onboarding-dashboard.unit.test.ts
+++ b/tests/admin-frontend-onboarding-dashboard.unit.test.ts
@@ -622,8 +622,8 @@ describe('admin frontend onboarding dashboard', () => {
expect(onLoadingCallback).toContain('if (!value)')
expect(onLoadingCallback).toContain('isLoading.value = false')
expect(source).toContain('const visibleAnalytics = computed(() => isLoadingStats.value ? null : analytics.value)')
- expect(source).toContain('visibleAnalytics.value?.v4_kpis ?? visibleAnalytics.value?.kpis')
- expect(source).toContain('visibleAnalytics.value?.v4_daily_conversions ?? visibleAnalytics.value?.daily_conversions')
+ expect(source).toContain("rawLatestFunnel.value.version === 'v4'\n ? visibleAnalytics.value?.v4_kpis ?? visibleAnalytics.value?.kpis")
+ expect(source).toContain("rawLatestFunnel.value.version === 'v4'\n ? visibleAnalytics.value?.v4_daily_conversions ?? visibleAnalytics.value?.daily_conversions")
})
it.concurrent('uses the existing admin dashboard components for v4 and the legacy funnel', async () => {
diff --git a/tests/app-onboarding-welcome.unit.test.ts b/tests/app-onboarding-welcome.unit.test.ts
index aab1f55c2a..7bcbff09cf 100644
--- a/tests/app-onboarding-welcome.unit.test.ts
+++ b/tests/app-onboarding-welcome.unit.test.ts
@@ -14,6 +14,8 @@ describe('app onboarding welcome', () => {
expect(source).toContain('data-test="onboarding-welcome-brand"')
expect(source).toContain('src="/favicon.svg"')
expect(source).toContain('class="d-btn d-btn-primary')
+ const primaryButtonTag = source.slice(source.indexOf('