diff --git a/artifacts/observe-reach.webp b/artifacts/observe-reach.webp new file mode 100644 index 0000000000..a5c8ffa1e7 Binary files /dev/null and b/artifacts/observe-reach.webp differ diff --git a/messages/en.context.json b/messages/en.context.json index cbabf70e51..e04fa74e89 100644 --- a/messages/en.context.json +++ b/messages/en.context.json @@ -632,8 +632,12 @@ "builds-last-month-trend": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "builds-period": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "builds-trend": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "bundle": "Used in Capgo web console areas: app, components/dashboard, components/tables, composables, layouts. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "bundle": "Used in Capgo web console areas: app, components/bundle, components/dashboard, components/tables, composables. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "bundle-active": "Used in Capgo web console areas: pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "bundle-adoption": "Used in Capgo web console areas: components/bundle. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "bundle-adoption-devices": "Used in Capgo web console areas: components/bundle. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "bundle-adoption-empty": "Used in Capgo web console areas: components/bundle. Role: empty state text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "bundle-adoption-error": "Used in Capgo web console areas: components/bundle. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "bundle-assigned-to-this-channel": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "bundle-comment": "Used in Capgo web console areas: pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "bundle-comment-empty": "Used in Capgo web console areas: pages/app. Role: empty state text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1501,7 +1505,7 @@ "link-this-bundle-to-another-channel": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "linked-bundle": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "loading": "Used in Capgo web console areas: components, components/bundle, components/dashboard, components/permissions, components/tables. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "loading-statistics": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "loading-statistics": "Used in Capgo web console areas: components/bundle, pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "loading-version": "Used in Capgo web console areas: components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "log-as": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "log-in": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2169,6 +2173,7 @@ "registration-to-subscription-conversion": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "registrations-by-source": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "registrations-by-source-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.", + "release-banner-adoption": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "release-or-30-days": "Used in Capgo web console. Role: filter or date-range option label about \"release or 30 days\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "release-status": "Used in Capgo web console. Role: UI label about \"release status\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "released": "Used in Capgo web console areas: components/dashboard, pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", diff --git a/messages/en.json b/messages/en.json index 20c4d5400e..5e25a1172a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -609,6 +609,10 @@ "builds-trend": "Native Builds Trend (Total, iOS, Android)", "bundle": "Bundle", "bundle-active": "Active", + "bundle-adoption": "Bundle reach", + "bundle-adoption-devices": "{count} / {total} devices", + "bundle-adoption-empty": "No devices have checked in on {version} yet.", + "bundle-adoption-error": "Could not load how many devices currently have {version}.", "bundle-comment": "Comment", "bundle-comment-empty": "No comment", "bundle-comment-placeholder": "Release notes, commit hash, or context", @@ -1543,6 +1547,7 @@ "new-name-to-short": "The apikey name is too short. It must be at least 4 characters long", "new-record": "New Record", "new-release-available": "New release available", + "release-banner-adoption": "{percent} of devices", "next": "Next", "next-run": "Next update", "no": "no", @@ -1791,7 +1796,7 @@ "org-notifications-bundle-deployed-desc": "Send bundle deployment notifications to the organization's management email", "org-notifications-channel-self-rejected-desc": "Send channel self-assignment rejection notifications to the organization's management email", "org-notifications-credit-usage-desc": "Send credit usage alerts to the organization's management email", - "org-notifications-deploy-stats-desc": "Send 24-hour deployment statistics to the organization's management email", + "org-notifications-deploy-stats-desc": "Send 24-hour install counts and how many devices reached the deployed bundle to the organization's management email", "org-notifications-description": "Control which email notifications are sent to your organization's management email address. These settings only apply when the management email is different from individual admin email addresses.", "org-notifications-device-error-desc": "Send device update error notifications to the organization's management email", "org-notifications-monthly-stats-desc": "Send monthly statistics to the organization's management email", diff --git a/playwright/e2e/bundle-adoption.spec.ts b/playwright/e2e/bundle-adoption.spec.ts new file mode 100644 index 0000000000..4f7e882377 --- /dev/null +++ b/playwright/e2e/bundle-adoption.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from '../support/commands' + +test.describe('Bundle reach', () => { + test.beforeEach(async ({ page }) => { + await page.login('test@capgo.app', 'testtest') + }) + + test('shows reach on observe updater', async ({ page }) => { + await page.goto('/app/com.demo.app/observe/updater') + const reachCard = page.locator('[data-test="bundle-adoption-card"]').first() + await expect(reachCard).toBeVisible() + await expect(reachCard).toContainText('Bundle reach') + + await reachCard.click() + await expect(page).toHaveURL(/\/app\/com\.demo\.app\/channel\/\d+\/statistics/) + }) +}) diff --git a/playwright/visual-diff.config.ts b/playwright/visual-diff.config.ts index 32317805c2..e18d58de3c 100644 --- a/playwright/visual-diff.config.ts +++ b/playwright/visual-diff.config.ts @@ -61,6 +61,11 @@ export const visualDiffRoutes: VisualDiffRoute[] = [ { slug: 'observe-native', path: '/app/com.demo.app/observe/native', auth: true }, { slug: 'observe-compatibility', path: '/app/com.demo.app/observe/compatibility', auth: true }, { slug: 'observe-plugins', path: '/app/com.demo.app/observe/plugins', auth: true }, + { + slug: 'channel-statistics', + path: '/app/com.demo.app/channel/1/statistics', + auth: true, + }, { slug: 'api-keys-app-preview', path: '/apikeys', diff --git a/src/components/bundle/BundleAdoptionCard.vue b/src/components/bundle/BundleAdoptionCard.vue new file mode 100644 index 0000000000..d579af7129 --- /dev/null +++ b/src/components/bundle/BundleAdoptionCard.vue @@ -0,0 +1,122 @@ + + + diff --git a/src/components/dashboard/ReleaseBanner.vue b/src/components/dashboard/ReleaseBanner.vue index 904cbe79ca..69d763667e 100644 --- a/src/components/dashboard/ReleaseBanner.vue +++ b/src/components/dashboard/ReleaseBanner.vue @@ -4,7 +4,10 @@ import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' import IconCheckCircle from '~icons/lucide/check-circle' import IconTrendingUp from '~icons/lucide/trending-up' -import { formatDistanceToNow } from '~/services/date' +import { getLatestDayVersionAdoption } from '~/services/bundleAdoption' +import { useChartData } from '~/services/chartDataService' +import { formatDistanceToNow, getChartDateRange } from '~/services/date' +import { formatNumberValue } from '~/services/formatLocale' import { useSupabase } from '~/services/supabase' import { useOrganizationStore } from '~/stores/organization' @@ -21,6 +24,8 @@ const isLoading = ref(false) const lastVersion = ref('') const lastReleaseDate = ref(null) const defaultChannelId = ref(null) +const adoptionPercent = ref(null) +let requestToken = 0 const HOURS_48_IN_DAYS = 2 @@ -30,6 +35,12 @@ const lastReleaseDisplay = computed(() => { return formatDistanceToNow(new Date(lastReleaseDate.value)) }) +const adoptionPercentLabel = computed(() => { + if (adoptionPercent.value === null) + return '' + return `${formatNumberValue(adoptionPercent.value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })}%` +}) + const hasRecentRelease = computed(() => { if (!lastReleaseDate.value || isLoading.value) return false @@ -44,15 +55,19 @@ async function fetchReleaseInfo() { return } + const currentToken = ++requestToken isLoading.value = true try { await organizationStore.awaitInitialLoad() + if (currentToken !== requestToken) + return const orgId = organizationStore.currentOrganization?.gid if (!orgId) { lastVersion.value = '' lastReleaseDate.value = null defaultChannelId.value = null + adoptionPercent.value = null return } @@ -71,25 +86,51 @@ async function fetchReleaseInfo() { .eq('public', true) .limit(1) + if (currentToken !== requestToken) + return + const latestVersion = versionsData?.[0] const defaultChannel = channelsData?.[0] if (latestVersion) { lastVersion.value = latestVersion.name lastReleaseDate.value = latestVersion.created_at + try { + const { startDate, endDate } = getChartDateRange(false) + const chartData = await useChartData(supabase, props.appId, startDate, endDate, 'bundle') + if (currentToken !== requestToken) + return + if (!chartData) { + adoptionPercent.value = null + } + else { + const adoption = getLatestDayVersionAdoption(chartData.datasets ?? [], latestVersion.name) + adoptionPercent.value = adoption && adoption.total > 0 ? adoption.percent : null + } + } + catch (error) { + console.error('Error fetching bundle adoption:', error) + if (currentToken !== requestToken) + return + adoptionPercent.value = null + } } else { lastVersion.value = '' lastReleaseDate.value = null + adoptionPercent.value = null } defaultChannelId.value = defaultChannel?.id || null } catch (error) { + if (currentToken !== requestToken) + return console.error('Error fetching release info:', error) } finally { - isLoading.value = false + if (currentToken === requestToken) + isLoading.value = false } } @@ -128,6 +169,9 @@ watch(() => [props.appId, organizationStore.currentOrganization?.gid], () => {

