diff --git a/messages/en.json b/messages/en.json index e0e1e63020..38aa6ffc7c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -205,6 +205,10 @@ "new-users": "New Users", "plan-distribution": "Plan Distribution", "plan-distribution-trend": "Plan Distribution Trend", + "onboarding-funnel": "Onboarding Funnel", + "onboarding-funnel-description": "Track user progression from organization creation to first bundle upload", + "onboarding-trend": "Onboarding Trend", + "no-data-available": "No data available", "users-trend": "Users Trend", "users-activity-trend": "Users Activity Trend", "registrations-and-updates": "Registrations & Updates", diff --git a/src/pages/admin/dashboard/users.vue b/src/pages/admin/dashboard/users.vue index ffd63817c5..0834280a83 100644 --- a/src/pages/admin/dashboard/users.vue +++ b/src/pages/admin/dashboard/users.vue @@ -25,6 +25,27 @@ const adminStore = useAdminDashboardStore() const router = useRouter() const isLoading = ref(true) +// Onboarding funnel data +interface OnboardingFunnelData { + total_orgs: number + orgs_with_app: number + orgs_with_channel: number + orgs_with_bundle: number + app_conversion_rate: number + channel_conversion_rate: number + bundle_conversion_rate: number + trend: Array<{ + date: string + new_orgs: number + orgs_created_app: number + orgs_created_channel: number + orgs_created_bundle: number + }> +} + +const onboardingFunnelData = ref(null) +const isLoadingOnboardingFunnel = ref(false) + // Global stats trend data const globalStatsTrendData = ref { if (globalStatsTrendData.value.length === 0) @@ -284,13 +321,91 @@ const latestGlobalStats = computed(() => { return globalStatsTrendData.value[globalStatsTrendData.value.length - 1] }) +// Onboarding funnel stages for display +const onboardingFunnelStages = computed(() => { + if (!onboardingFunnelData.value) + return [] + + const data = onboardingFunnelData.value + return [ + { + label: 'Organizations Created', + value: data.total_orgs, + percentage: 100, + color: '#3b82f6', // blue + }, + { + label: 'Created an App', + value: data.orgs_with_app, + percentage: data.app_conversion_rate, + color: '#8b5cf6', // purple + }, + { + label: 'Created a Channel', + value: data.orgs_with_channel, + percentage: data.channel_conversion_rate, + color: '#f59e0b', // amber + }, + { + label: 'Uploaded a Bundle', + value: data.orgs_with_bundle, + percentage: data.bundle_conversion_rate, + color: '#10b981', // green + }, + ] +}) + +// Onboarding funnel trend for multi-line chart +const onboardingFunnelTrendSeries = computed(() => { + if (!onboardingFunnelData.value || !onboardingFunnelData.value.trend) + return [] + + const trend = onboardingFunnelData.value.trend + return [ + { + label: 'New Organizations', + data: trend.map(item => ({ + date: item.date, + value: item.new_orgs, + })), + color: '#3b82f6', // blue + }, + { + label: 'Created App (within 7 days)', + data: trend.map(item => ({ + date: item.date, + value: item.orgs_created_app, + })), + color: '#8b5cf6', // purple + }, + { + label: 'Created Channel (within 7 days)', + data: trend.map(item => ({ + date: item.date, + value: item.orgs_created_channel, + })), + color: '#f59e0b', // amber + }, + { + label: 'Uploaded Bundle (within 7 days)', + data: trend.map(item => ({ + date: item.date, + value: item.orgs_created_bundle, + })), + color: '#10b981', // green + }, + ] +}) + watch(() => adminStore.activeDateRange, () => { loadGlobalStatsTrend() + loadOnboardingFunnel() }, { deep: true }) // Watch for refresh button clicks watch(() => adminStore.refreshTrigger, () => { loadGlobalStatsTrend() + loadOnboardingFunnel() }) onMounted(async () => { @@ -301,7 +416,7 @@ onMounted(async () => { } isLoading.value = true - await Promise.all([loadGlobalStatsTrend(), loadTrialOrganizations()]) + await Promise.all([loadGlobalStatsTrend(), loadOnboardingFunnel(), loadTrialOrganizations()]) isLoading.value = false displayStore.NavTitle = t('users-and-revenue') @@ -322,6 +437,85 @@ displayStore.defaultBack = '/dashboard'
+ +
+

+ {{ t('onboarding-funnel') }} +

+

+ {{ t('onboarding-funnel-description') }} +

+
+ +
+
+ +
+
+ {{ stage.label }} + + {{ stage.value.toLocaleString() }} + + ({{ stage.percentage.toFixed(1) }}% {{ index === 1 ? 'of orgs' : 'of previous' }}) + + +
+
+
+
+
+ + +
+
+

+ {{ onboardingFunnelData?.app_conversion_rate?.toFixed(1) || 0 }}% +

+

+ Org → App +

+
+
+

+ {{ onboardingFunnelData?.channel_conversion_rate?.toFixed(1) || 0 }}% +

+

+ App → Channel +

+
+
+

+ {{ onboardingFunnelData?.bundle_conversion_rate?.toFixed(1) || 0 }}% +

