Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified docs/pr-screenshots/admin-onboarding-activation-milestones.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand Down
89 changes: 45 additions & 44 deletions src/pages/admin/dashboard/users.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
})

Expand Down Expand Up @@ -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
},
]
: []),
]
})

Expand Down Expand Up @@ -964,12 +974,12 @@ displayStore.defaultBack = '/dashboard'
<span class="loading loading-spinner loading-lg" />
</div>
<div v-else-if="onboardingFunnelStages.length > 0" class="space-y-6">
<div class="h-64 sm:h-72">
<div class="h-72 sm:h-80">
<AdminFunnelChart :stages="onboardingFunnelStages" :is-loading="isLoadingOnboardingFunnel" />
</div>

<!-- Conversion summary -->
<div class="grid grid-cols-2 gap-4 pt-4 mt-4 border-t border-gray-200 sm:grid-cols-4 dark:border-gray-700">
<!-- Funnel conversion summary -->
<div class="grid grid-cols-2 gap-4 pt-4 mt-4 border-t border-gray-200 sm:grid-cols-3 xl:grid-cols-5 dark:border-gray-700">
<div class="text-center">
<p class="text-2xl font-bold text-purple-500">
{{ formatOneDecimal(onboardingFunnelRates.app) }}%
Expand All @@ -994,47 +1004,38 @@ displayStore.defaultBack = '/dashboard'
{{ t('channel-to-bundle') }}
</p>
</div>
<div class="text-center">
<p class="text-2xl font-bold text-rose-500">
{{ formatOneDecimal(onboardingFunnelRates.subscribed) }}%
<div v-if="onboardingFunnelData?.activation_telemetry_available" class="text-center">
<p class="text-2xl font-bold text-pink-500">
{{ formatOneDecimal(onboardingFunnelRates.productionDevice) }}%
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('bundle-to-subscribed') }}
{{ t('bundle-to-production-device') }}
</p>
</div>
<div v-if="onboardingFunnelData?.activation_telemetry_available" class="text-center">
<p class="text-2xl font-bold text-indigo-500">
{{ formatOneDecimal(onboardingFunnelRates.updateDownload) }}%
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('production-device-to-update-download') }}
</p>
</div>
</div>

<p v-if="!onboardingFunnelData?.activation_telemetry_available" class="text-sm text-slate-500 dark:text-slate-400">
{{ t('activation-telemetry-unavailable') }}
</p>

<!-- Commercial conversion -->
<div class="pt-4 mt-4 border-t border-gray-200 dark:border-gray-700">
<h4 class="mb-3 text-sm font-semibold text-slate-700 dark:text-slate-200">
{{ t('activation-signals') }}
</h4>
<div v-if="onboardingFunnelData?.activation_telemetry_available" class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="p-4 text-center rounded-lg bg-pink-50 dark:bg-pink-950/20">
<p class="text-2xl font-bold text-pink-500">
{{ formatNumberValue(onboardingFunnelData?.orgs_with_production_device || 0) }}
</p>
<p class="mt-1 text-sm font-medium text-slate-700 dark:text-slate-200">
{{ t('production-plugin-device') }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ formatOneDecimal(onboardingFunnelRates.productionDevice) }}% {{ t('of-new-organizations') }}
</p>
</div>
<div class="p-4 text-center rounded-lg bg-indigo-50 dark:bg-indigo-950/20">
<p class="text-2xl font-bold text-indigo-500">
{{ formatNumberValue(onboardingFunnelData?.orgs_with_update_download || 0) }}
</p>
<p class="mt-1 text-sm font-medium text-slate-700 dark:text-slate-200">
{{ t('completed-update-download') }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ formatOneDecimal(onboardingFunnelRates.updateDownload) }}% {{ t('of-new-organizations') }}
</p>
</div>
<div class="text-center">
<p class="text-2xl font-bold text-rose-500">
{{ formatOneDecimal(onboardingFunnelRates.subscribed) }}%
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('bundle-to-subscribed') }}
</p>
</div>
<p v-else class="text-sm text-slate-500 dark:text-slate-400">
{{ t('activation-telemetry-unavailable') }}
</p>
</div>
</div>
<div v-else class="flex items-center justify-center h-48 text-slate-400">
Expand Down
19 changes: 10 additions & 9 deletions supabase/functions/_backend/utils/onboardingFunnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
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<string>()
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<string>()
orgIds.add(cohort.org_id)
updateDownloadOrgIdsByDate.set(date, orgIds)
const updateDownloadOrgIdsForDate = updateDownloadOrgIdsByDate.get(date) ?? new Set<string>()
updateDownloadOrgIdsForDate.add(cohort.org_id)
updateDownloadOrgIdsByDate.set(date, updateDownloadOrgIdsForDate)
}
}

Expand Down
40 changes: 24 additions & 16 deletions supabase/functions/_backend/utils/pg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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,
}

Expand Down
27 changes: 21 additions & 6 deletions tests/admin-onboarding-funnel.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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,
})
})

Expand Down
Loading
Loading