From bcae1761da67c9a17ac50786e190b6d16591c78b Mon Sep 17 00:00:00 2001
From: Martin Donadieu
Date: Thu, 13 Aug 2026 13:39:20 +0300
Subject: [PATCH 01/13] feat(console): surface how many devices have a bundle
Reach already lived in channel statistics, but people look at the
bundle page and Observe after upload. Show the same check-in share
there, on the app dashboard, and in the 24h deploy email.
Co-authored-by: Cursor
---
messages/en.context.json | 12 +-
messages/en.json | 10 +-
playwright/e2e/bundle-adoption.spec.ts | 18 +
playwright/visual-diff.config.ts | 10 +
src/components/bundle/BundleAdoptionCard.vue | 171 +++++++
src/components/dashboard/ReleaseBanner.vue | 26 +-
src/pages/app/[app].bundle.[bundle].vue | 438 +++++++++---------
src/pages/app/[app].devices.vue | 2 +-
src/pages/app/[app].observe.updater.vue | 28 ++
src/pages/app/[app].vue | 9 +
src/services/bundleAdoption.ts | 115 +++++
.../functions/_backend/triggers/cron_email.ts | 48 +-
.../_backend/utils/cron_email_stats.ts | 28 ++
tests/bundle-adoption.unit.test.ts | 59 +++
tests/cron-email-stats.unit.test.ts | 22 +
15 files changed, 764 insertions(+), 232 deletions(-)
create mode 100644 playwright/e2e/bundle-adoption.spec.ts
create mode 100644 src/components/bundle/BundleAdoptionCard.vue
create mode 100644 src/services/bundleAdoption.ts
create mode 100644 tests/bundle-adoption.unit.test.ts
diff --git a/messages/en.context.json b/messages/en.context.json
index cbabf70e51..9fd07294ba 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-help": "Used in Capgo web console areas: components/bundle. Role: helper or description text. 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.",
@@ -2652,6 +2657,9 @@
"view": "Used in Capgo web console areas: components/tables, composables, pages/admin/dashboard, pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"view-action-logs": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"view-adoption": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "view-bundle-devices": "Used in Capgo web console areas: components/bundle. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "view-channel-adoption": "Used in Capgo web console areas: components/bundle. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "view-channel-adoption-named": "Used in Capgo web console areas: components/bundle. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"view-deliveries": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"view-dependencies": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"view-logs": "Used in Capgo web console areas: pages/app. 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 20c4d5400e..4cd2452bed 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 reported {version} yet. They pick it up the next time they check for updates.",
+ "bundle-adoption-help": "{count} of {total} devices ({percent}) currently report {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",
@@ -2490,6 +2495,9 @@
"version-rules-tester-description": "Learn how versions work and simulate update scenarios",
"view": "View",
"view-adoption": "View adoption",
+ "view-bundle-devices": "View devices on this bundle",
+ "view-channel-adoption": "View channel adoption",
+ "view-channel-adoption-named": "View {channel} adoption",
"view-deliveries": "View Deliveries",
"view-dependencies": "View dependencies",
"view-on-npm": "View on npm",
diff --git a/playwright/e2e/bundle-adoption.spec.ts b/playwright/e2e/bundle-adoption.spec.ts
new file mode 100644
index 0000000000..e99724c5c6
--- /dev/null
+++ b/playwright/e2e/bundle-adoption.spec.ts
@@ -0,0 +1,18 @@
+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 the bundle page and observe updater', async ({ page }) => {
+ await page.goto('/app/com.demo.app/bundle/3')
+ const bundleCard = page.locator('[data-test="bundle-adoption-card"]')
+ await expect(bundleCard).toBeVisible()
+ await expect(bundleCard.getByRole('heading', { name: 'Bundle reach' })).toBeVisible()
+ await expect(page.locator('[data-test="bundle-adoption-devices"]')).toBeVisible()
+
+ await page.goto('/app/com.demo.app/observe/updater')
+ await expect(page.locator('[data-test="bundle-adoption-card"]')).toBeVisible()
+ })
+})
diff --git a/playwright/visual-diff.config.ts b/playwright/visual-diff.config.ts
index 32317805c2..1fc9e1d614 100644
--- a/playwright/visual-diff.config.ts
+++ b/playwright/visual-diff.config.ts
@@ -61,6 +61,16 @@ 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: 'bundle-detail',
+ path: '/app/com.demo.app/bundle/3',
+ 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..790cedadb0
--- /dev/null
+++ b/src/components/bundle/BundleAdoptionCard.vue
@@ -0,0 +1,171 @@
+
+
+
+
+
+
+
+
+
+ {{ t('bundle-adoption') }}
+
+
+
+ {{ t('loading-statistics') }}
+
+
+ {{ t('bundle-adoption-help', {
+ version: versionName,
+ count: countLabel,
+ total: totalLabel,
+ percent: percentLabel,
+ }) }}
+
+
+ {{ t('bundle-adoption-empty', { version: versionName }) }}
+
+
+
+
+
+
+
+
+ {{ percentLabel }}
+
+
+ {{ t('bundle-adoption-devices', { count: countLabel, total: totalLabel }) }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/dashboard/ReleaseBanner.vue b/src/components/dashboard/ReleaseBanner.vue
index 904cbe79ca..39ecdd085a 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,7 @@ const isLoading = ref(false)
const lastVersion = ref('')
const lastReleaseDate = ref(null)
const defaultChannelId = ref(null)
+const adoptionPercent = ref(null)
const HOURS_48_IN_DAYS = 2
@@ -30,6 +34,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
@@ -53,6 +63,7 @@ async function fetchReleaseInfo() {
lastVersion.value = ''
lastReleaseDate.value = null
defaultChannelId.value = null
+ adoptionPercent.value = null
return
}
@@ -77,10 +88,20 @@ async function fetchReleaseInfo() {
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')
+ adoptionPercent.value = getLatestDayVersionAdoption(chartData?.datasets ?? [], latestVersion.name)?.percent ?? 0
+ }
+ catch (error) {
+ console.error('Error fetching bundle adoption:', error)
+ adoptionPercent.value = null
+ }
}
else {
lastVersion.value = ''
lastReleaseDate.value = null
+ adoptionPercent.value = null
}
defaultChannelId.value = defaultChannel?.id || null
@@ -128,6 +149,9 @@ watch(() => [props.appId, organizationStore.currentOrganization?.gid], () => {
{{ t('version') }} {{ lastVersion }} — {{ t('released') }} {{ lastReleaseDisplay }}
+
+ · {{ t('release-banner-adoption', { percent: adoptionPercentLabel }) }}
+
diff --git a/src/pages/app/[app].bundle.[bundle].vue b/src/pages/app/[app].bundle.[bundle].vue
index 493f140377..f9f0b2e118 100644
--- a/src/pages/app/[app].bundle.[bundle].vue
+++ b/src/pages/app/[app].bundle.[bundle].vue
@@ -97,6 +97,14 @@ watch([() => channels.value, () => promotableChannelIds.value], () => {
const canPromoteBundle = computed(() => promotableChannelIds.value.size > 0)
+const linkedChannelsForVersion = computed(() => {
+ if (!version.value)
+ return []
+ return channels.value.filter(channel => channel.version === version.value!.id)
+})
+
+const primaryLinkedChannel = computed(() => linkedChannelsForVersion.value[0] ?? null)
+
const canEditBundleMetadata = computedAsync(async () => {
if (!version.value?.app_id)
return false
@@ -815,236 +823,244 @@ 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') }}
+
+
+
+
+
+
+
+
+ {{ zipSizeLabel }}
+
+
+
+ {{ t('no-zip-bundle') }}
+
-
-
-
-
-
-
-
- {{ version.comment || t('bundle-comment-empty') }}
+
+
+
+
+
+ {{ t('open') }}
+
+
+ {{ t('no-manifest-bundle') }}
+
-
-
-
-
-
-
-
- {{ zipSizeLabel }}
+
+
+
+
+
+ {{ t('bundle-active') }}
-
-
- {{ t('no-zip-bundle') }}
-
-
-
-
-
-
-
- {{ t('open') }}
-
-
- {{ t('no-manifest-bundle') }}
-
-
-
-
-
-
-
- {{ 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..8e13950276 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 publicChannel = ref<{ id: number, name: string, versionName: string } | null>(null)
let latestInsightsRequest = 0
const appRouteSegment = computed(() => {
@@ -160,6 +161,25 @@ async function loadAppInfo() {
.eq('app_id', id.value)
.single()
app.value = dataApp || app.value
+
+ const { data: channelData } = 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 })
+ .limit(1)
+ .maybeSingle()
+
+ const version = channelData?.version as { id?: number, name?: string } | { id?: number, name?: string }[] | null | undefined
+ const versionRow = Array.isArray(version) ? version[0] : version
+ publicChannel.value = channelData && versionRow?.name
+ ? { id: channelData.id, name: channelData.name, versionName: versionRow.name }
+ : null
}
catch (error) {
console.error(error)
@@ -267,6 +287,14 @@ 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)
+ const percents = dataset.data ?? []
+ const length = Math.max(counts.length, percents.length)
+
+ for (let index = length - 1; index >= 0; index--) {
+ const percent = percents[index]
+ if (toNonNegativeInt(counts[index]) > 0 || (typeof percent === 'number' && Number.isFinite(percent) && percent > 0)) {
+ lastIndex = Math.max(lastIndex, index)
+ break
+ }
+ }
+ }
+
+ return lastIndex
+}
+
+function percentFromShare(count: number, total: number, fallbackPercent: number | null | undefined): number {
+ if (total > 0)
+ return Math.round((count / total) * 1000) / 10
+ if (typeof fallbackPercent === 'number' && Number.isFinite(fallbackPercent))
+ return Math.max(0, Math.round(fallbackPercent * 10) / 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, target.data?.[lastIndex]),
+ }
+}
diff --git a/supabase/functions/_backend/triggers/cron_email.ts b/supabase/functions/_backend/triggers/cron_email.ts
index 3414542da8..0dfd02950b 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()
@@ -266,22 +267,37 @@ 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))
+ let adoption = summarizeDeviceVersionAdoption({}, versionName)
+ try {
+ const deviceCounts = await readDeviceVersionCounts(c, appId, channelName)
+ adoption = summarizeDeviceVersionAdoption(deviceCounts, versionName)
+ }
+ catch (error) {
+ cloudlogErr({
+ requestId: c.get('requestId'),
+ message: 'Failed to read device version counts for deploy stats email',
+ error,
+ metadata: { appId, versionName, 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..b05ef16e26 100644
--- a/supabase/functions/_backend/utils/cron_email_stats.ts
+++ b/supabase/functions/_backend/utils/cron_email_stats.ts
@@ -215,3 +215,31 @@ 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,
+): DeviceVersionAdoptionSummary {
+ const totalDevices = Object.values(counts).reduce((sum, value) => {
+ return sum + Math.max(0, Math.round(toStatNumber(value)))
+ }, 0)
+ const deviceCount = versionName
+ ? Math.max(0, Math.round(toStatNumber(counts[versionName] ?? 0)))
+ : 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..ecaaa01e60
--- /dev/null
+++ b/tests/bundle-adoption.unit.test.ts
@@ -0,0 +1,59 @@
+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,
+ })
+ })
+})
diff --git a/tests/cron-email-stats.unit.test.ts b/tests/cron-email-stats.unit.test.ts
index 42b2fb796b..b1b9b3e5cf 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,24 @@ 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',
+ })
+ })
+})
From d089e15ada531d00eeccf51bce556e63455ccd36 Mon Sep 17 00:00:00 2001
From: Martin Donadieu
Date: Thu, 13 Aug 2026 13:43:03 +0300
Subject: [PATCH 02/13] fix(backend): type device adoption reduce as number
Object.values on Record infers a union accumulator, which failed CI typecheck.
Co-authored-by: Cursor
---
supabase/functions/_backend/utils/cron_email_stats.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/supabase/functions/_backend/utils/cron_email_stats.ts b/supabase/functions/_backend/utils/cron_email_stats.ts
index b05ef16e26..1ce1c0d193 100644
--- a/supabase/functions/_backend/utils/cron_email_stats.ts
+++ b/supabase/functions/_backend/utils/cron_email_stats.ts
@@ -227,7 +227,7 @@ export function summarizeDeviceVersionAdoption(
counts: Record,
versionName?: string | null,
): DeviceVersionAdoptionSummary {
- const totalDevices = Object.values(counts).reduce((sum, value) => {
+ const totalDevices = Object.values(counts).reduce((sum, value) => {
return sum + Math.max(0, Math.round(toStatNumber(value)))
}, 0)
const deviceCount = versionName
From b37efc12839b7e0b51a9e7544dd4cc012681d607 Mon Sep 17 00:00:00 2001
From: Martin DONADIEU
Date: Thu, 13 Aug 2026 13:54:39 +0300
Subject: [PATCH 03/13] docs: add bundle reach preview for PR 3032
---
.github/pr-previews/3032-bundle-reach.webp | Bin 0 -> 29328 bytes
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 .github/pr-previews/3032-bundle-reach.webp
diff --git a/.github/pr-previews/3032-bundle-reach.webp b/.github/pr-previews/3032-bundle-reach.webp
new file mode 100644
index 0000000000000000000000000000000000000000..759aec9358f71dfd001bae3b328adcee210321b5
GIT binary patch
literal 29328
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
z
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
Date: Thu, 13 Aug 2026 13:55:32 +0300
Subject: [PATCH 04/13] fix(console): use native progress for bundle reach
Sonar flagged the fake progressbar and the extra try/catch in the 24h deploy email.
Co-authored-by: Cursor
---
.../bundle-reach.webp | Bin
artifacts/observe-reach.webp | Bin 0 -> 37952 bytes
src/components/bundle/BundleAdoptionCard.vue | 15 +++-----
.../functions/_backend/triggers/cron_email.ts | 35 +++++++++++-------
4 files changed, 27 insertions(+), 23 deletions(-)
rename .github/pr-previews/3032-bundle-reach.webp => artifacts/bundle-reach.webp (100%)
create mode 100644 artifacts/observe-reach.webp
diff --git a/.github/pr-previews/3032-bundle-reach.webp b/artifacts/bundle-reach.webp
similarity index 100%
rename from .github/pr-previews/3032-bundle-reach.webp
rename to artifacts/bundle-reach.webp
diff --git a/artifacts/observe-reach.webp b/artifacts/observe-reach.webp
new file mode 100644
index 0000000000000000000000000000000000000000..ff0023d7b0e23261c5a30e86602d7373b217df34
GIT binary patch
literal 37952
zcmcG#W0+)3v@Te-ZFbqV)n#>;ZQHhOTV1wo+tp>;MoxX-Ip^McW}bVWnI9AT&yJn3
zGb2~7^&(=YlBBpeXA%&Qs+h2XiUKDQ?e}?Y2uL3x8^c;L%_zj{Z0DictXJbYvbMgb7LL$W9G((
zrAz(|u;QyE5dT^F(etc(Pxve_=(YOk`Z@CqxYMoWkNn{H;`pGr#(WWo(;fFs_RaWu
zeC4?Fyvx1pS#agG@VyY2_RV_&dGtLdybpE6=f`0x7;TfMbB{!N7CuV^5PVknS{~6|fB8_;P&4Jhgq3|MWff
zUH{tmwETqq0DR3n`T`S9`!@Nm0${&Nx4*w=+sxKB0Py8QAmA(j06buL^c+LTPs62A
zzvLQ&{TcT8RF1BP{cC!?#JNG#_5ZPZN)`$;JD5#-;f8ZwsIBvU*`2=i@J!xB6?Q(T
zf?_-8;hCtBH0ZoX1I=;L#Wz+ZWx#!l9+df@lXI|K%!Kb2(Jz}d=rKS-7NP;HKDAxV
z1%CbYD}b(&H!f2jc_M+1>LkrNN_ob|GUQF;3_#rTqAVFZzB~nEN9l^}3vI0O_PM?6
zR85u63(O#;199!=+QBK+JUQh2kVZu6UlVcsBeH0%_Agj%YP^zwv(1!ygt(F~T}6Yx
z5WaO|OZ|5gaLVA^F)u*=~PHM;N0+G#eb!8VIX+5|SczASnq$kDYHgx!A&@
zo?7G{fRAf%t;~c&<{_Rc3K>i9V95Z?PTh=T67{HC+k)55P1i&0H^_P=wnr30v)Kde9WQ21W{)gkFec(wfa#25i1#1pS1aLs0g|
zt%hWMVw?eQxzONb+H!^;Fe-<+=2wk+5JK`%DOhEozRQ85Fd8DXx32M2WnoaRsIb&5
z*XsO~NxNM)SE;}pX&Cs!Hs4vLM{(kOs??9d*I0tg1XsaYlQ}>hk1CKq$x5x|a|GW*ym}lST3ABND6hDZz1K4K4o%&L)QKs0Gv;G%
z$Ji%w1PkvopPb9wJa)cUC$oT(u1o9mvXCTqj&v|?McRnf1UHOcVa6>Xd|Ko&N?
zi0Js|R8j3zOd`)_)=pJO2sIT+0f8oM)_b-I2X1FTW@%sa&moqg5Klj&9zm~L#df6*WXkb
zWfSe}``<89Wz*Bhi!{6bH%UXXA%|v{N$TpRLBJ%#hCK(&6%Ixl7pYXr4U({Vf)On_
zB$kcS0Vj(mdB|dG*hf6I0)dQ=DN5}L)f#jR)QNc%ei0Vs4cm%G^X~(7Qc0dL6Lw2B
z->IJB{Vlc?@%H~9ssDgy|Ax28Oj2Uos8^`+vl3`{XTJhG_cPIEBtzEZa*=AQ3-s%>knQdN=D2`?XH-0_NFgbacVAKZ}dbgq*kubX<|1WC!PMM{b
z8}omO(PoK?8H=U~Lsf4@n<^s$ly(7dbLA;VTENeT=mYV}uza!p#aMl0lwZr4hnp0;
z2|+FnYRJow3N62^{C=6Y{WtOIGxcsrVyRYexC}7D*K#2J1(!SAtm}ji>is(CJ)+T8W3qa^8i}@@uGuC_P*F#TSLKZ4GdeCuCHzVjaTjrB%N4
zXbcd9sGv5mVBf`=f&M_=6)MSYlu`;>s7cUJ8j{N#fpCDdZDWu@O~^lqAou!0qR;L~
zgWglNKzWcJvu9?r!&u%Fl-rJv8m+qXq*$LrQCji-6%JDQ8#e_3*_nIm@^!{1|%ZfNE2MX@EH#gTVhYS_CYwGp9Yj#o@E=*iV*
zHKq~b4;6BywD~WD{7-?RoH#4UGeAP7MW>0NnyFAN(B
zZ?du4Us#?BL!oE%lxMiJaIbAA>a+~U-aKX0;4k6t!VB+v6+my}4kh?<;~}s|t(2R684S^d8ho|p1f(T8ZapDKL7GxiB3IIHqjX=KdXJJoAn%Jrz;
z7G0N%eLsh`=BQ}3QYxrE-og?0!PBu
zV^UU?P4gGXi_{jZA$YzsZi}`!Ag~heBJe^G>=UIK5cD^ap?tcR{|}M#Z()XBV+&w-
z>=vu}v)Lx48EwsojK(*6S;(xvwaRsV@f8Vw6c~`SpIWL{A}zWe2t36u;98_Q(irx*
zYC-T}0vgXV+Xepw;)W*KKh1c-^z+KZF?UsUf5-nn;r%|4ynr%4O(Rap%gJsV4Kd-@
zE>gW}L^RLY>}A3ZOWxFgBoD-ExF>}Rxuhy3w0@-{%jb!vh5XVg_%GQmlpub&tT$R}
z1Zm^^_-leEWmd$!{Y0FS@YNQiz9$8seFdrAJ3*
zxC?bTor@>CoOf+3!5dsa?4o9(1gBFv8RJ2`koNmmQR^PC7nXc_RMc#q0)T(8DnhsR
z63A6)^@6FVE;I~{t*~I;3d!&O&qEGA^B4wKfY
z9Xm!>QrTYNco$ee9sKGfEi@zQ7o<6FBaD8h`DP3cj=U3@N4gB8ygJ|uO2p5>@X#@l3}$hh-vkEpLAq8~7acwZW7lv}CAK>Iu<4Qg
zxsCrxGenyCk6EDKWW68P&Dc?}5I+W2De@L?W|vr-*hUxa{aOjCt@|C$qwV>y%%uNN
zr~R_uN$Xlok^vrceHfS=z?-M@B-2q&Cqfa^Y{rVhL&W`Ko01I(55JFUr4q-z((h
zYLDg+s}Ri~N-=^-oO0-Yd#ZnoE(XF)r===vJ1#e}@H2-7f`4G&-GAbg&F`quvt>ZA
zq2t+&JJa7w~x6_$4lVA+VaCnQ;iItb3(P!=$RIfmGU!}
zT3H~DxciIejwpD*o_a5%-ZGPl-c(rM-PjfqBFB)JTfBo{kGs8lC(KW=FLyp92X*T(
zG&4}|gfV
zK1MS=3E{dxw-6{*rXZAms928UwG`=v@e
z*=ne5M=ne?Qg`7h*L{(NbG#RrwiOn$Hn5$uhG(O8$tfeSZO{=O)VWuCe@qbd$esX3
zqU3)!dROUSscG=SAa`_k;OH1J-M3EXfo+aw=o3(V9=f2JL5!;>^-86oOP1HZu?VsX+W%+5X!M_Y#y^{6s
z`~m{!fz~}WK(XxomM^Uexq}xmnSpIb)7sfOOwjsM;|5=tXx3o!W!0}V9sa2gQ8OYS
z?vY6K`)@L+pkm!EWIz420KBoQ9#K1}UFWANx|A&UEg2Dl_&8@iOJih;i)5%h;L=lJs-epzD
z0cdX72^PXM;e0E6cn!cRhidNzn(Klnm|na@WmbHRgkcLIQvB$FY#VIQ1AQVN-J8LS
zi7l)>{7HvvmH5O%{>x;l`_wd6ktJMUap9LX&^q69aW5P
z?0s)yBUS4k1ztffSzCXjChCi-gOoWte=7FW&i1a>#3bCb380{)4Zm#(6+7!FrO`=pYPg6y=;OR8g#4yb$X(?#JIaXNP1ir-J4cXR
zVAT5`<+%GqR(g*SrX%8ALHr}&>t)ID7XZTxz(UH;${F~PsZn+yQQOe&A
z*tHbICmRpS1<}8#{&~du#}nOt_;HEZy9Yy&Uu|%^&3i;Z{FVKTeA_0B&MAFIJ^V=7nG*iY}bSjy9fggn*VEUr;Dk?
z0fxuD#ztV^Kl0sPnm26XocF_v^3Gst)creUEU0i|U7zZ5dw%U(eJhyvM>+OU2K)aP
z-KZr05~EAeAnM^Y@GBM=CW4L+AfPXRF8=E^TdEqnFTAN%cub5RKfq#;msLE98>DJk
z8%dMn{R5nPrvl3rv5xp9+
zgqJcBg4$#8{3&Ulzmd_EienAC0>7GrpEjMHub1deX*^B5z>}w$+(XeQ$7#o3U1FZr
z$j5uAjfbP^E9jIPfDfzbxjPuu+NDPJop1k<|S{7WsE%Vktp)&4N`dpYod_B
zqqN`<*|^b^xJH)Zh%J)e(yKFTlgH9@Dy=|)Bm$Wc8_u>yJEhayGe)+DMT_^z>BM!t
z%w0S$*-$3nOdrshw1|b{l!c()(l$yY%C%1i_TxMipN1FPTtpyO$jkx5ye3@$f3v*;
zOTh9o-Cr7It
z_WVe!jg4^0%8vg;%ykDl&qlrthx$^dMG!`$yhAno;<@EWmg-J->+0vuIU?}oPt3bQ
zQ%)}PHUKNkev-wDhkuM1%bnzK_bXZ3na!M{0Ghz47aqJ43IYhW-Dn#(#YuvtBEC!678B2z!w_E`UtXCFZ`RtIp9=QWrGk)+7w#_xc%C@vXHQNj)s>
zF!X9$XT*HL^rn}!wqqMwCGwzYdz;qR978VMa+sWMB>IeoAV3%G>a>g{+1la$XV6}u
z8LdED)MzYoem#<-_jz)c&gnplPTtIl@U*kcm4frx(Ab+*DkoYtBbv-xyPEU*fqDU<8hX=~Xbc<35g%>wWeIz+0peIY1+(a3Ij
zKkCn(8#Pw=_cH>HeDj=+`tnBa);bKVl+N3dcKg@Ax?dbaSGVpuT;~Ed(yAz}y+NHo
z%cm44M5hM8E(N2Mh9||;aH9x|5Yo9XCVc|9e>;$x-(s#_+Pwh+J328NdD!l7@uOp3
z^_;!?Qk8ct2&XV^V)c^K7TS7W;8i0@W5|OEh83)i*ErH^uOJ_;exH;WIh#ErP&0Tq
zIJwLXs9}n=j||~QZjU}wwfey{IlZ8ku?8oQcR$+3fe*JYm@>*b-a^7HshHKL3;tTM
zeFJ#}u{fS_Ox$*e)6uH36gA#GhQ)+z(!JOGa{aqrUbvx90{x!G`ZX1|CNz3SfTP$@
z$tbxDFo{62D#CxPhz}@@uV2JKt|0Dg{{=d2s#YGEc8L|r6T7Uy;-g^ac-CQ*ZPyk4
z3!0?&8!=-%xJp0Sd6$jqA^h!65#_qJWhPmw8oHXEu1
ziA}aGWCb-J7=I;PjJ^&Pnnq%_dMBqt49@O#@mV4Ej=z>@hnk@A
z&sy236`SnA#3DV2WAKl^rId{0?~x?5cL!^twHe{feXPMv4OT`q17FCf-^IWJmENrw
zAEFdRl@xPCD=bi5R@XS$4coPgf+cmbw6sz2{BUfj`%4qM+fyxXfVXK0TiuAJnE^rA
zKQ@qj=d7Axpy4IG32(2_{LH#E!pmYfw{Yb*_8a2FUcKN3`A1XmC{G7Gg7kxjMaG@D
zZV1rq-%$E9(LeT9TnsO(;$IMldV+Z>tR&60Tc*f--o_+u1uYJrQhm3_5WS^Fo+-Y-
zc6y+0qwUeVBV&INq&quqV}Eiy6PmNR{iXu;XJF4ALrC=*XA@`E2JQc$N(SI)1dnhL
zp-vS(kdqB$6($P}^GrEhp)zCr42vA`4>tJk)6hXZ
zypS&tkExccwhBpMSo7M9gKQxs@(9N|0uM95T!~Df=BC9#9YtX9z+Abs59k0Lliz+o
zf`dxn7blvfjP7;q+Rrj`sYEky4(y=KqaU@fV8_!#P<-)u5z2I_qVhWx-Xl`SiWD59
zhCz;_+R#r0?v65N`(Xwgfv5mu%$o
zRJieNMBnxEKbtWXeyYD9hXz=7iY$-Bze7(y{g1TE?`K#AL2+Fmt$#h;dzUdu|(yF
zZtvQvQX)&2m*H6lbsljvF)ms+BC)O`3m#3+w+5bVgQhHr7}Y-moT5Wb(ZfnT&KBKA
zbyG-sQ!gnXVY62TkzZ*@tgJ9=r?lu}>j#@7;IN3D%g-}Bb}4^kJ5Z1{-4Jw4A)O+=
zjX27YF`}aglL*pl1Wxh>wqtrHdaV#=UM?Q7Y0)LLwXqBIH`xe9g#%$~JOam!iXme>
zK|P8f+kx&Swz*?+i@|{f^>@k_#j9zU^NVgm{N=>@Tg^RO;>sFMVEZ)b`rH{<6vaZ0
zE4~IVaC!6VFG<#eJp0@Sw`J_x%hQz(;q~ie=>n3B=1A~RnYVQR1-kG3g>uMhpVP`a
z;sQ#|w~i-FDo%w96lvIrkEDF1)Jc29BYbj
z-ogV;UJ3j7b-B^PB%lv+I~>|yYibWRWADdv=8;SBq!a9H!aCtR`m`GmPjny@=Co_B
zoxw13YHZZr?Nm&Zg`VV}#M$bjd{?-Bdn1gNQ2k;O(m$0!B`g|-A+AQt9GV~fnpfC=
zBbfVvwUs)=W%$ch!gzK&=Hs%`CHVYCg`EVMi
z&^{lLA%>>52F)CPi!q}cyDKed<)W$IiX+LIKd;@E;pGc9A*>Gzn|425LVuD?hi`Qq
zm8hR_hMUcqSMQoS#BlP6Sb=jiaAXd12qzbE|Axe7A))l5Tz?(ckpQQfC+w`>R*8C$y
z?Z&V+hGDLXFn6tD0irpaQc#aolLY~3JV52#`4MM9pjo9UkuE{dkDh=TR0Z01-Ek41
zFi6lq#YaT`9?@-e-&P3+n@6Tycdf{E^ZJ`3(8h`v7`O#vNPeL~{q>&0>Rg$}UZ>9R
zY*C64=P90219!nQC;@bWYj#Se$Q)^(hrXg^EBc_4($;7W~NIorRX~5iBw!Q>y^L=ku1_SNMJWv
z+fg~Yp|ZY-Fu2W{NG07&J*}Hm)Y5?N%QI3H`{$Omo#9(TS$>%?6wA;dB_9Oe6XOz6QMP}iUNl&cqgErS?Xc4;#
z;P`J^6yyhxvSQ+r%rs5Z?PEHLd<-Lqd$7}r&8a62wU&u8;o-XyQ|e8mKck`5q&iPM
zS=2qNN)=&98kt9iC8>ok-jyhDp6Nll
z>8UxO>3LWk^Ru}(-xl`TMg|U>p>i&*Bbtnv9Z}I9=_TF3fmsxU8#5WcixMfCZlgEW
zGttZqP`eNbT95~^U)q9)f^uedTEP>Q1a$#ln_J>fMiMVN1MeMmzTCU=zNIfQ$T2`z
znYj+fnpY+2^tjvg0{+CycLMT|`fAn9Vafr8xkm1?6tu?wbQct4>&6wxC@uIkLnt$W
z`wlJ`Ow9GS^`vhKB8tieXL?A5;8i@V1m(8EU3>dfE6MRM&`FCJkp_a#hpY%RkA^u6
zj8IBQR{$t+j>3tPf?iFGE*aLwdpXWUjFU(5s)t1??*iJ38bTHIvOU{-Pj>j!(I2Z
zvzwGay?fs!`+Al>qQ9eRJqT=4;p9}maMpHMqY|ZwtwlTW1b_M#rc~+zGbEQo#*(Mo
zV#~o^UHTs{9I=>AunWP_AkGx}YIaBUjJ(ii*r{tv3MRIyYfL95lhC4ZS~&Y>ak+W*
zrevi~l)xQS($Lu?$vKm4<+UB^rNOR-;;|bVHvjH9Mv5M0CueHLxf;fIPPh|Vw6MA$
zN4V*AjH;1Wg2U7vF+nJ(Yqitpvqdw|em%1F^FJbWUtvD+KDM2VdqFJIfr@d|x%9qD
zdnNR1t>>pWWOtRU#hIKJK-`f>7ysT^Gn?%(WMi?#Bm8x$ehg*%Ht!p5SshFFdr%b<
z>(HudN_em29q}4kAWF6E{zqR0bKq;Yt;43p&pQT2sEN-p@usg8
z>O+{)`BRR?CL8xYAtVHX#YPIAIw%_ItKuU99N63f3e28zn4BSA
zd8^bjh$16(fH&G<#3BgDrR=bm=xz^}RU1o=l5kn1BbCNi?nvr2{5|UW=Fyj0GYCpe
zb*7Fo37}!OQkQ?B51~r(o8j@9^*`;@0#%x(#L!G7pe|6}Ji)Btk?Ep}k@@yt5!~*=
zyoS2NyWahsJ?#st$94hKqIY-rxIDpo>T=wCvK;(S=rmKljZg+B5~!GeN|h={&WU);
zm-Wy3tQKu%FA~#?|9TA`1$&^eXB=m22&xXqn^i=O5?+PM8h+AAW&V;EQ!7DBohU#<
z2HL%t6p&9_is6%b{d3+P{p)IO$zu1~=xQE*G9;CGU#c*Gf|to;jqR^lO<}CI&bVt}
z7_^DUUS3|kE2_WjCSuy_3PkKVG7vRYm(M&g6EWAP|M{+Mim(9j*ljye9VI`Ss=i>Y
zyrkC=+)*AsNPQGW;DJCjv&*BxfLXU*x-GLf*7d=r?wE*7U@G96l}cU@K`JR7a=@bp
zA(hKNE4FMy)Qn`j3Q+~CuB01;X(*yPvdP~GvA0Yc7r?(gz4|#Xyu6tCtCoP3jK?u1$q-B
z8j?bVNQD}NBmq8tHgDMf*7<
znUb76kZyDp0qb->0QO%Gw&y~EQ0baPpJ)0d0pXK@2>yX2J(Don-QN|-y%z-Y^O`%=
z2cmn-EC!Qbk8smIqJ<34WH|FfI(6p`-uE2trJ@?hKYNs>W!qCIpC@gE2tScgBwcwb
zkp%`E+BH75g|W2Qa<(FxirUqv3eY-G%S-45l+z
zuHWYw-hW6}^I^g#2*2t9&k!@k4AQ*!o@1qHJM&i8bRPR;?A`G@*j1!OAvNhB-bn<9
zE9l1oDzi6OqnPqjDUKz{W}gkl#*p26T;i8`-L0v~F<@dc0w0#ceem_7)X>@%52~3l
zBgwJnjZdlMfR5&?tN
zVd?`;5^HaviwgLRPG>CKm@LmBBaYdOCN^zWgp>Gm8w