diff --git a/docs/pr-screenshots/admin-onboarding-activation-milestones.webp b/docs/pr-screenshots/admin-onboarding-activation-milestones.webp index d9a28ba7ff..4d65589605 100644 Binary files a/docs/pr-screenshots/admin-onboarding-activation-milestones.webp and b/docs/pr-screenshots/admin-onboarding-activation-milestones.webp differ diff --git a/messages/en.json b/messages/en.json index f0601f615b..8676f7b090 100644 --- a/messages/en.json +++ b/messages/en.json @@ -260,11 +260,11 @@ "admin-debug-description": "Replicate your bundle across our global infrastructure so updates are available faster, and verify the rollout toast flow you see in production.", "admin-debug-hint": "This runs the exact same toast lifecycle as production: live progress while the update replicates in each region, then completion once all regions are updated.", "admin-debug-trigger-toast": "Run replication toast simulation", - "activation-signals": "Activation signals", "activation-telemetry-unavailable": "Activation telemetry is unavailable for this date range. Select a range within the last 3 months.", + "bundle-to-production-device": "Bundle → Production device", "completed-update-download": "Completed update download", "completed-update-download-within-7-days": "Completed Update Download (within 7 days)", - "of-new-organizations": "of new organizations", + "production-device-to-update-download": "Production device → Update download", "production-plugin-device": "Production plugin device", "production-plugin-device-within-7-days": "Production Plugin Device (within 7 days)", "all-billing-cycles": "All billing cycles", @@ -1648,7 +1648,7 @@ "onboarding-demo-option-title": "Just want to explore Capgo?", "onboarding-demo-success": "Demo app created! Welcome to Capgo! This demo will be deleted in 14 days.", "onboarding-funnel": "Onboarding Funnel", - "onboarding-funnel-description": "Track user progression from organization creation to first bundle upload", + "onboarding-funnel-description": "Track organization activation from creation through the first completed update download", "onboarding-invite-option-cta": "Invite a technical teammate", "onboarding-invite-option-dialog-desc": "We will email them detailed instructions to create the first app for this organization.", "onboarding-invite-option-helper": "They will receive an email asking them to create the first app for this organization.", diff --git a/src/pages/admin/dashboard/users.vue b/src/pages/admin/dashboard/users.vue index aed0c5700b..47d1a9a012 100644 --- a/src/pages/admin/dashboard/users.vue +++ b/src/pages/admin/dashboard/users.vue @@ -767,8 +767,8 @@ const onboardingFunnelRates = computed(() => { channel: orgsWithApp > 0 ? (orgsWithChannel / orgsWithApp) * 100 : 0, bundle: orgsWithChannel > 0 ? (orgsWithBundle / orgsWithChannel) * 100 : 0, subscribed: orgsWithBundle > 0 ? (orgsSubscribed / orgsWithBundle) * 100 : 0, - productionDevice: totalOrgs > 0 ? (orgsWithProductionDevice / totalOrgs) * 100 : 0, - updateDownload: totalOrgs > 0 ? (orgsWithUpdateDownload / totalOrgs) * 100 : 0, + productionDevice: orgsWithBundle > 0 ? (orgsWithProductionDevice / orgsWithBundle) * 100 : 0, + updateDownload: orgsWithProductionDevice > 0 ? (orgsWithUpdateDownload / orgsWithProductionDevice) * 100 : 0, } }) @@ -804,12 +804,22 @@ const onboardingFunnelStages = computed(() => { percentage: rates.bundle, color: '#10b981', // green }, - { - label: t('subscribed'), - value: Number(data.orgs_subscribed) || 0, - percentage: rates.subscribed, - color: '#ef4444', // red - }, + ...(data.activation_telemetry_available + ? [ + { + label: t('production-plugin-device'), + value: Number(data.orgs_with_production_device) || 0, + percentage: rates.productionDevice, + color: '#ec4899', // pink + }, + { + label: t('completed-update-download'), + value: Number(data.orgs_with_update_download) || 0, + percentage: rates.updateDownload, + color: '#6366f1', // indigo + }, + ] + : []), ] }) @@ -964,12 +974,12 @@ displayStore.defaultBack = '/dashboard'
-
+
- -
+ +

{{ formatOneDecimal(onboardingFunnelRates.app) }}% @@ -994,47 +1004,38 @@ displayStore.defaultBack = '/dashboard' {{ t('channel-to-bundle') }}

-
-

- {{ formatOneDecimal(onboardingFunnelRates.subscribed) }}% +

+

+ {{ formatOneDecimal(onboardingFunnelRates.productionDevice) }}%

- {{ t('bundle-to-subscribed') }} + {{ t('bundle-to-production-device') }} +

+
+
+

+ {{ formatOneDecimal(onboardingFunnelRates.updateDownload) }}% +

+

+ {{ t('production-device-to-update-download') }}

+

+ {{ t('activation-telemetry-unavailable') }} +

+ +
-

- {{ t('activation-signals') }} -

-
-
-

- {{ formatNumberValue(onboardingFunnelData?.orgs_with_production_device || 0) }} -

-

- {{ t('production-plugin-device') }} -

-

- {{ formatOneDecimal(onboardingFunnelRates.productionDevice) }}% {{ t('of-new-organizations') }} -

-
-
-

- {{ formatNumberValue(onboardingFunnelData?.orgs_with_update_download || 0) }} -

-

- {{ t('completed-update-download') }} -

-

- {{ formatOneDecimal(onboardingFunnelRates.updateDownload) }}% {{ t('of-new-organizations') }} -

-
+
+

+ {{ formatOneDecimal(onboardingFunnelRates.subscribed) }}% +

+

+ {{ t('bundle-to-subscribed') }} +

-

- {{ t('activation-telemetry-unavailable') }} -

diff --git a/supabase/functions/_backend/utils/onboardingFunnel.ts b/supabase/functions/_backend/utils/onboardingFunnel.ts index 39fcb0ca05..9d3c75daaf 100644 --- a/supabase/functions/_backend/utils/onboardingFunnel.ts +++ b/supabase/functions/_backend/utils/onboardingFunnel.ts @@ -50,19 +50,20 @@ export function getAdminOnboardingActivationMetrics( const date = startAt.toISOString().slice(0, 10) const productionDeviceAt = telemetry.first_production_device_at_by_app.get(cohort.app_id) - if (isWithinActivationWindow(productionDeviceAt, startAt, endAt)) { - productionDeviceOrgIds.add(cohort.org_id) - const orgIds = productionDeviceOrgIdsByDate.get(date) ?? new Set() - orgIds.add(cohort.org_id) - productionDeviceOrgIdsByDate.set(date, orgIds) - } + if (!isWithinActivationWindow(productionDeviceAt, startAt, endAt)) + continue + + productionDeviceOrgIds.add(cohort.org_id) + const productionDeviceOrgIdsForDate = productionDeviceOrgIdsByDate.get(date) ?? new Set() + productionDeviceOrgIdsForDate.add(cohort.org_id) + productionDeviceOrgIdsByDate.set(date, productionDeviceOrgIdsForDate) const updateDownloadAt = telemetry.first_update_download_at_by_app.get(cohort.app_id) if (isWithinActivationWindow(updateDownloadAt, startAt, endAt)) { updateDownloadOrgIds.add(cohort.org_id) - const orgIds = updateDownloadOrgIdsByDate.get(date) ?? new Set() - orgIds.add(cohort.org_id) - updateDownloadOrgIdsByDate.set(date, orgIds) + const updateDownloadOrgIdsForDate = updateDownloadOrgIdsByDate.get(date) ?? new Set() + updateDownloadOrgIdsForDate.add(cohort.org_id) + updateDownloadOrgIdsByDate.set(date, updateDownloadOrgIdsForDate) } } diff --git a/supabase/functions/_backend/utils/pg.ts b/supabase/functions/_backend/utils/pg.ts index d6e1410766..2432ccdd33 100644 --- a/supabase/functions/_backend/utils/pg.ts +++ b/supabase/functions/_backend/utils/pg.ts @@ -3141,6 +3141,24 @@ export async function getAdminOnboardingFunnel( const drizzleClient = getDrizzleClient(pgClient) const now = new Date() + const onboardingBundleEligibility = sql` + a.created_at >= o.created_at + AND a.created_at < o.created_at + interval '7 days' + AND EXISTS ( + SELECT 1 + FROM channels c + WHERE c.app_id = a.app_id + ) + AND EXISTS ( + SELECT 1 + FROM app_versions av + WHERE av.app_id = a.app_id + AND av.name NOT IN ('builtin', 'unknown') + AND av.created_at >= o.created_at + AND av.created_at < o.created_at + interval '7 days' + ) + ` + // Get total funnel counts for orgs created in the date range const funnelQuery = sql` WITH orgs_in_range AS ( @@ -3164,10 +3182,7 @@ export async function getAdminOnboardingFunnel( SELECT DISTINCT o.id, o.customer_id, o.created_at, o.created_date FROM orgs_in_range o INNER JOIN apps a ON a.owner_org = o.id - INNER JOIN channels c ON c.app_id = a.app_id - INNER JOIN app_versions av ON av.id = c.version AND av.name NOT IN ('builtin', 'unknown') - WHERE av.created_at >= o.created_at - AND av.created_at < o.created_at + interval '7 days' + WHERE ${onboardingBundleEligibility} ), orgs_subscribed AS ( SELECT DISTINCT o.id, o.created_date @@ -3235,25 +3250,19 @@ export async function getAdminOnboardingFunnel( SELECT o.created_at::date as date, COUNT(DISTINCT o.id)::int as orgs_created_bundle FROM orgs o INNER JOIN apps a ON a.owner_org = o.id - INNER JOIN channels c ON c.app_id = a.app_id - INNER JOIN app_versions av ON av.id = c.version AND av.name NOT IN ('builtin', 'unknown') WHERE o.created_at >= ${start_date}::timestamp AND o.created_at < ${end_date}::timestamp - AND av.created_at >= o.created_at - AND av.created_at < o.created_at + interval '7 days' + AND ${onboardingBundleEligibility} GROUP BY o.created_at::date ), daily_subscriptions AS ( SELECT o.created_at::date as date, COUNT(DISTINCT o.id)::int as orgs_subscribed FROM orgs o INNER JOIN apps a ON a.owner_org = o.id - INNER JOIN channels c ON c.app_id = a.app_id - INNER JOIN app_versions av ON av.id = c.version AND av.name NOT IN ('builtin', 'unknown') INNER JOIN stripe_info si ON si.customer_id = o.customer_id WHERE o.created_at >= ${start_date}::timestamp AND o.created_at < ${end_date}::timestamp - AND av.created_at >= o.created_at - AND av.created_at < o.created_at + interval '7 days' + AND ${onboardingBundleEligibility} AND si.paid_at IS NOT NULL AND si.paid_at >= o.created_at AND si.paid_at < o.created_at + interval '7 days' @@ -3284,8 +3293,7 @@ export async function getAdminOnboardingFunnel( INNER JOIN apps a ON a.owner_org = o.id WHERE o.created_at >= ${start_date}::timestamp AND o.created_at < ${end_date}::timestamp - AND a.created_at >= o.created_at - AND a.created_at < o.created_at + interval '7 days' + AND ${onboardingBundleEligibility} ` const [trendResult, activationCohortResult] = await Promise.all([ @@ -3349,8 +3357,8 @@ export async function getAdminOnboardingFunnel( channel_conversion_rate: orgsWithApp > 0 ? (orgsWithChannel / orgsWithApp) * 100 : 0, bundle_conversion_rate: orgsWithChannel > 0 ? (orgsWithBundle / orgsWithChannel) * 100 : 0, subscription_conversion_rate: orgsWithBundle > 0 ? (orgsSubscribed / orgsWithBundle) * 100 : 0, - production_device_conversion_rate: totalOrgs > 0 ? (activationMetrics.orgs_with_production_device / totalOrgs) * 100 : 0, - update_download_conversion_rate: totalOrgs > 0 ? (activationMetrics.orgs_with_update_download / totalOrgs) * 100 : 0, + production_device_conversion_rate: orgsWithBundle > 0 ? (activationMetrics.orgs_with_production_device / orgsWithBundle) * 100 : 0, + update_download_conversion_rate: activationMetrics.orgs_with_production_device > 0 ? (activationMetrics.orgs_with_update_download / activationMetrics.orgs_with_production_device) * 100 : 0, trend, } diff --git a/tests/admin-onboarding-funnel.unit.test.ts b/tests/admin-onboarding-funnel.unit.test.ts index 8683f1fa48..11ac91d68d 100644 --- a/tests/admin-onboarding-funnel.unit.test.ts +++ b/tests/admin-onboarding-funnel.unit.test.ts @@ -96,7 +96,7 @@ describe('admin onboarding activation telemetry', () => { } }) - it.concurrent('counts organization activation signals once within each seven-day window', () => { + it.concurrent('counts nested activation funnel stages once within each seven-day window', () => { const metrics = getAdminOnboardingActivationMetrics([ { org_id: 'org-alpha', @@ -116,29 +116,44 @@ describe('admin onboarding activation telemetry', () => { created_at: '2026-07-02T00:00:00.000Z', activation_window_end: '2026-07-09T00:00:00.000Z', }, + { + org_id: 'org-gamma', + app_id: 'app-gamma-device', + created_at: '2026-07-03T00:00:00.000Z', + activation_window_end: '2026-07-10T00:00:00.000Z', + }, + { + org_id: 'org-gamma', + app_id: 'app-gamma-download', + created_at: '2026-07-03T00:00:00.000Z', + activation_window_end: '2026-07-10T00:00:00.000Z', + }, ], { available: true, first_production_device_at_by_app: new Map([ ['app-alpha', new Date('2026-07-02T00:00:00.000Z')], ['app-alpha-second', new Date('2026-07-03T00:00:00.000Z')], ['app-beta', new Date('2026-07-09T00:00:00.000Z')], + ['app-gamma-device', new Date('2026-07-04T00:00:00.000Z')], ]), first_update_download_at_by_app: new Map([ ['app-alpha', new Date('2026-07-04T00:00:00.000Z')], ['app-alpha-second', new Date('2026-07-10T00:00:00.000Z')], ['app-beta', new Date('2026-07-08T00:00:00.000Z')], + ['app-gamma-download', new Date('2026-07-05T00:00:00.000Z')], ]), }) - expect(metrics.orgs_with_production_device).toBe(1) - expect(metrics.orgs_with_update_download).toBe(2) + expect(metrics.orgs_with_production_device).toBe(2) + expect(metrics.orgs_with_update_download).toBe(1) expect(metrics.trend_by_date.get('2026-07-01')).toEqual({ orgs_with_production_device: 1, orgs_with_update_download: 1, }) - expect(metrics.trend_by_date.get('2026-07-02')).toEqual({ - orgs_with_production_device: 0, - orgs_with_update_download: 1, + expect(metrics.trend_by_date.get('2026-07-02')).toBeUndefined() + expect(metrics.trend_by_date.get('2026-07-03')).toEqual({ + orgs_with_production_device: 1, + orgs_with_update_download: 0, }) }) diff --git a/tests/admin-stats.test.ts b/tests/admin-stats.test.ts index 6d2bc71609..c45b4b51de 100644 --- a/tests/admin-stats.test.ts +++ b/tests/admin-stats.test.ts @@ -989,6 +989,156 @@ describe('/private/admin_stats', () => { }) }) + it.concurrent('keeps an uploaded bundle in the funnel after a later channel promotion', async () => { + if (!soloPlan) + throw new Error('Expected Solo plan to exist for onboarding funnel test') + + const supabase = getSupabaseClient() + const orgId = randomUUID() + const suffix = orgId.replaceAll('-', '').slice(0, 12) + const appId = `com.admin.stats.onboardinghistory.${suffix}` + const customerId = `cus_admin_stats_onboarding_history_${suffix}` + const orgCreatedAt = '2098-07-01T10:00:00.000Z' + + try { + const { error: stripeError } = await supabase.from('stripe_info').insert({ + customer_id: customerId, + status: 'succeeded', + product_id: soloPlan.stripe_id, + price_id: soloPlan.price_m_id, + trial_at: '2098-07-20T10:00:00.000Z', + paid_at: '2098-07-05T10:00:00.000Z', + is_good_plan: true, + plan_usage: 2, + subscription_anchor_start: '2098-07-05T10:00:00.000Z', + subscription_anchor_end: '2098-08-05T10:00:00.000Z', + }) + if (stripeError) + throw stripeError + + const { error: orgError } = await supabase.from('orgs').insert({ + id: orgId, + name: `Admin Stats Onboarding History ${suffix}`, + created_by: USER_ID, + management_email: TEST_EMAIL, + customer_id: customerId, + created_at: orgCreatedAt, + }) + if (orgError) + throw orgError + + const { error: appError } = await supabase.from('apps').insert({ + owner_org: orgId, + name: 'Admin Stats Onboarding History App', + app_id: appId, + icon_url: 'https://example.com/icon.png', + created_at: '2098-07-02T10:00:00.000Z', + }) + if (appError) + throw appError + + const { data: firstVersion, error: firstVersionError } = await supabase + .from('app_versions') + .insert({ + app_id: appId, + name: '1.0.0', + owner_org: orgId, + user_id: USER_ID, + storage_provider: 'r2-direct', + created_at: '2098-07-04T10:00:00.000Z', + }) + .select('id') + .single() + if (firstVersionError) + throw firstVersionError + if (!firstVersion) + throw new Error('Expected the initial onboarding bundle to be created') + + const { error: channelError } = await supabase.from('channels').insert({ + name: 'production', + app_id: appId, + version: firstVersion.id, + created_by: USER_ID, + owner_org: orgId, + created_at: '2098-07-03T10:00:00.000Z', + }) + if (channelError) + throw channelError + + const { data: promotedVersion, error: promotedVersionError } = await supabase + .from('app_versions') + .insert({ + app_id: appId, + name: '2.0.0', + owner_org: orgId, + user_id: USER_ID, + storage_provider: 'r2-direct', + created_at: '2098-07-12T10:00:00.000Z', + }) + .select('id') + .single() + if (promotedVersionError) + throw promotedVersionError + if (!promotedVersion) + throw new Error('Expected a later onboarding bundle to be created') + + const { error: channelUpdateError } = await supabase + .from('channels') + .update({ version: promotedVersion.id }) + .eq('app_id', appId) + .eq('name', 'production') + if (channelUpdateError) + throw channelUpdateError + + const response = await fetchTestRequest(`${BASE_URL}/private/admin_stats`, { + method: 'POST', + headers: adminHeaders, + body: JSON.stringify({ + metric_category: 'onboarding_funnel', + start_date: '2098-07-01T00:00:00.000Z', + end_date: '2098-07-02T00:00:00.000Z', + }), + }) + + expect(response.status).toBe(200) + const payload = await response.json() as { + success: boolean + data: { + total_orgs: number + orgs_with_app: number + orgs_with_channel: number + orgs_with_bundle: number + orgs_subscribed: number + trend: Array<{ + date: string + orgs_created_bundle: number + orgs_subscribed: number + }> + } + } + + expect(payload.success).toBe(true) + expect(payload.data.total_orgs).toBe(1) + expect(payload.data.orgs_with_app).toBe(1) + expect(payload.data.orgs_with_channel).toBe(1) + expect(payload.data.orgs_with_bundle).toBe(1) + expect(payload.data.orgs_subscribed).toBe(1) + expect(payload.data.trend).toHaveLength(1) + expect(payload.data.trend[0]).toMatchObject({ + date: '2098-07-01', + orgs_created_bundle: 1, + orgs_subscribed: 1, + }) + } + finally { + await supabase.from('channels').delete().eq('app_id', appId) + await supabase.from('app_versions').delete().eq('app_id', appId) + await supabase.from('apps').delete().eq('app_id', appId) + await supabase.from('orgs').delete().eq('id', orgId) + await supabase.from('stripe_info').delete().eq('customer_id', customerId) + } + }) + it('returns daily new trial organizations grouped by plan', async () => { const response = await fetchTestRequest(getEndpointUrl('/private/admin_stats'), { method: 'POST',