{{ t('version') }} {{ lastVersion }} — {{ t('released') }} {{ lastReleaseDisplay }} +

diff --git a/src/pages/app/[app].bundle.[bundle].vue b/src/pages/app/[app].bundle.[bundle].vue index 493f140377..4a7119b136 100644 --- a/src/pages/app/[app].bundle.[bundle].vue +++ b/src/pages/app/[app].bundle.[bundle].vue @@ -815,236 +815,238 @@ async function deleteBundle() {
-
-
- - {{ version.name }} - - - {{ version.id.toString() }} - - - {{ formatDate(version.created_at) }} - - - {{ formatDate(version.updated_at) }} - - - - - {{ hideString(version.checksum) }} - -
+
+
+
+ + {{ version.name }} + + + {{ version.id.toString() }} + + + {{ formatDate(version.created_at) }} + + + {{ formatDate(version.updated_at) }} + + + + + {{ hideString(version.checksum) }} + +
+ + +
+
{{ t('checksum-type-info') }}
+
{{ t('min-plugin-version') }}: {{ checksumInfo.minPluginVersion }}
+
{{ t('checksum-sha256-desc') }}
+
{{ t('checksum-crc32-desc') }}
+ +
+
+
- -
-
{{ t('checksum-type-info') }}
-
{{ t('min-plugin-version') }}: {{ checksumInfo.minPluginVersion }}
-
{{ t('checksum-sha256-desc') }}
-
{{ t('checksum-crc32-desc') }}
- -
+ + + + + {{ version.min_update_version }} + + + +
+
+ + {{ chn!.name }} + +
- - -
- - - {{ version.min_update_version }} - - - -
-
+ + + {{ t('set-bundle') }} + + + {{ t('encrypted') }} + + + {{ t('not-encrypted-bundle') }} + + + + {{ hideString(version.session_key) }} + + + + {{ version.key_id }} + + + + {{ version.cli_version }} + + + + {{ version.external_url }} + + + +
- {{ chn!.name }} + {{ version.link || t('bundle-link-empty') }}
-
- - - {{ t('set-bundle') }} - - - {{ t('encrypted') }} - - - {{ t('not-encrypted-bundle') }} - - - - {{ hideString(version.session_key) }} - - - - {{ version.key_id }} - - - - {{ version.cli_version }} - - - - {{ version.external_url }} - - - -
- - {{ version.link || t('bundle-link-empty') }} + + + +
+ + {{ version.comment || t('bundle-comment-empty') }} + + +
+
+ + + + + - -
-
- - -
- - {{ version.comment || t('bundle-comment-empty') }} + + + + + + - -
-
- - - - - - - - - - - - - - - - - - - {{ t('bundle-active') }} - - - - - - - {{ t('bundle-deleted') }} - -
+ + + + + + {{ t('bundle-deleted') }} + +
+
diff --git a/src/pages/app/[app].devices.vue b/src/pages/app/[app].devices.vue index ead3c33868..6d64bfc395 100644 --- a/src/pages/app/[app].devices.vue +++ b/src/pages/app/[app].devices.vue @@ -58,7 +58,7 @@ watchEffect(async () => {
- +
diff --git a/src/pages/app/[app].observe.updater.vue b/src/pages/app/[app].observe.updater.vue index e23fbe7566..1be60af128 100644 --- a/src/pages/app/[app].observe.updater.vue +++ b/src/pages/app/[app].observe.updater.vue @@ -85,6 +85,7 @@ const insightsLoading = ref(false) const selectedDays = ref(7) const app = ref() const insights = ref(null) +const publicChannels = ref<{ id: number, name: string, versionName: string }[]>([]) let latestInsightsRequest = 0 const appRouteSegment = computed(() => { @@ -160,9 +161,32 @@ async function loadAppInfo() { .eq('app_id', id.value) .single() app.value = dataApp || app.value + + const { data: channelsData } = await supabase + .from('channels') + .select(` + id, + name, + version:app_versions!channels_version_fkey(id, name) + `) + .eq('app_id', id.value) + .eq('public', true) + .order('id', { ascending: true }) + + const uniqueByVersion = new Map() + for (const channel of channelsData ?? []) { + const version = channel.version as { id?: number, name?: string } | { id?: number, name?: string }[] | null | undefined + const versionRow = Array.isArray(version) ? version[0] : version + const versionName = versionRow?.name + if (!versionName || uniqueByVersion.has(versionName)) + continue + uniqueByVersion.set(versionName, { id: channel.id, name: channel.name, versionName }) + } + publicChannels.value = [...uniqueByVersion.values()] } catch (error) { console.error(error) + publicChannels.value = [] } } @@ -314,7 +338,17 @@ watchEffect(async () => { -
+
+
diff --git a/src/pages/app/[app].vue b/src/pages/app/[app].vue index 91a649f218..bafd4eb068 100644 --- a/src/pages/app/[app].vue +++ b/src/pages/app/[app].vue @@ -202,6 +202,15 @@ watchEffect(async () => { :force-demo="appNotFound" class="col-span-full sm:col-span-6 xl:col-span-4" /> + + metaCounts?: Array + metaCountValues?: Array +} + +function toNonNegativeInt(value: number | null | undefined): number { + if (typeof value !== 'number' || !Number.isFinite(value)) + return 0 + return Math.max(0, Math.round(value)) +} + +function getCountSeries(dataset: ChartDatasetLike): Array { + if (Array.isArray(dataset.metaCountValues) && dataset.metaCountValues.length > 0) + return dataset.metaCountValues + if (Array.isArray(dataset.metaCounts) && dataset.metaCounts.length > 0) + return dataset.metaCounts + return [] +} + +function findLatestIndex(datasets: ChartDatasetLike[]): number { + let lastIndex = -1 + + for (const dataset of datasets) { + const counts = getCountSeries(dataset) + + for (let index = counts.length - 1; index >= 0; index--) { + if (toNonNegativeInt(counts[index]) > 0) { + lastIndex = Math.max(lastIndex, index) + break + } + } + } + + return lastIndex +} + +function percentFromShare(count: number, total: number): number { + if (total > 0) + return Math.round((count / total) * 1000) / 10 + return 0 +} + +/** + * Latest-day unique-device share for a specific bundle, or the leading bundle + * when no version name is given. Counts come from daily check-in stats. + */ +export function getLatestDayVersionAdoption( + datasets: ChartDatasetLike[], + versionName?: string, +): VersionAdoption | null { + if (!datasets.length) + return null + + const lastIndex = findLatestIndex(datasets) + if (lastIndex < 0) { + if (!versionName) + return null + return { + versionName, + count: 0, + total: 0, + percent: 0, + } + } + + const total = datasets.reduce((sum, dataset) => { + return sum + toNonNegativeInt(getCountSeries(dataset)[lastIndex]) + }, 0) + + const namedDataset = versionName + ? datasets.find(dataset => dataset.label === versionName) + : undefined + + if (versionName && !namedDataset) { + return { + versionName, + count: 0, + total, + percent: 0, + } + } + + const target = namedDataset ?? datasets.reduce((current, dataset) => { + const count = toNonNegativeInt(getCountSeries(dataset)[lastIndex]) + if (!current) + return dataset + const currentCount = toNonNegativeInt(getCountSeries(current)[lastIndex]) + return count > currentCount ? dataset : current + }, null) + + if (!target) + return null + + const count = toNonNegativeInt(getCountSeries(target)[lastIndex]) + return { + versionName: target.label, + count, + total, + percent: percentFromShare(count, total), + } +} diff --git a/supabase/functions/_backend/triggers/cron_email.ts b/supabase/functions/_backend/triggers/cron_email.ts index 3414542da8..1bc05c84dd 100644 --- a/supabase/functions/_backend/triggers/cron_email.ts +++ b/supabase/functions/_backend/triggers/cron_email.ts @@ -7,13 +7,14 @@ import { getPreviousMonthUtcRange, shouldRetryDeployInstallStats, shouldSendDeployInstallStatsEmail, + summarizeDeviceVersionAdoption, sumVersionInstalls, } from '../utils/cron_email_stats.ts' import { BRES, middlewareAPISecret, parseBody, simpleError } from '../utils/hono.ts' import { cloudlog, cloudlogErr } from '../utils/logging.ts' import { sendEmailToOrgMembers } from '../utils/org_email_notifications.ts' import { findBestPlan } from '../utils/plans.ts' -import { readStatsVersion } from '../utils/stats.ts' +import { readDeviceVersionCounts, readStatsVersion } from '../utils/stats.ts' import { getCurrentPlanNameOrg, supabaseAdmin } from '../utils/supabase.ts' export const app = new Hono() @@ -207,6 +208,28 @@ async function handleMonthlyCreateStats(c: Context, appId: string) { return c.json(BRES) } +async function loadDeployVersionAdoption( + c: Context, + appId: string, + versionName?: string, + versionId?: number, + channelName?: string, +) { + try { + const deviceCounts = await readDeviceVersionCounts(c, appId, channelName) + return summarizeDeviceVersionAdoption(deviceCounts, versionName, versionId) + } + catch (error) { + cloudlogErr({ + requestId: c.get('requestId'), + message: 'Failed to read device version counts for deploy stats email', + error, + metadata: { appId, versionName, versionId, channelName }, + }) + return summarizeDeviceVersionAdoption({}, versionName, versionId) + } +} + async function handleDeployInstallStats( c: Context, payload: { @@ -266,22 +289,25 @@ async function handleDeployInstallStats( // Coerce installs with Number() — Analytics Engine sum() can arrive as stringy Float64. const installs = sumVersionInstalls(versionStats, versionName, versionId) - const metadata = { - app_id: appId, - app_name: appName ?? '', - deploy_id: deployId?.toString(), - version_id: versionId?.toString(), - version_name: versionName ?? '', - channel_id: channelId?.toString(), - channel_name: channelName ?? '', - platform: platform ?? '', - deployed_at: windowStart, - install_count_24h: installs.toString(), - window_hours: '24', - } - if (shouldSendDeployInstallStatsEmail(installs)) { - await sendEmailToOrgMembers(c, 'bundle:install_stats_24h', 'deploy_stats_24h', metadata, orgId ?? await getOrgIdForApp(c, appId)) + const adoption = await loadDeployVersionAdoption(c, appId, versionName, versionId, channelName) + + await sendEmailToOrgMembers(c, 'bundle:install_stats_24h', 'deploy_stats_24h', { + app_id: appId, + app_name: appName ?? '', + deploy_id: deployId?.toString(), + version_id: versionId?.toString(), + version_name: versionName ?? '', + channel_id: channelId?.toString(), + channel_name: channelName ?? '', + platform: platform ?? '', + deployed_at: windowStart, + install_count_24h: installs.toString(), + window_hours: '24', + device_count: adoption.device_count.toString(), + total_devices: adoption.total_devices.toString(), + adoption_percent: adoption.adoption_percent, + }, orgId ?? await getOrgIdForApp(c, appId)) return c.json(BRES) } diff --git a/supabase/functions/_backend/utils/cron_email_stats.ts b/supabase/functions/_backend/utils/cron_email_stats.ts index 8b556ad309..ed1b973f5c 100644 --- a/supabase/functions/_backend/utils/cron_email_stats.ts +++ b/supabase/functions/_backend/utils/cron_email_stats.ts @@ -215,3 +215,32 @@ export function shouldRetryDeployInstallStats(deployedAt: Date, now: Date = new export function shouldSendDeployInstallStatsEmail(installs: number): boolean { return installs > 1 } + +export interface DeviceVersionAdoptionSummary { + device_count: number + total_devices: number + adoption_percent: string +} + +/** Live unique-device reach for a deployed bundle from version_name counts. */ +export function summarizeDeviceVersionAdoption( + counts: Record, + versionName?: string | null, + versionId?: number | null, +): DeviceVersionAdoptionSummary { + const totalDevices = Object.values(counts).reduce((sum, value) => { + return sum + Math.max(0, Math.round(toStatNumber(value))) + }, 0) + const namedCount = versionName ? counts[versionName] : undefined + const idCount = versionId != null ? counts[String(versionId)] : undefined + const deviceCount = Math.max(0, Math.round(toStatNumber(namedCount ?? idCount ?? 0))) + const adoptionPercent = totalDevices > 0 + ? (Math.round((deviceCount / totalDevices) * 1000) / 10).toFixed(1) + : '0.0' + + return { + device_count: deviceCount, + total_devices: totalDevices, + adoption_percent: adoptionPercent, + } +} diff --git a/tests/bundle-adoption.unit.test.ts b/tests/bundle-adoption.unit.test.ts new file mode 100644 index 0000000000..1024d58f48 --- /dev/null +++ b/tests/bundle-adoption.unit.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { getLatestDayVersionAdoption } from '~/services/bundleAdoption' + +describe('getLatestDayVersionAdoption', () => { + it.concurrent('returns the named bundle share on the latest day with counts', () => { + const adoption = getLatestDayVersionAdoption([ + { label: '1.0.0', metaCountValues: [80, 60, 20], data: [80, 60, 20] }, + { label: '1.1.0', metaCountValues: [20, 40, 80], data: [20, 40, 80] }, + ], '1.1.0') + + expect(adoption).toEqual({ + versionName: '1.1.0', + count: 80, + total: 100, + percent: 80, + }) + }) + + it.concurrent('returns zero for a bundle that has not been reported yet', () => { + const adoption = getLatestDayVersionAdoption([ + { label: '1.0.0', metaCounts: [100], data: [100] }, + ], '1.2.0') + + expect(adoption).toEqual({ + versionName: '1.2.0', + count: 0, + total: 100, + percent: 0, + }) + }) + + it.concurrent('picks the leading bundle when no version is given', () => { + const adoption = getLatestDayVersionAdoption([ + { label: '1.0.0', metaCountValues: [10, 30] }, + { label: '1.1.0', metaCountValues: [90, 70] }, + ]) + + expect(adoption).toEqual({ + versionName: '1.1.0', + count: 70, + total: 100, + percent: 70, + }) + }) + + it.concurrent('skips trailing empty days', () => { + const adoption = getLatestDayVersionAdoption([ + { label: '1.0.0', metaCountValues: [40, 0], data: [40, 0] }, + { label: '1.1.0', metaCountValues: [60, 0], data: [60, 0] }, + ], '1.1.0') + + expect(adoption).toEqual({ + versionName: '1.1.0', + count: 60, + total: 100, + percent: 60, + }) + }) + + it.concurrent('ignores percent-only days that have no device counts', () => { + const adoption = getLatestDayVersionAdoption([ + { label: '1.0.0', metaCountValues: [40, 0], data: [40, 80] }, + { label: '1.1.0', metaCountValues: [60, 0], data: [60, 20] }, + ], '1.1.0') + + expect(adoption).toEqual({ + versionName: '1.1.0', + count: 60, + total: 100, + percent: 60, + }) + }) + + it.concurrent('keeps one-decimal rounding for fractional shares', () => { + const adoption = getLatestDayVersionAdoption([ + { label: '1.0.0', metaCountValues: [77] }, + { label: '1.1.0', metaCountValues: [56] }, + ], '1.1.0') + + expect(adoption).toEqual({ + versionName: '1.1.0', + count: 56, + total: 133, + percent: 42.1, + }) + }) +}) diff --git a/tests/cron-email-stats.unit.test.ts b/tests/cron-email-stats.unit.test.ts index 42b2fb796b..e2ad9222f7 100644 --- a/tests/cron-email-stats.unit.test.ts +++ b/tests/cron-email-stats.unit.test.ts @@ -7,6 +7,7 @@ import { getPreviousMonthUtcRange, shouldRetryDeployInstallStats, shouldSendDeployInstallStatsEmail, + summarizeDeviceVersionAdoption, sumVersionInstalls, toStatNumber, } from '../supabase/functions/_backend/utils/cron_email_stats.ts' @@ -198,3 +199,46 @@ describe('shouldRetryDeployInstallStats', () => { expect(shouldRetryDeployInstallStats(deployedAt, now)).toBe(false) }) }) + +describe('summarizeDeviceVersionAdoption', () => { + it.concurrent('computes live device reach for a deployed bundle', () => { + expect(summarizeDeviceVersionAdoption({ + '1.0.0': 20, + '1.1.0': 80, + }, '1.1.0')).toEqual({ + device_count: 80, + total_devices: 100, + adoption_percent: '80.0', + }) + }) + + it.concurrent('returns zeros when no devices have reported', () => { + expect(summarizeDeviceVersionAdoption({}, '1.1.0')).toEqual({ + device_count: 0, + total_devices: 0, + adoption_percent: '0.0', + }) + }) + + it.concurrent('falls back to version_id string keys used by legacy device counts', () => { + expect(summarizeDeviceVersionAdoption({ + 9601: 40, + 9602: 60, + }, '1.1.0', 9602)).toEqual({ + device_count: 60, + total_devices: 100, + adoption_percent: '60.0', + }) + }) + + it.concurrent('keeps one-decimal rounding for fractional shares', () => { + expect(summarizeDeviceVersionAdoption({ + '1.0.0': 77, + '1.1.0': 56, + }, '1.1.0')).toEqual({ + device_count: 56, + total_devices: 133, + adoption_percent: '42.1', + }) + }) +})