+

+ Channel → Bundle +

+
+
+
+
+ {{ t('no-data-available') }} +
+
+ + + + + +
diff --git a/src/stores/adminDashboard.ts b/src/stores/adminDashboard.ts index 298e0bfd92..ee73713323 100644 --- a/src/stores/adminDashboard.ts +++ b/src/stores/adminDashboard.ts @@ -2,7 +2,7 @@ import { acceptHMRUpdate, defineStore } from 'pinia' import { computed, ref } from 'vue' import { defaultApiHost, useSupabase } from '~/services/supabase' -export type MetricCategory = 'uploads' | 'distribution' | 'failures' | 'success_rate' | 'platform_overview' | 'org_metrics' | 'mau_trend' | 'success_rate_trend' | 'apps_trend' | 'bundles_trend' | 'deployments_trend' | 'storage_trend' | 'bandwidth_trend' | 'global_stats_trend' | 'plugin_breakdown' | 'trial_organizations' +export type MetricCategory = 'uploads' | 'distribution' | 'failures' | 'success_rate' | 'platform_overview' | 'org_metrics' | 'mau_trend' | 'success_rate_trend' | 'apps_trend' | 'bundles_trend' | 'deployments_trend' | 'storage_trend' | 'bandwidth_trend' | 'global_stats_trend' | 'plugin_breakdown' | 'trial_organizations' | 'onboarding_funnel' export type DateRangeMode = '30day' | '90day' | 'quarter' | '6month' | '12month' | 'custom' interface DateRange { diff --git a/supabase/functions/_backend/private/admin_stats.ts b/supabase/functions/_backend/private/admin_stats.ts index 16ce45124e..4cfeffae27 100644 --- a/supabase/functions/_backend/private/admin_stats.ts +++ b/supabase/functions/_backend/private/admin_stats.ts @@ -4,11 +4,11 @@ import { z } from 'zod/mini' import { getAdminAppsTrend, getAdminBandwidthTrend, getAdminBundlesTrend, getAdminDistributionMetrics, getAdminFailureMetrics, getAdminMauTrend, getAdminOrgMetrics, getAdminPlatformOverview, getAdminStorageTrend, getAdminSuccessRate, getAdminSuccessRateTrend, getAdminUploadMetrics } from '../utils/cloudflare.ts' import { middlewareAuth, parseBody, simpleError, useCors } from '../utils/hono.ts' import { cloudlog } from '../utils/logging.ts' -import { getAdminDeploymentsTrend, getAdminGlobalStatsTrend, getAdminPluginBreakdown, getAdminTrialOrganizations } from '../utils/pg.ts' +import { getAdminDeploymentsTrend, getAdminGlobalStatsTrend, getAdminOnboardingFunnel, getAdminPluginBreakdown, getAdminTrialOrganizations } from '../utils/pg.ts' import { supabaseClient as useSupabaseClient } from '../utils/supabase.ts' const bodySchema = z.object({ - metric_category: z.enum(['uploads', 'distribution', 'failures', 'success_rate', 'platform_overview', 'org_metrics', 'mau_trend', 'success_rate_trend', 'apps_trend', 'bundles_trend', 'deployments_trend', 'storage_trend', 'bandwidth_trend', 'global_stats_trend', 'plugin_breakdown', 'trial_organizations']), + metric_category: z.enum(['uploads', 'distribution', 'failures', 'success_rate', 'platform_overview', 'org_metrics', 'mau_trend', 'success_rate_trend', 'apps_trend', 'bundles_trend', 'deployments_trend', 'storage_trend', 'bandwidth_trend', 'global_stats_trend', 'plugin_breakdown', 'trial_organizations', 'onboarding_funnel']), start_date: z.string().check(z.minLength(1)), end_date: z.string().check(z.minLength(1)), }) @@ -134,6 +134,10 @@ app.post('/', middlewareAuth, async (c) => { result = await getAdminTrialOrganizations(c, limit || 20, offset || 0) break + case 'onboarding_funnel': + result = await getAdminOnboardingFunnel(c, start_date, end_date) + break + default: throw simpleError('invalid_metric_category', 'Invalid metric category', { metric_category }) } diff --git a/supabase/functions/_backend/utils/pg.ts b/supabase/functions/_backend/utils/pg.ts index cca6129b95..fe164c936a 100644 --- a/supabase/functions/_backend/utils/pg.ts +++ b/supabase/functions/_backend/utils/pg.ts @@ -1123,6 +1123,178 @@ export async function getAdminTrialOrganizations( } } +// Admin Onboarding Funnel +export interface AdminOnboardingFunnel { + total_orgs: number + orgs_with_app: number + orgs_with_channel: number + orgs_with_bundle: number + // Conversion rates + app_conversion_rate: number + channel_conversion_rate: number + bundle_conversion_rate: number + // Trend data + trend: Array<{ + date: string + new_orgs: number + orgs_created_app: number + orgs_created_channel: number + orgs_created_bundle: number + }> +} + +export async function getAdminOnboardingFunnel( + c: Context, + start_date: string, + end_date: string, +): Promise { + try { + const pgClient = getPgClient(c, true) // Read-only query + const drizzleClient = getDrizzleClient(pgClient) + + // Get total funnel counts for orgs created in the date range + const funnelQuery = sql` + WITH orgs_in_range AS ( + SELECT id, created_at::date as created_date + FROM orgs + WHERE created_at >= ${start_date}::timestamp + AND created_at < ${end_date}::timestamp + ), + orgs_with_apps AS ( + SELECT DISTINCT o.id, o.created_date + FROM orgs_in_range o + INNER JOIN apps a ON a.owner_org = o.id + ), + orgs_with_channels AS ( + SELECT DISTINCT o.id, 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 + ), + orgs_with_bundles AS ( + SELECT DISTINCT o.id, 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 != 'builtin' + ) + SELECT + (SELECT COUNT(*)::int FROM orgs_in_range) as total_orgs, + (SELECT COUNT(*)::int FROM orgs_with_apps) as orgs_with_app, + (SELECT COUNT(*)::int FROM orgs_with_channels) as orgs_with_channel, + (SELECT COUNT(*)::int FROM orgs_with_bundles) as orgs_with_bundle + ` + + const funnelResult = await drizzleClient.execute(funnelQuery) + const funnelRow = funnelResult.rows[0] as any || {} + + const totalOrgs = Number(funnelRow.total_orgs) || 0 + const orgsWithApp = Number(funnelRow.orgs_with_app) || 0 + const orgsWithChannel = Number(funnelRow.orgs_with_channel) || 0 + const orgsWithBundle = Number(funnelRow.orgs_with_bundle) || 0 + + // Get daily trend data + const trendQuery = sql` + WITH date_series AS ( + SELECT generate_series( + ${start_date}::date, + ${end_date}::date - interval '1 day', + interval '1 day' + )::date as date + ), + daily_orgs AS ( + SELECT created_at::date as date, COUNT(*)::int as new_orgs + FROM orgs + WHERE created_at >= ${start_date}::timestamp + AND created_at < ${end_date}::timestamp + GROUP BY created_at::date + ), + daily_apps AS ( + SELECT o.created_at::date as date, COUNT(DISTINCT o.id)::int as orgs_created_app + FROM orgs o + 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' + GROUP BY o.created_at::date + ), + daily_channels AS ( + SELECT o.created_at::date as date, COUNT(DISTINCT o.id)::int as orgs_created_channel + FROM orgs o + INNER JOIN apps a ON a.owner_org = o.id + INNER JOIN channels c ON c.app_id = a.app_id + WHERE o.created_at >= ${start_date}::timestamp + AND o.created_at < ${end_date}::timestamp + AND c.created_at >= o.created_at + AND c.created_at < o.created_at + interval '7 days' + GROUP BY o.created_at::date + ), + daily_bundles AS ( + 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 != 'builtin' + 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' + GROUP BY o.created_at::date + ) + SELECT + ds.date, + COALESCE(do.new_orgs, 0) as new_orgs, + COALESCE(da.orgs_created_app, 0) as orgs_created_app, + COALESCE(dc.orgs_created_channel, 0) as orgs_created_channel, + COALESCE(db.orgs_created_bundle, 0) as orgs_created_bundle + FROM date_series ds + LEFT JOIN daily_orgs do ON do.date = ds.date + LEFT JOIN daily_apps da ON da.date = ds.date + LEFT JOIN daily_channels dc ON dc.date = ds.date + LEFT JOIN daily_bundles db ON db.date = ds.date + ORDER BY ds.date ASC + ` + + const trendResult = await drizzleClient.execute(trendQuery) + const trend = trendResult.rows.map((row: any) => ({ + date: row.date instanceof Date ? row.date.toISOString().split('T')[0] : row.date, + new_orgs: Number(row.new_orgs) || 0, + orgs_created_app: Number(row.orgs_created_app) || 0, + orgs_created_channel: Number(row.orgs_created_channel) || 0, + orgs_created_bundle: Number(row.orgs_created_bundle) || 0, + })) + + const result: AdminOnboardingFunnel = { + total_orgs: totalOrgs, + orgs_with_app: orgsWithApp, + orgs_with_channel: orgsWithChannel, + orgs_with_bundle: orgsWithBundle, + app_conversion_rate: totalOrgs > 0 ? (orgsWithApp / totalOrgs) * 100 : 0, + channel_conversion_rate: orgsWithApp > 0 ? (orgsWithChannel / orgsWithApp) * 100 : 0, + bundle_conversion_rate: orgsWithChannel > 0 ? (orgsWithBundle / orgsWithChannel) * 100 : 0, + trend, + } + + cloudlog({ requestId: c.get('requestId'), message: 'getAdminOnboardingFunnel result', result }) + + return result + } + catch (e: unknown) { + logPgError(c, 'getAdminOnboardingFunnel', e) + return { + total_orgs: 0, + orgs_with_app: 0, + orgs_with_channel: 0, + orgs_with_bundle: 0, + app_conversion_rate: 0, + channel_conversion_rate: 0, + bundle_conversion_rate: 0, + trend: [], + } + } +} + export async function getAdminPluginBreakdown( c: Context, start_date: string,