diff --git a/supabase/functions/_backend/private/channel_stats.ts b/supabase/functions/_backend/private/channel_stats.ts index c2a417abb6..f677802331 100644 --- a/supabase/functions/_backend/private/channel_stats.ts +++ b/supabase/functions/_backend/private/channel_stats.ts @@ -7,8 +7,9 @@ import { middlewareAuth } from '../utils/hono_jwt.ts' import { cloudlog } from '../utils/logging.ts' import { checkPermission } from '../utils/rbac.ts' import { readDeviceVersionCounts, readStatsVersion } from '../utils/stats.ts' +import { generateDateLabels } from '../utils/stats_date_range.ts' import { supabaseWithAuth } from '../utils/supabase.ts' -import { buildDailyReportedCountsByName, convertCountsToPercentagesByName, fillMissingDailyCounts } from '../utils/version_stats_helpers.ts' +import { buildDailyReportedCountsByName, convertCountsToPercentagesByName, createPercentageDatasetsByName, fillMissingDailyCounts } from '../utils/version_stats_helpers.ts' dayjs.extend(utc) @@ -70,22 +71,6 @@ function getStatsPeriod(requestedDays: StatsPeriodDays, endDate: Date, currentVe } } -function generateDateLabels(from: Date, to: Date) { - const start = dayjs(from).utc().startOf('day') - const end = dayjs(to).utc().startOf('day') - - if (start.isAfter(end)) - return [] as string[] - - const labels: string[] = [] - let cursor = start - while (cursor.isBefore(end) || cursor.isSame(end)) { - labels.push(cursor.format('YYYY-MM-DD')) - cursor = cursor.add(1, 'day') - } - - return labels -} function trimTrailingEmptyLabels(labels: string[], countsByDate: Record>) { if (labels.length <= 1) @@ -101,23 +86,6 @@ function trimTrailingEmptyLabels(labels: string[], countsByDate: Record { - const percentageData = dates.map(date => percentagesByDate[date]?.[version] ?? 0) - const countData = dates.map(date => Math.round(countsByDate[date]?.[version] ?? 0)) - - return { - label: version, - data: percentageData, - metaCounts: countData, - } - }) -} function selectRecentChannelVersions( deploymentHistory: DeploymentHistoryEntry[], diff --git a/supabase/functions/_backend/private/native_observe_stats.ts b/supabase/functions/_backend/private/native_observe_stats.ts index 5605c621ec..7cb2757d71 100644 --- a/supabase/functions/_backend/private/native_observe_stats.ts +++ b/supabase/functions/_backend/private/native_observe_stats.ts @@ -11,6 +11,7 @@ import { middlewareAuth } from '../utils/hono_jwt.ts' import { cloudlog } from '../utils/logging.ts' import { closeClient, getPgClient, logPgError } from '../utils/pg.ts' import { checkPermission } from '../utils/rbac.ts' +import { generateDateLabels } from '../utils/stats_date_range.ts' dayjs.extend(utc) @@ -247,21 +248,6 @@ function normalizeNativeObserveView(value: unknown): NativeObserveView | null { return 'plugins' return null } -function generateDateLabels(from: Date, to: Date) { - const start = dayjs(from).utc().startOf('day') - const end = dayjs(to).utc().startOf('day') - if (start.isAfter(end)) - return [] as string[] - - const labels: string[] = [] - let cursor = start - while (cursor.isBefore(end) || cursor.isSame(end)) { - labels.push(cursor.format('YYYY-MM-DD')) - cursor = cursor.add(1, 'day') - } - - return labels -} function toCount(value: NativeObserveNumericValue) { const numeric = Number(value ?? 0) diff --git a/supabase/functions/_backend/private/update_delivery_stats.ts b/supabase/functions/_backend/private/update_delivery_stats.ts index a068226ab0..7ff7412826 100644 --- a/supabase/functions/_backend/private/update_delivery_stats.ts +++ b/supabase/functions/_backend/private/update_delivery_stats.ts @@ -12,6 +12,7 @@ import { middlewareAuth } from '../utils/hono_jwt.ts' import { cloudlog, cloudlogErr, serializeError } from '../utils/logging.ts' import { closeClient, getPgClient, logPgError } from '../utils/pg.ts' import { checkPermission } from '../utils/rbac.ts' +import { generateDateLabels } from '../utils/stats_date_range.ts' import { supabaseAdmin, supabaseClient as useSupabaseClient } from '../utils/supabase.ts' dayjs.extend(utc) @@ -225,20 +226,6 @@ function normalizeScope(value: unknown): UpdateDeliveryScope | null { return null } -function generateDateLabels(from: Date, to: Date) { - const start = dayjs(from).utc().startOf('day') - const end = dayjs(to).utc().startOf('day') - if (start.isAfter(end)) - return [] as string[] - - const labels: string[] = [] - let cursor = start - while (cursor.isBefore(end) || cursor.isSame(end)) { - labels.push(cursor.format('YYYY-MM-DD')) - cursor = cursor.add(1, 'day') - } - return labels -} function toCount(value: NumericValue) { const numeric = Number(value ?? 0) diff --git a/supabase/functions/_backend/public/bundle/set_channel.ts b/supabase/functions/_backend/public/bundle/set_channel.ts index 0cb38bc5df..561a9e3a2e 100644 --- a/supabase/functions/_backend/public/bundle/set_channel.ts +++ b/supabase/functions/_backend/public/bundle/set_channel.ts @@ -2,6 +2,7 @@ import type { Context } from 'hono' import type { MiddlewareKeyVariables } from '../../utils/hono.ts' import type { Database } from '../../utils/supabase.types.ts' import { HTTPException } from 'hono/http-exception' +import { requireEffectiveApikey } from '../../utils/effective_apikey.ts' import { simpleError } from '../../utils/hono.ts' import { closeClient, getDrizzleClient, getPgClient, logPgError } from '../../utils/pg.ts' import { checkPermissionPg } from '../../utils/rbac.ts' @@ -27,6 +28,20 @@ export interface SetChannelResult { type DrizzleClient = ReturnType + +function requireSetChannelApikey( + c: Context, + apikey: Database['public']['Tables']['apikeys']['Row'], +) { + return requireEffectiveApikey( + c, + apikey, + 'cannot_set_bundle_to_channel', + 'Cannot set bundle to channel', + { error: 'Missing API key context for audit logging' }, + ) +} + function validateSetChannelBody(body: SetChannelBody) { if (!body.app_id || !body.version_id || !body.channel_id) { throw simpleError('missing_required_fields', 'Missing required fields', { app_id: body.app_id, version_id: body.version_id, channel_id: body.channel_id }) @@ -37,14 +52,6 @@ function validateSetChannelBody(body: SetChannelBody) { } } -function getEffectiveApikey(c: Context, apikey: Database['public']['Tables']['apikeys']['Row']) { - const effectiveApikey = apikey.key ?? c.get('capgkey') - if (!effectiveApikey) { - throw simpleError('cannot_set_bundle_to_channel', 'Cannot set bundle to channel', { error: 'Missing API key context for audit logging' }) - } - return effectiveApikey -} - async function fetchTargetChannel(dbClient: PgQueryClient, body: SetChannelBody) { const channelResult = await dbClient.query( `SELECT name, owner_org @@ -104,7 +111,7 @@ export async function assertCanPromoteChannelInTransaction( checkAppScope ? { appId: body.app_id } : { appId: body.app_id, channelId: body.channel_id }, drizzle, apikey.user_id, - getEffectiveApikey(c, apikey), + requireSetChannelApikey(c, apikey), ) if (!canPromote) { @@ -137,7 +144,7 @@ export async function setChannelInTransaction( await dbClient.query( 'SELECT set_config(\'request.headers\', $1, true)', - [JSON.stringify({ capgkey: getEffectiveApikey(c, apikey) })], + [JSON.stringify({ capgkey: requireSetChannelApikey(c, apikey) })], ) await updateChannelVersion(dbClient, body, channel.owner_org) return { channelName: channel.name, versionName } diff --git a/supabase/functions/_backend/public/channel/delete.ts b/supabase/functions/_backend/public/channel/delete.ts index 43aae9f365..70db083995 100644 --- a/supabase/functions/_backend/public/channel/delete.ts +++ b/supabase/functions/_backend/public/channel/delete.ts @@ -2,6 +2,7 @@ import type { Context } from 'hono' import type { MiddlewareKeyVariables } from '../../utils/hono.ts' import type { Database } from '../../utils/supabase.types.ts' import { HTTPException } from 'hono/http-exception' +import { requireEffectiveApikey } from '../../utils/effective_apikey.ts' import { BRES, simpleError } from '../../utils/hono.ts' import { closeClient, getPgClient, logPgError } from '../../utils/pg.ts' import { checkPermission } from '../../utils/rbac.ts' @@ -48,14 +49,6 @@ interface OwnerOrgRow { owner_org: string } -function getEffectiveApikey(c: Context, apikey: Database['public']['Tables']['apikeys']['Row']) { - const effectiveApikey = apikey.key ?? c.get('capgkey') - if (!effectiveApikey) { - throw simpleError('cannot_access_app', 'You can\'t access this app') - } - return effectiveApikey -} - async function loadPreviewChannelForUpdate(dbClient: PgQueryClient, body: ChannelSet) { const result = await dbClient.query( `SELECT id, app_id, owner_org, rbac_id, version, rollout_version @@ -168,7 +161,7 @@ async function deletePreviewChannelAndBundle( body: ChannelSet, apikey: Database['public']['Tables']['apikeys']['Row'], ) { - const effectiveApikey = getEffectiveApikey(c, apikey) + const effectiveApikey = requireEffectiveApikey(c, apikey, 'cannot_access_app', 'You can\'t access this app') const pgClient = getPgClient(c) let dbClient: PgQueryClient | null = null let transactionStarted = false diff --git a/supabase/functions/_backend/public/channel/get.ts b/supabase/functions/_backend/public/channel/get.ts index b49cc408a6..54706ca06d 100644 --- a/supabase/functions/_backend/public/channel/get.ts +++ b/supabase/functions/_backend/public/channel/get.ts @@ -12,6 +12,68 @@ interface GetDevice { page?: number } +function mapChannelRowToPublic(row: T) { + const { + disable_auto_update_under_native, + disable_auto_update, + rollout_percentage_bps, + rollout_enabled, + rollout_paused_at, + rollout_pause_reason, + rollout_cache_ttl_seconds, + auto_pause_enabled, + auto_pause_window_minutes, + auto_pause_failure_rate_bps, + auto_pause_confidence, + auto_pause_min_attempts, + auto_pause_min_failures, + auto_pause_action, + auto_pause_cooldown_minutes, + auto_pause_last_triggered_at, + auto_pause_last_checked_at, + ...rest + } = row + + return { + ...rest, + disableAutoUpdateUnderNative: disable_auto_update_under_native, + disableAutoUpdate: disable_auto_update, + rolloutPercentageBps: rollout_percentage_bps, + rolloutEnabled: rollout_enabled, + rolloutPausedAt: rollout_paused_at, + rolloutPauseReason: rollout_pause_reason, + rolloutCacheTtlSeconds: rollout_cache_ttl_seconds, + autoPauseEnabled: auto_pause_enabled, + autoPauseWindowMinutes: auto_pause_window_minutes, + autoPauseFailureRateBps: auto_pause_failure_rate_bps, + autoPauseConfidence: auto_pause_confidence, + autoPauseMinAttempts: auto_pause_min_attempts, + autoPauseMinFailures: auto_pause_min_failures, + autoPauseAction: auto_pause_action, + autoPauseCooldownMinutes: auto_pause_cooldown_minutes, + autoPauseLastTriggeredAt: auto_pause_last_triggered_at, + autoPauseLastCheckedAt: auto_pause_last_checked_at, + } +} + async function getAll(c: Context, body: GetDevice, apikey: Database['public']['Tables']['apikeys']['Row']) { const fetchOffset = body.page ?? 0 const from = fetchOffset * fetchLimit @@ -66,29 +128,7 @@ async function getAll(c: Context, body: GetDevice, apikey: Database['public']['T if (dbError || !dataChannels) { throw simpleError('cannot_find_channels', 'Cannot find channels', { supabaseError: dbError }) } - return c.json(dataChannels.map((o) => { - const { disable_auto_update_under_native, disable_auto_update, rollout_percentage_bps, rollout_enabled, rollout_paused_at, rollout_pause_reason, rollout_cache_ttl_seconds, auto_pause_enabled, auto_pause_window_minutes, auto_pause_failure_rate_bps, auto_pause_confidence, auto_pause_min_attempts, auto_pause_min_failures, auto_pause_action, auto_pause_cooldown_minutes, auto_pause_last_triggered_at, auto_pause_last_checked_at, ...rest } = o - return { - ...rest, - disableAutoUpdateUnderNative: disable_auto_update_under_native, - disableAutoUpdate: disable_auto_update, - rolloutPercentageBps: rollout_percentage_bps, - rolloutEnabled: rollout_enabled, - rolloutPausedAt: rollout_paused_at, - rolloutPauseReason: rollout_pause_reason, - rolloutCacheTtlSeconds: rollout_cache_ttl_seconds, - autoPauseEnabled: auto_pause_enabled, - autoPauseWindowMinutes: auto_pause_window_minutes, - autoPauseFailureRateBps: auto_pause_failure_rate_bps, - autoPauseConfidence: auto_pause_confidence, - autoPauseMinAttempts: auto_pause_min_attempts, - autoPauseMinFailures: auto_pause_min_failures, - autoPauseAction: auto_pause_action, - autoPauseCooldownMinutes: auto_pause_cooldown_minutes, - autoPauseLastTriggeredAt: auto_pause_last_triggered_at, - autoPauseLastCheckedAt: auto_pause_last_checked_at, - } - })) + return c.json(dataChannels.map(mapChannelRowToPublic)) } async function getOne(c: Context, body: GetDevice, apikey: Database['public']['Tables']['apikeys']['Row']) { @@ -144,29 +184,7 @@ async function getOne(c: Context, body: GetDevice, apikey: Database['public']['T throw simpleError('cannot_find_version', 'Cannot find version', { supabaseError: dbError }) } - const { disable_auto_update_under_native, disable_auto_update, rollout_percentage_bps, rollout_enabled, rollout_paused_at, rollout_pause_reason, rollout_cache_ttl_seconds, auto_pause_enabled, auto_pause_window_minutes, auto_pause_failure_rate_bps, auto_pause_confidence, auto_pause_min_attempts, auto_pause_min_failures, auto_pause_action, auto_pause_cooldown_minutes, auto_pause_last_triggered_at, auto_pause_last_checked_at, ...rest } = dataChannel - const newObject = { - ...rest, - disableAutoUpdateUnderNative: disable_auto_update_under_native, - disableAutoUpdate: disable_auto_update, - rolloutPercentageBps: rollout_percentage_bps, - rolloutEnabled: rollout_enabled, - rolloutPausedAt: rollout_paused_at, - rolloutPauseReason: rollout_pause_reason, - rolloutCacheTtlSeconds: rollout_cache_ttl_seconds, - autoPauseEnabled: auto_pause_enabled, - autoPauseWindowMinutes: auto_pause_window_minutes, - autoPauseFailureRateBps: auto_pause_failure_rate_bps, - autoPauseConfidence: auto_pause_confidence, - autoPauseMinAttempts: auto_pause_min_attempts, - autoPauseMinFailures: auto_pause_min_failures, - autoPauseAction: auto_pause_action, - autoPauseCooldownMinutes: auto_pause_cooldown_minutes, - autoPauseLastTriggeredAt: auto_pause_last_triggered_at, - autoPauseLastCheckedAt: auto_pause_last_checked_at, - } - - return c.json(newObject) + return c.json(mapChannelRowToPublic(dataChannel)) } export async function get(c: Context, body: GetDevice, apikey: Database['public']['Tables']['apikeys']['Row']): Promise { diff --git a/supabase/functions/_backend/public/channel/post.ts b/supabase/functions/_backend/public/channel/post.ts index 9cb56294a5..8cd81e930c 100644 --- a/supabase/functions/_backend/public/channel/post.ts +++ b/supabase/functions/_backend/public/channel/post.ts @@ -2,6 +2,7 @@ import type { Context } from 'hono' import type { MiddlewareKeyVariables } from '../../utils/hono.ts' import type { Database } from '../../utils/supabase.types.ts' import { HTTPException } from 'hono/http-exception' +import { requireEffectiveApikey } from '../../utils/effective_apikey.ts' import { BRES, simpleError } from '../../utils/hono.ts' import { cloudlogErr } from '../../utils/logging.ts' import { closeClient, getDrizzleClient, getPgClient, logPgError } from '../../utils/pg.ts' @@ -259,10 +260,13 @@ async function createAndPromoteChannelInTransaction( versionName: string, apikey: Database['public']['Tables']['apikeys']['Row'], ): Promise { - const effectiveApikey = apikey.key ?? c.get('capgkey') - if (!effectiveApikey) { - throw simpleError('cannot_set_bundle_to_channel', 'Cannot set bundle to channel', { error: 'Missing API key context for audit logging' }) - } + const effectiveApikey = requireEffectiveApikey( + c, + apikey, + 'cannot_set_bundle_to_channel', + 'Cannot set bundle to channel', + { error: 'Missing API key context for audit logging' }, + ) const pgClient = getPgClient(c) let dbClient: PgQueryClient | null = null diff --git a/supabase/functions/_backend/public/device/post.ts b/supabase/functions/_backend/public/device/post.ts index b04ccc471b..ce9a7dbd40 100644 --- a/supabase/functions/_backend/public/device/post.ts +++ b/supabase/functions/_backend/public/device/post.ts @@ -3,6 +3,7 @@ import type { MiddlewareKeyVariables } from '../../utils/hono.ts' import type { Database } from '../../utils/supabase.types.ts' import type { DeviceLink } from './delete.ts' import { syncLegacyChannelSelfOverrideForDevice } from '../../utils/channelSelfStore.ts' +import { getEffectiveApikey } from '../../utils/effective_apikey.ts' import { BRES, quickError, simpleError } from '../../utils/hono.ts' import { checkPermission } from '../../utils/rbac.ts' import { supabaseApikey, updateOrCreateChannelDevice } from '../../utils/supabase.ts' @@ -21,7 +22,7 @@ export async function post(c: Context, body throw simpleError('invalid_version_id', 'Cannot set version to device, use channel instead') } - const effectiveApikey = apikey.key ?? (c.get('capgkey') as string) + const effectiveApikey = getEffectiveApikey(c, apikey) as string const supabase = supabaseApikey(c, effectiveApikey) // if channel set channel_override to it diff --git a/supabase/functions/_backend/public/statistics/index.ts b/supabase/functions/_backend/public/statistics/index.ts index 45f1e06a14..3a1b5a78d3 100644 --- a/supabase/functions/_backend/public/statistics/index.ts +++ b/supabase/functions/_backend/public/statistics/index.ts @@ -10,9 +10,10 @@ import { cloudlog } from '../../utils/logging.ts' import { checkPermission } from '../../utils/rbac.ts' import { getRetryablePostgrestStatus, isRetryablePostgrestError, isRetryablePostgrestResult, retryWithBackoff } from '../../utils/retry.ts' import { readNativeVersionUsage } from '../../utils/stats.ts' +import { generateDateLabels } from '../../utils/stats_date_range.ts' import { supabaseApikey, supabaseClient } from '../../utils/supabase.ts' import { isStripeConfigured } from '../../utils/utils.ts' -import { buildDailyReportedCountsByName, convertCountsToPercentagesByName, fillMissingDailyCounts } from '../../utils/version_stats_helpers.ts' +import { buildDailyReportedCountsByName, convertCountsToPercentagesByName, createPercentageDatasetsByName, fillMissingDailyCounts } from '../../utils/version_stats_helpers.ts' dayjs.extend(utc) @@ -688,7 +689,7 @@ async function getBundleUsage(appId: string, from: Date, to: Date, shouldGetLate const filledCounts = fillMissingDailyCounts(dailyCounts, dates, versions) const dailyPercentages = convertCountsToPercentagesByName(filledCounts, dates, versions) const activeVersions = getActiveVersionsByName(versions, filledCounts) - const datasets = createDatasetsByName(activeVersions, dates, dailyPercentages, filledCounts) + const datasets = createPercentageDatasetsByName(activeVersions, dates, dailyPercentages, filledCounts) if (shouldGetLatestVersion) { const latestVersion = getLatestDayVersionShare(activeVersions, dates, filledCounts) @@ -787,7 +788,7 @@ async function getNativeVersionUsage(c: Context, appId: string, from: Date, to: const dailyCounts = buildNativeVersionCounts(nativeVersionUsage, dates, seriesNames) const dailyPercentages = convertCountsToPercentagesByName(dailyCounts, dates, seriesNames) const activeVersions = getActiveVersionsByName(seriesNames, dailyCounts) - const datasets = createDatasetsByName(activeVersions, dates, dailyPercentages, dailyCounts) + const datasets = createPercentageDatasetsByName(activeVersions, dates, dailyPercentages, dailyCounts) const latestVersion = getLatestDayVersionShare(activeVersions, dates, dailyCounts) return { @@ -810,41 +811,7 @@ function getActiveVersionsByName(versions: string[], counts: { [date: string]: { ) } -// Create datasets for Chart.js (by version_name - no lookup needed) -function createDatasetsByName( - versions: string[], - dates: string[], - percentages: { [date: string]: { [version: string]: number } }, - counts: { [date: string]: { [version: string]: number } }, -) { - return versions.map((version) => { - const percentageData = dates.map(date => percentages[date][version] ?? 0) - const countData = dates.map(date => Math.max(0, Math.round(counts[date][version] ?? 0))) - - return { - label: version, - data: percentageData, - metaCounts: countData, - } - }) -} - -function generateDateLabels(from: Date, to: Date) { - const start = dayjs(from).utc().startOf('day') - const end = dayjs(to).utc().startOf('day') - - if (start.isAfter(end)) - return [] - const labels: string[] = [] - let cursor = start - while (cursor.isBefore(end) || cursor.isSame(end)) { - labels.push(cursor.format('YYYY-MM-DD')) - cursor = cursor.add(1, 'day') - } - - return labels -} function fillMissingDailyData(datasets: { label: string, data: number[] }[], labels: string[]) { if (datasets.length === 0 || labels.length === 0) @@ -880,7 +847,7 @@ export const bundleUsageTestUtils = { fillMissingDailyCounts, convertCountsToPercentagesByName, getActiveVersionsByName, - createDatasetsByName, + createDatasetsByName: createPercentageDatasetsByName, getLatestDayVersionShare, } diff --git a/supabase/functions/_backend/utils/effective_apikey.ts b/supabase/functions/_backend/utils/effective_apikey.ts new file mode 100644 index 0000000000..94bf5dc80d --- /dev/null +++ b/supabase/functions/_backend/utils/effective_apikey.ts @@ -0,0 +1,26 @@ +import type { Context } from 'hono' +import type { MiddlewareKeyVariables } from './hono.ts' +import type { Database } from './supabase.types.ts' +import { simpleError } from './hono.ts' + +type ApikeyRow = Database['public']['Tables']['apikeys']['Row'] + +export function getEffectiveApikey( + c: Context, + apikey: Pick, +): string | undefined { + return apikey.key ?? c.get('capgkey') ?? undefined +} + +export function requireEffectiveApikey( + c: Context, + apikey: Pick, + errorCode: string, + errorMessage: string, + extra?: Record, +): string { + const effectiveApikey = getEffectiveApikey(c, apikey) + if (!effectiveApikey) + throw simpleError(errorCode, errorMessage, extra) + return effectiveApikey +} diff --git a/supabase/functions/_backend/utils/stats_date_range.ts b/supabase/functions/_backend/utils/stats_date_range.ts new file mode 100644 index 0000000000..109f2bfeb7 --- /dev/null +++ b/supabase/functions/_backend/utils/stats_date_range.ts @@ -0,0 +1,22 @@ +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc.js' + +dayjs.extend(utc) + +/** Inclusive UTC day labels from `from` through `to` as YYYY-MM-DD. */ +export function generateDateLabels(from: Date, to: Date): string[] { + const start = dayjs(from).utc().startOf('day') + const end = dayjs(to).utc().startOf('day') + + if (start.isAfter(end)) + return [] + + const labels: string[] = [] + let cursor = start + while (cursor.isBefore(end) || cursor.isSame(end)) { + labels.push(cursor.format('YYYY-MM-DD')) + cursor = cursor.add(1, 'day') + } + + return labels +} diff --git a/supabase/functions/_backend/utils/version_stats_helpers.ts b/supabase/functions/_backend/utils/version_stats_helpers.ts index a7e4747366..f711f3fd91 100644 --- a/supabase/functions/_backend/utils/version_stats_helpers.ts +++ b/supabase/functions/_backend/utils/version_stats_helpers.ts @@ -120,3 +120,29 @@ export function convertCountsToPercentagesByName( return percentages } + +export interface VersionChartDataset { + label: string + data: number[] + metaCounts: number[] +} + +/** Chart.js datasets: percentage series plus raw counts in metaCounts. */ +export function createPercentageDatasetsByName( + versions: string[], + dates: string[], + percentagesByDate: DailyVersionMap, + countsByDate: DailyVersionMap, +): VersionChartDataset[] { + return versions.map((version) => { + const percentageData = dates.map(date => percentagesByDate[date]?.[version] ?? 0) + const countData = dates.map(date => Math.max(0, Math.round(countsByDate[date]?.[version] ?? 0))) + + return { + label: version, + data: percentageData, + metaCounts: countData, + } + }) +} +