Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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 added artifacts/bundle-reach.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added artifacts/observe-reach.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 10 additions & 2 deletions messages/en.context.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down
10 changes: 9 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,10 @@
"builds-trend": "Native Builds Trend (Total, iOS, Android)",
Comment thread
riderx marked this conversation as resolved.
Resolved
"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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions playwright/e2e/bundle-adoption.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
Comment thread
riderx marked this conversation as resolved.
Resolved
})
10 changes: 10 additions & 0 deletions playwright/visual-diff.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
166 changes: 166 additions & 0 deletions src/components/bundle/BundleAdoptionCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import IconSmartphone from '~icons/lucide/smartphone'
import IconTrendingUp from '~icons/lucide/trending-up'
import { getLatestDayVersionAdoption } from '~/services/bundleAdoption'
import { useChartData } from '~/services/chartDataService'
import { getChartDateRange } from '~/services/date'
import { formatNumberValue } from '~/services/formatLocale'
import { useSupabase } from '~/services/supabase'

const props = withDefaults(defineProps<{
appId: string
versionName: string
linkedChannelId?: number | null
linkedChannelName?: string | null
compact?: boolean
}>(), {
linkedChannelId: null,
linkedChannelName: null,
compact: false,
})

const { t } = useI18n()
const router = useRouter()
const supabase = useSupabase()

const loading = ref(true)
const adoption = ref<ReturnType<typeof getLatestDayVersionAdoption>>(null)
let requestToken = 0

const percentLabel = computed(() => {
const percent = adoption.value?.percent ?? 0
return `${formatNumberValue(percent, { minimumFractionDigits: 1, maximumFractionDigits: 1 })}%`
})

const countLabel = computed(() => formatNumberValue(adoption.value?.count ?? 0))
const totalLabel = computed(() => formatNumberValue(adoption.value?.total ?? 0))
const hasDevices = computed(() => (adoption.value?.total ?? 0) > 0)

async function loadAdoption() {
if (!props.appId || !props.versionName) {
adoption.value = null
loading.value = false
return
}

const currentToken = ++requestToken
loading.value = true
try {
const { startDate, endDate } = getChartDateRange(false)
const data = await useChartData(supabase, props.appId, startDate, endDate, 'bundle')
if (currentToken !== requestToken)
return
adoption.value = getLatestDayVersionAdoption(data?.datasets ?? [], props.versionName)
}
catch (error) {
console.error('[BundleAdoptionCard] Failed to load adoption', error)
if (currentToken !== requestToken)
return
adoption.value = {
Comment thread
riderx marked this conversation as resolved.
Resolved
versionName: props.versionName,
count: 0,
total: 0,
percent: 0,
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
finally {
if (currentToken === requestToken)
loading.value = false
}
}

function openDevices() {
router.push({
path: `/app/${props.appId}/devices`,
query: { version: props.versionName },
})
}

function openChannelStats() {
if (!props.linkedChannelId)
return
router.push(`/app/${props.appId}/channel/${props.linkedChannelId}/statistics`)
}

watch(() => [props.appId, props.versionName] as const, () => {
void loadAdoption()
}, { immediate: true })
</script>

<template>
<section
data-test="bundle-adoption-card"
class="p-4 border rounded-lg shadow-sm"
:class="hasDevices
? 'bg-sky-50 border-sky-200 dark:bg-sky-950/20 dark:border-sky-800'
: 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700'"
>
<div class="flex flex-col gap-4" :class="{ 'lg:flex-row lg:items-center lg:justify-between': compact }">
<div class="flex items-start gap-3 min-w-0">
<IconTrendingUp class="w-6 h-6 mt-0.5 shrink-0 text-sky-600 dark:text-sky-300" />
<div class="min-w-0">
<h3 class="font-semibold text-slate-900 dark:text-white">
{{ t('bundle-adoption') }}
</h3>
<p class="mt-1 text-sm text-slate-600 dark:text-slate-300">
<template v-if="loading">
{{ t('loading-statistics') }}
</template>
<template v-else-if="hasDevices">
{{ t('bundle-adoption-help', {
version: versionName,
count: countLabel,
total: totalLabel,
percent: percentLabel,
}) }}
</template>
<template v-else>
{{ t('bundle-adoption-empty', { version: versionName }) }}
</template>
</p>
</div>
</div>

<div v-if="!loading" class="flex flex-col gap-3 min-w-0" :class="{ 'lg:w-80': compact }">
<div class="flex items-baseline gap-2">
<span class="text-2xl font-semibold tabular-nums text-slate-900 dark:text-white">
{{ percentLabel }}
</span>
<span class="text-sm text-slate-600 dark:text-slate-300">
{{ t('bundle-adoption-devices', { count: countLabel, total: totalLabel }) }}
</span>
</div>
<progress
class="w-full h-2 d-progress d-progress-info"
:value="adoption?.percent ?? 0"
max="100"
:aria-label="t('bundle-adoption')"
/>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="gap-2 d-btn d-btn-sm d-btn-outline"
data-test="bundle-adoption-devices"
@click="openDevices"
>
<IconSmartphone class="w-4 h-4" />
{{ t('view-bundle-devices') }}
</button>
<button
v-if="linkedChannelId"
type="button"
class="gap-2 d-btn d-btn-sm d-btn-primary"
data-test="bundle-adoption-channel"
@click="openChannelStats"
>
<IconTrendingUp class="w-4 h-4" />
{{ linkedChannelName ? t('view-channel-adoption-named', { channel: linkedChannelName }) : t('view-channel-adoption') }}
</button>
</div>
</div>
</div>
</section>
</template>
26 changes: 25 additions & 1 deletion src/components/dashboard/ReleaseBanner.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -21,6 +24,7 @@ const isLoading = ref(false)
const lastVersion = ref<string>('')
const lastReleaseDate = ref<string | null>(null)
const defaultChannelId = ref<number | null>(null)
const adoptionPercent = ref<number | null>(null)

const HOURS_48_IN_DAYS = 2

Expand All @@ -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
Expand All @@ -53,6 +63,7 @@ async function fetchReleaseInfo() {
lastVersion.value = ''
lastReleaseDate.value = null
defaultChannelId.value = null
adoptionPercent.value = null
return
}

Expand All @@ -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
Comment thread
riderx marked this conversation as resolved.
Resolved
Comment thread
riderx marked this conversation as resolved.
Resolved
Comment thread
riderx marked this conversation as resolved.
Resolved
}
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
Expand Down Expand Up @@ -128,6 +149,9 @@ watch(() => [props.appId, organizationStore.currentOrganization?.gid], () => {
</p>
<p class="text-sm text-emerald-700 dark:text-emerald-300">
{{ t('version') }} {{ lastVersion }} — {{ t('released') }} {{ lastReleaseDisplay }}
<template v-if="adoptionPercentLabel">
· {{ t('release-banner-adoption', { percent: adoptionPercentLabel }) }}
</template>
</p>
</div>
</div>
Expand Down
Loading
Loading