diff --git a/src/components/dashboard/BundleUploadsCard.vue b/src/components/dashboard/BundleUploadsCard.vue index 8893153093..1d0f295b86 100644 --- a/src/components/dashboard/BundleUploadsCard.vue +++ b/src/components/dashboard/BundleUploadsCard.vue @@ -3,6 +3,8 @@ import type { Database } from '~/types/supabase.types' import colors from 'tailwindcss/colors' import { computed, onMounted, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' +import { computeLastDayEvolution } from '~/services/buildCharts' +import { normalizeToUtcStartOfDay } from '~/services/date' import { calculateDemoEvolution, calculateDemoTotal, @@ -14,6 +16,8 @@ import { import { useSupabase } from '~/services/supabase' import { useDashboardAppsStore } from '~/stores/dashboardApps' import { useOrganizationStore } from '~/stores/organization' +import { filterDailySeriesToBillingPeriod, resolveDashboardDailySeriesWindow } from '~/utils/chartOptimizations' +import { ensureMinDelay } from '~/utils/minDelay' import BundleUploadsChart from './BundleUploadsChart.vue' import ChartCard from './ChartCard.vue' @@ -45,44 +49,6 @@ const props = defineProps({ }, }) -// Helper function to filter 30-day data to billing period -function filterToBillingPeriod(fullData: number[], last30DaysStart: Date, billingStart: Date) { - const currentDate = new Date() - - // Calculate billing period length - let currentBillingDay: number - - if (billingStart.getDate() === 1) { - currentBillingDay = currentDate.getDate() - } - else { - const billingStartDay = billingStart.getUTCDate() - const daysInMonth = new Date(Date.UTC(currentDate.getUTCFullYear(), currentDate.getUTCMonth() + 1, 0)).getUTCDate() - currentBillingDay = (currentDate.getUTCDate() - billingStartDay + 1 + daysInMonth) % daysInMonth - if (currentBillingDay === 0) - currentBillingDay = daysInMonth - } - - // Create arrays for billing period length - const billingData = Array.from({ length: currentBillingDay }).fill(0) as number[] - - // Map 30-day data to billing period - for (let i = 0; i < 30; i++) { - const dataDate = new Date(last30DaysStart) - dataDate.setDate(dataDate.getDate() + i) - - // Check if this date falls within current billing period - if (dataDate >= billingStart && dataDate <= currentDate) { - const billingIndex = Math.floor((dataDate.getTime() - billingStart.getTime()) / (1000 * 60 * 60 * 24)) - if (billingIndex >= 0 && billingIndex < currentBillingDay) { - billingData[billingIndex] = fullData[i] - } - } - } - - return { data: billingData } -} - const { t } = useI18n() const organizationStore = useOrganizationStore() @@ -154,16 +120,12 @@ async function calculateStats(forceRefetch = false) { const orgChanged = currentCacheOrgId.value !== currentOrgId currentCacheOrgId.value = currentOrgId - // Always work with last 30 days of data - const last30DaysEnd = new Date() - const last30DaysStart = new Date() - last30DaysStart.setDate(last30DaysStart.getDate() - 29) // 30 days including today - last30DaysStart.setHours(0, 0, 0, 0) - last30DaysEnd.setHours(23, 59, 59, 999) - // Get billing period dates for filtering - const billingStart = new Date(organizationStore.currentOrganization?.subscription_start ?? new Date()) - billingStart.setHours(0, 0, 0, 0) + const billingStart = normalizeToUtcStartOfDay(new Date(organizationStore.currentOrganization?.subscription_start ?? new Date())) + const { seriesStart: last30DaysStart, exclusiveEnd: last30DaysEnd, dayCount } = resolveDashboardDailySeriesWindow( + props.useBillingPeriod, + billingStart, + ) // Determine target apps const localAppNames: { [appId: string]: string } = {} @@ -202,7 +164,7 @@ async function calculateStats(forceRefetch = false) { } if (targetAppIds.length === 0) { - bundleData.value = Array.from({ length: 30 }).fill(0) as number[] + bundleData.value = Array.from({ length: dayCount }).fill(0) as number[] bundleDataByApp.value = {} return } @@ -216,12 +178,12 @@ async function calculateStats(forceRefetch = false) { data = cachedData } else { - // Fetch last 30 days of data + // Fetch series window (billing cycle or last 30 UTC days) const query = useSupabase() .from('app_versions') .select('created_at, app_id, deleted, r2_path, external_url, user_id') .gte('created_at', last30DaysStart.toISOString()) - .lte('created_at', last30DaysEnd.toISOString()) + .lt('created_at', last30DaysEnd.toISOString()) .in('app_id', targetAppIds) const result = await query @@ -236,26 +198,26 @@ async function calculateStats(forceRefetch = false) { if (!error && data) { // Create fresh arrays for processing - const dailyCounts30Days = Array.from({ length: 30 }).fill(0) as number[] + const dailyCounts30Days = Array.from({ length: dayCount }).fill(0) as number[] const bundleDataByApp30Days: { [appId: string]: number[] } = {} targetAppIds.forEach((appId) => { - bundleDataByApp30Days[appId] = Array.from({ length: 30 }).fill(0) as number[] + bundleDataByApp30Days[appId] = Array.from({ length: dayCount }).fill(0) as number[] }) // Track total separately (don't use ref during loop) let totalCount = 0 - // Map each bundle to the correct day and app (30 days) + // Map each bundle to the correct day and app data .filter((bundle: BundleUploadRow) => bundle.created_at !== null && bundle.app_id !== null && !isSyntheticDefaultVersion(bundle)) .forEach((bundle: any) => { if (bundle.created_at && bundle.app_id) { const bundleDate = new Date(bundle.created_at) - // Calculate days since start of 30-day period + // Calculate days since start of series window const daysDiff = Math.floor((bundleDate.getTime() - last30DaysStart.getTime()) / (1000 * 60 * 60 * 24)) - if (daysDiff >= 0 && daysDiff < 30) { + if (daysDiff >= 0 && daysDiff < dayCount) { dailyCounts30Days[daysDiff]++ totalCount++ @@ -270,13 +232,13 @@ async function calculateStats(forceRefetch = false) { // Filter data based on billing period mode if (props.useBillingPeriod) { // Show only data within billing period - const filteredData = filterToBillingPeriod(dailyCounts30Days, last30DaysStart, billingStart) + const filteredData = filterDailySeriesToBillingPeriod(dailyCounts30Days, last30DaysStart, billingStart) bundleData.value = filteredData.data // Filter by-app data too const filteredByApp: { [appId: string]: number[] } = {} Object.keys(bundleDataByApp30Days).forEach((appId) => { - const filteredAppData = filterToBillingPeriod(bundleDataByApp30Days[appId], last30DaysStart, billingStart) + const filteredAppData = filterDailySeriesToBillingPeriod(bundleDataByApp30Days[appId], last30DaysStart, billingStart) filteredByApp[appId] = filteredAppData.data }) bundleDataByApp.value = filteredByApp @@ -292,25 +254,14 @@ async function calculateStats(forceRefetch = false) { } // Calculate evolution (compare last two days with data) - const nonZeroDays = bundleData.value.filter(count => count > 0) - if (nonZeroDays.length >= 2) { - const lastDayCount = nonZeroDays[nonZeroDays.length - 1] - const previousDayCount = nonZeroDays[nonZeroDays.length - 2] - if (previousDayCount > 0) { - lastDayEvolution.value = ((lastDayCount - previousDayCount) / previousDayCount) * 100 - } - } + lastDayEvolution.value = computeLastDayEvolution(bundleData.value) } } catch (error) { console.error('Error calculating bundle upload stats:', error) } finally { - // Ensure spinner shows for at least 300ms for better UX - const elapsed = Date.now() - startTime - if (elapsed < 300) { - await new Promise(resolve => setTimeout(resolve, 300 - elapsed)) - } + await ensureMinDelay(startTime) isLoading.value = false } } diff --git a/src/components/dashboard/BundleUploadsChart.vue b/src/components/dashboard/BundleUploadsChart.vue index 704e39bd18..81195376e6 100644 --- a/src/components/dashboard/BundleUploadsChart.vue +++ b/src/components/dashboard/BundleUploadsChart.vue @@ -2,173 +2,44 @@ import type { ChartData, ChartOptions, Plugin } from 'chart.js' import type { TooltipClickHandler } from '~/services/chartTooltip' import { useDark } from '@vueuse/core' -import { - BarController, - BarElement, - CategoryScale, - Chart, - LinearScale, - LineController, - LineElement, - PointElement, - Tooltip, -} from 'chart.js' import { computed } from 'vue' import { Bar, Line } from 'vue-chartjs' import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' +import { useDashboardDailyChartCycle } from '~/composables/useOrgBillingCycleChart' import { createLegendConfig, createStackedChartScales } from '~/services/chartConfig' +import { createTodayLineOptions, generateAppChartColors, getSafeChartHue } from '~/services/chartTodayLine' import { createTooltipConfig, todayLinePlugin, verticalLinePlugin } from '~/services/chartTooltip' -import { generateMonthDays, getDaysInCurrentMonth } from '~/services/date' -import { useOrganizationStore } from '~/stores/organization' +import { dailyChartBaseProps } from '~/services/dailyChartProps' +import { registerDashboardCharts } from '~/services/dashboardChartRegister' +import { generateMonthDays } from '~/services/date' import { createChartLegendItems } from './chartLegend' import ChartLegend from './ChartLegend.vue' const props = defineProps({ - title: { type: String, default: '' }, - colors: { type: Object, default: () => ({}) }, - limits: { type: Object, default: () => ({}) }, - data: { type: Array, default: () => Array.from({ length: getDaysInCurrentMonth() }).fill(0) as number[] }, - dataByApp: { type: Object, default: () => ({}) }, - appNames: { type: Object, default: () => ({}) }, - useBillingPeriod: { type: Boolean, default: true }, - accumulated: { type: Boolean, default: false }, + ...dailyChartBaseProps(), }) +registerDashboardCharts() + const isDark = useDark() const { t } = useI18n() const router = useRouter() -const organizationStore = useOrganizationStore() -const cycleStart = new Date(organizationStore.currentOrganization?.subscription_start ?? new Date()) -const cycleEnd = new Date(organizationStore.currentOrganization?.subscription_end ?? new Date()) -// Reset to start of day for consistent date handling -cycleStart.setHours(0, 0, 0, 0) -cycleEnd.setHours(0, 0, 0, 0) - -const DAY_IN_MS = 1000 * 60 * 60 * 24 - -// Create a reverse mapping from app name to app ID for tooltip clicks -const appIdByLabel = computed(() => { - const mapping: Record = {} - Object.entries(props.appNames as Record).forEach(([appId, appName]) => { - mapping[appName] = appId - }) - return mapping -}) - -// Click handler for tooltip items - navigates to app detail page -const tooltipClickHandler = computed(() => ({ - onAppClick: (appId: string) => { - router.push(`/app/${appId}`) - }, - appIdByLabel: appIdByLabel.value, -})) - -Chart.register( - Tooltip, - BarController, - BarElement, - LineController, - LineElement, - PointElement, - CategoryScale, - LinearScale, -) - -// Check if a hue is in the red or green range (reserved for UpdateStats) -function isReservedHue(hue: number): boolean { - // Red range: 0-30 and 330-360 - // Green range: 90-160 - return (hue >= 0 && hue <= 30) || (hue >= 330 && hue <= 360) || (hue >= 90 && hue <= 160) -} - -// Get the nth safe hue that skips red/green colors -function getSafeHue(targetIndex: number): number { - let i = 0 - let safeCount = 0 - - while (safeCount <= targetIndex && i < targetIndex * 3 + 10) { - const hue = (210 + i * 137.508) % 360 - i++ - - if (!isReservedHue(hue)) { - if (safeCount === targetIndex) - return hue - safeCount++ - } - } - - // Fallback to blue if we somehow can't find enough safe hues - return 210 -} - -// Generate infinite distinct pastel colors starting with blue, skipping red/green -function generateAppColors(appCount: number) { - const colors = [] - - for (let colorIndex = 0; colorIndex < appCount; colorIndex++) { - const hue = getSafeHue(colorIndex) - - // Use pastel-friendly saturation and lightness values - const saturation = 50 + (colorIndex % 3) * 8 // 50%, 58%, 66% - softer colors - const lightness = 60 + (colorIndex % 4) * 5 // 60%, 65%, 70%, 75% - lighter, more pastel - - const backgroundColor = `hsla(${hue}, ${saturation}%, ${lightness}%, 0.8)` - - colors.push(backgroundColor) - } - - return colors -} +const chartCycle = useDashboardDailyChartCycle(() => props.useBillingPeriod) +const { cycleStart, cycleEnd, todayLimit, transformDailySeries } = chartCycle -function getTodayLimit(labelCount: number) { - if (!props.useBillingPeriod) - return labelCount - 1 - - const today = new Date() - today.setHours(0, 0, 0, 0) - - // If cycle end is today or in the past, show all data - if (cycleEnd <= today) - return labelCount - 1 - - // If cycle end is in the future, only show data up to today - const diff = Math.floor((today.getTime() - cycleStart.getTime()) / DAY_IN_MS) - - if (Number.isNaN(diff) || diff < 0) - return -1 - - return Math.min(diff, labelCount - 1) -} - -function transformSeries(source: number[], accumulated: boolean, labelCount: number) { - const display: Array = Array.from({ length: labelCount }).fill(null) as Array - const base: Array = Array.from({ length: labelCount }).fill(null) as Array - const limitIndex = getTodayLimit(labelCount) - - if (limitIndex < 0) - return { display, base } - - let runningTotal = 0 - for (let index = 0; index <= limitIndex; index++) { - const hasValue = index < source.length && typeof source[index] === 'number' && Number.isFinite(source[index]) - const numericValue = hasValue ? source[index] as number : 0 - - base[index] = numericValue - if (accumulated) { - runningTotal += numericValue - display[index] = runningTotal - } - else { - display[index] = numericValue - } +const tooltipClickHandler = computed(() => { + const appIdByLabel: Record = {} + for (const [appId, appName] of Object.entries(props.appNames as Record)) + appIdByLabel[appName] = appId + return { + onAppClick: (appId: string) => router.push(`/app/${appId}`), + appIdByLabel, } - - return { display, base } -} +}) function monthdays() { - return generateMonthDays(props.useBillingPeriod, cycleStart, cycleEnd) + return generateMonthDays(props.useBillingPeriod, cycleStart.value, cycleEnd.value) } const hasAppData = computed(() => Object.keys(props.dataByApp).length > 0) @@ -185,13 +56,13 @@ const chartData = computed>(() => { // Process data for cumulative mode if (props.accumulated) { - processed = transformSeries(props.data as number[], true, labelCount) + processed = transformDailySeries(props.data as number[], true, labelCount) // Use LineChartStats color scheme for line mode borderColor = `hsl(210, 65%, 45%)` backgroundColor = `hsla(210, 50%, 60%, 0.6)` } else { - processed = transformSeries(props.data as number[], false, labelCount) + processed = transformDailySeries(props.data as number[], false, labelCount) // Use existing bar chart colors for bar mode backgroundColor = props.colors[400] borderColor = props.colors[200] @@ -224,7 +95,7 @@ const chartData = computed>(() => { } // Create stacked datasets for each app - const appColors = generateAppColors(appIds.length) + const appColors = generateAppChartColors(appIds.length) const datasets = appIds.map((appId, index) => { const appData = props.dataByApp[appId] as number[] @@ -234,16 +105,16 @@ const chartData = computed>(() => { // Process data for cumulative mode if (props.accumulated) { - processed = transformSeries(appData, true, labelCount) + processed = transformDailySeries(appData, true, labelCount) // Use safe hue that skips red/green (reserved for UpdateStats) - const hue = getSafeHue(index) + const hue = getSafeChartHue(index) const saturation = 50 + (index % 3) * 8 const lightness = 60 + (index % 4) * 5 borderColor = `hsl(${hue}, ${saturation + 15}%, ${lightness - 15}%)` backgroundColor = `hsla(${hue}, ${saturation}%, ${lightness}%, 0.6)` } else { - processed = transformSeries(appData, false, labelCount) + processed = transformDailySeries(appData, false, labelCount) // Use existing bar chart colors for bar mode backgroundColor = appColors[index] borderColor = backgroundColor.replace('hsla', 'hsl').replace(', 0.8)', ')').replace(/(\d+)%\)/, (_, lightness) => { @@ -284,29 +155,14 @@ const chartData = computed>(() => { const legendItems = computed(() => hasAppData.value ? createChartLegendItems(chartData.value.datasets, 'appId') : []) const todayLineOptions = computed(() => { - if (!props.useBillingPeriod) - return { enabled: false } - const labels = Array.isArray(chartData.value.labels) ? chartData.value.labels : [] - const index = getTodayLimit(labels.length) - - if (index < 0 || index >= labels.length) - return { enabled: false } - - const strokeColor = isDark.value ? 'rgba(165, 180, 252, 0.75)' : 'rgba(99, 102, 241, 0.7)' - const glowColor = isDark.value ? 'rgba(129, 140, 248, 0.35)' : 'rgba(165, 180, 252, 0.35)' - const badgeFill = isDark.value ? 'rgba(67, 56, 202, 0.45)' : 'rgba(199, 210, 254, 0.85)' - const textColor = isDark.value ? '#e0e7ff' : '#312e81' - - return { - enabled: true, - xIndex: index, + return createTodayLineOptions({ + useBillingPeriod: props.useBillingPeriod, + index: todayLimit(labels.length), + labelCount: labels.length, label: t('today'), - color: strokeColor, - glowColor, - badgeFill, - textColor, - } + isDark: isDark.value, + }) }) const chartOptions = computed(() => { @@ -318,7 +174,7 @@ const chartOptions = computed(() => { title: { display: false, }, - tooltip: createTooltipConfig(hasAppData.value, props.accumulated, props.useBillingPeriod ? cycleStart : false, hasAppData.value ? tooltipClickHandler.value : undefined), + tooltip: createTooltipConfig(hasAppData.value, props.accumulated, props.useBillingPeriod ? cycleStart.value : false, hasAppData.value ? tooltipClickHandler.value : undefined), todayLine: todayLineOptions.value, }, } diff --git a/src/components/dashboard/DeploymentStatsCard.vue b/src/components/dashboard/DeploymentStatsCard.vue index e1a4ae7a1e..235c992a78 100644 --- a/src/components/dashboard/DeploymentStatsCard.vue +++ b/src/components/dashboard/DeploymentStatsCard.vue @@ -2,6 +2,8 @@ import colors from 'tailwindcss/colors' import { computed, onMounted, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' +import { computeLastDayEvolution } from '~/services/buildCharts' +import { formatUtcDateParam, normalizeToUtcStartOfDay } from '~/services/date' import { calculateDemoEvolution, calculateDemoTotal, @@ -13,6 +15,8 @@ import { import { useSupabase } from '~/services/supabase' import { useDashboardAppsStore } from '~/stores/dashboardApps' import { useOrganizationStore } from '~/stores/organization' +import { filterDailySeriesToBillingPeriod, resolveDashboardDailySeriesWindow } from '~/utils/chartOptimizations' +import { ensureMinDelay } from '~/utils/minDelay' import ChartCard from './ChartCard.vue' import DeploymentStatsChart from './DeploymentStatsChart.vue' @@ -39,44 +43,6 @@ const props = defineProps({ }, }) -// Helper function to filter 30-day data to billing period -function filterToBillingPeriod(fullData: number[], last30DaysStart: Date, billingStart: Date) { - const currentDate = new Date() - - // Calculate billing period length - let currentBillingDay: number - - if (billingStart.getDate() === 1) { - currentBillingDay = currentDate.getDate() - } - else { - const billingStartDay = billingStart.getUTCDate() - const daysInMonth = new Date(Date.UTC(currentDate.getUTCFullYear(), currentDate.getUTCMonth() + 1, 0)).getUTCDate() - currentBillingDay = (currentDate.getUTCDate() - billingStartDay + 1 + daysInMonth) % daysInMonth - if (currentBillingDay === 0) - currentBillingDay = daysInMonth - } - - // Create arrays for billing period length - const billingData = Array.from({ length: currentBillingDay }).fill(0) as number[] - - // Map 30-day data to billing period - for (let i = 0; i < 30; i++) { - const dataDate = new Date(last30DaysStart) - dataDate.setDate(dataDate.getDate() + i) - - // Check if this date falls within current billing period - if (dataDate >= billingStart && dataDate <= currentDate) { - const billingIndex = Math.floor((dataDate.getTime() - billingStart.getTime()) / (1000 * 60 * 60 * 24)) - if (billingIndex >= 0 && billingIndex < currentBillingDay) { - billingData[billingIndex] = fullData[i] - } - } - } - - return { data: billingData } -} - const { t } = useI18n() const organizationStore = useOrganizationStore() const dashboardAppsStore = useDashboardAppsStore() @@ -169,19 +135,16 @@ async function calculateStats(forceRefetch = false) { return } - // Always work with last 30 days of data - const last30DaysEnd = new Date() - const last30DaysStart = new Date() - last30DaysStart.setDate(last30DaysStart.getDate() - 29) // 30 days including today - last30DaysStart.setHours(0, 0, 0, 0) - last30DaysEnd.setHours(23, 59, 59, 999) - // Get billing period dates for filtering - const billingStart = new Date(targetOrganization.subscription_start ?? new Date()) - billingStart.setHours(0, 0, 0, 0) + const billingStart = normalizeToUtcStartOfDay(new Date(targetOrganization.subscription_start ?? new Date())) + const { seriesStart: last30DaysStart, exclusiveEnd, dayCount } = resolveDashboardDailySeriesWindow( + props.useBillingPeriod, + billingStart, + ) - const startDate = last30DaysStart.toISOString().split('T')[0] - const endDate = last30DaysEnd.toISOString().split('T')[0] + // Date-only exclusive upper bound so today's UTC deployments are included + const startDate = formatUtcDateParam(last30DaysStart) + const endDateExclusive = formatUtcDateParam(exclusiveEnd) let targetAppIds: string[] = [] @@ -206,7 +169,7 @@ async function calculateStats(forceRefetch = false) { return } - const dailyCounts30Days = Array.from({ length: 30 }).fill(0) as number[] + const dailyCounts30Days = Array.from({ length: dayCount }).fill(0) as number[] let totalDeploymentsCount = 0 // Check per-org cache - only use if not forcing refetch @@ -235,7 +198,7 @@ async function calculateStats(forceRefetch = false) { `) .in('app_id', targetAppIds) .gte('deployed_at', startDate) - .lte('deployed_at', endDate) + .lt('deployed_at', endDateExclusive) .order('deployed_at') if (result.error) @@ -262,7 +225,7 @@ async function calculateStats(forceRefetch = false) { // Create fresh arrays for processing per channel const perChannel: { [channelId: string]: number[] } = {} Object.keys(localChannelNames).forEach((channelId) => { - perChannel[channelId] = Array.from({ length: 30 }).fill(0) as number[] + perChannel[channelId] = Array.from({ length: dayCount }).fill(0) as number[] }) // Create fresh arrays for processing per app (multi-app mode) @@ -276,10 +239,10 @@ async function calculateStats(forceRefetch = false) { const deployDate = new Date(deployment.deployed_at) - // Calculate days since start of 30-day period + // Calculate days since start of series window const daysDiff = Math.floor((deployDate.getTime() - last30DaysStart.getTime()) / (1000 * 60 * 60 * 24)) - if (daysDiff < 0 || daysDiff >= 30) + if (daysDiff < 0 || daysDiff >= dayCount) return dailyCounts30Days[daysDiff] += 1 @@ -287,14 +250,14 @@ async function calculateStats(forceRefetch = false) { // Initialize channel array if not already (for channels discovered during iteration) if (!perChannel[deployment.channel_id]) { - perChannel[deployment.channel_id] = Array.from({ length: 30 }).fill(0) as number[] + perChannel[deployment.channel_id] = Array.from({ length: dayCount }).fill(0) as number[] } perChannel[deployment.channel_id][daysDiff] += 1 // For multi-app mode: aggregate by app_id if (!isSingleAppMode.value && deployment.app_id) { if (!perApp[deployment.app_id]) { - perApp[deployment.app_id] = Array.from({ length: 30 }).fill(0) as number[] + perApp[deployment.app_id] = Array.from({ length: dayCount }).fill(0) as number[] // Get app name from dashboardAppsStore localAppNames[deployment.app_id] = dashboardAppsStore.appNames[deployment.app_id] || deployment.app_id } @@ -309,19 +272,19 @@ async function calculateStats(forceRefetch = false) { let finalTotal = totalDeploymentsCount if (props.useBillingPeriod) { - const filteredData = filterToBillingPeriod(dailyCounts30Days, last30DaysStart, billingStart) + const filteredData = filterDailySeriesToBillingPeriod(dailyCounts30Days, last30DaysStart, billingStart) finalDeploymentData = filteredData.data const filteredPerChannel: { [channelId: string]: number[] } = {} Object.keys(perChannel).forEach((channelId) => { - const filteredChannelData = filterToBillingPeriod(perChannel[channelId], last30DaysStart, billingStart) + const filteredChannelData = filterDailySeriesToBillingPeriod(perChannel[channelId], last30DaysStart, billingStart) filteredPerChannel[channelId] = filteredChannelData.data }) finalPerChannel = filteredPerChannel const filteredPerApp: { [appId: string]: number[] } = {} Object.keys(perApp).forEach((appId) => { - const filteredAppData = filterToBillingPeriod(perApp[appId], last30DaysStart, billingStart) + const filteredAppData = filterDailySeriesToBillingPeriod(perApp[appId], last30DaysStart, billingStart) filteredPerApp[appId] = filteredAppData.data }) finalPerApp = filteredPerApp @@ -329,14 +292,7 @@ async function calculateStats(forceRefetch = false) { finalTotal = finalDeploymentData.reduce((sum, count) => sum + count, 0) } - let evolution = 0 - const nonZeroDays = finalDeploymentData.filter(count => count > 0) - if (nonZeroDays.length >= 2) { - const lastDayCount = nonZeroDays[nonZeroDays.length - 1] - const previousDayCount = nonZeroDays[nonZeroDays.length - 2] - if (previousDayCount > 0) - evolution = ((lastDayCount - previousDayCount) / previousDayCount) * 100 - } + const evolution = computeLastDayEvolution(finalDeploymentData) if (requestToken !== latestRequestToken) return @@ -365,11 +321,7 @@ async function calculateStats(forceRefetch = false) { } finally { if (requestToken === latestRequestToken) { - // Ensure spinner shows for at least 300ms for better UX - const elapsed = Date.now() - startTime - if (elapsed < 300) { - await new Promise(resolve => setTimeout(resolve, 300 - elapsed)) - } + await ensureMinDelay(startTime) isLoading.value = false } } diff --git a/src/components/dashboard/DeploymentStatsChart.vue b/src/components/dashboard/DeploymentStatsChart.vue index 93ca64e688..eb99422115 100644 --- a/src/components/dashboard/DeploymentStatsChart.vue +++ b/src/components/dashboard/DeploymentStatsChart.vue @@ -2,204 +2,73 @@ import type { ChartData, ChartOptions, Plugin } from 'chart.js' import type { TooltipClickHandler } from '~/services/chartTooltip' import { useDark } from '@vueuse/core' -import { - BarController, - BarElement, - CategoryScale, - Chart, - LinearScale, - LineController, - LineElement, - PointElement, - Tooltip, -} from 'chart.js' import { computed } from 'vue' import { Bar, Line } from 'vue-chartjs' import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' +import { useDashboardDailyChartCycle } from '~/composables/useOrgBillingCycleChart' import { createLegendConfig, createStackedChartScales } from '~/services/chartConfig' +import { createTodayLineOptions, generateAppChartColors, getSafeChartHue } from '~/services/chartTodayLine' import { createTooltipConfig, todayLinePlugin, verticalLinePlugin } from '~/services/chartTooltip' -import { generateMonthDays, getDaysInCurrentMonth } from '~/services/date' -import { useOrganizationStore } from '~/stores/organization' +import { dailyChartBaseProps } from '~/services/dailyChartProps' +import { registerDashboardCharts } from '~/services/dashboardChartRegister' +import { generateMonthDays } from '~/services/date' import { createChartLegendItems } from './chartLegend' import ChartLegend from './ChartLegend.vue' const props = defineProps({ - title: { type: String, default: '' }, - colors: { type: Object, default: () => ({}) }, - limits: { type: Object, default: () => ({}) }, - data: { type: Array, default: () => Array.from({ length: getDaysInCurrentMonth() }).fill(0) as number[] }, + ...dailyChartBaseProps(), dataByChannel: { type: Object, default: () => ({}) }, channelNames: { type: Object, default: () => ({}) }, channelAppIds: { type: Object, default: () => ({}) }, - dataByApp: { type: Object, default: () => ({}) }, - appNames: { type: Object, default: () => ({}) }, - useBillingPeriod: { type: Boolean, default: true }, - accumulated: { type: Boolean, default: false }, }) +registerDashboardCharts() + const isDark = useDark() const { t } = useI18n() const router = useRouter() -const organizationStore = useOrganizationStore() -const cycleStart = new Date(organizationStore.currentOrganization?.subscription_start ?? new Date()) -const cycleEnd = new Date(organizationStore.currentOrganization?.subscription_end ?? new Date()) -// Reset to start of day for consistent date handling -cycleStart.setHours(0, 0, 0, 0) -cycleEnd.setHours(0, 0, 0, 0) - -const DAY_IN_MS = 1000 * 60 * 60 * 24 +const { cycleStart, cycleEnd, todayLimit, transformDailySeries } = useDashboardDailyChartCycle(() => props.useBillingPeriod) -// Determine mode based on which data is provided const isChannelMode = computed(() => Object.keys(props.dataByChannel).length > 0) const isAppMode = computed(() => Object.keys(props.dataByApp).length > 0) const hasBreakdownData = computed(() => isChannelMode.value || isAppMode.value) -// Create a reverse mapping from channel/app name to ID for tooltip clicks const idByLabel = computed(() => { const mapping: Record = {} if (isChannelMode.value) { - Object.entries(props.channelNames as Record).forEach(([channelId, channelName]) => { + for (const [channelId, channelName] of Object.entries(props.channelNames as Record)) mapping[channelName] = channelId - }) } else if (isAppMode.value) { - Object.entries(props.appNames as Record).forEach(([appId, appName]) => { + for (const [appId, appName] of Object.entries(props.appNames as Record)) mapping[appName] = appId - }) } return mapping }) -// Click handler for tooltip items - navigates to channel page (channel mode) or app page (app mode) const tooltipClickHandler = computed(() => { if (isChannelMode.value) { return { onAppClick: (channelId: string) => { const appId = (props.channelAppIds as Record)[channelId] - if (appId) { + if (appId) router.push(`/app/${appId}/channel/${channelId}`) - } }, appIdByLabel: idByLabel.value, } } - else if (isAppMode.value) { + if (isAppMode.value) { return { - onAppClick: (appId: string) => { - router.push(`/app/${appId}`) - }, + onAppClick: (appId: string) => router.push(`/app/${appId}`), appIdByLabel: idByLabel.value, } } return undefined }) -Chart.register( - Tooltip, - BarController, - BarElement, - LineController, - LineElement, - PointElement, - CategoryScale, - LinearScale, -) - -function getTodayLimit(labelCount: number) { - if (!props.useBillingPeriod) - return labelCount - 1 - - const today = new Date() - today.setHours(0, 0, 0, 0) - - // If cycle end is today or in the past, show all data - if (cycleEnd <= today) - return labelCount - 1 - - // If cycle end is in the future, only show data up to today - const diff = Math.floor((today.getTime() - cycleStart.getTime()) / DAY_IN_MS) - - if (Number.isNaN(diff) || diff < 0) - return -1 - - return Math.min(diff, labelCount - 1) -} - -function transformSeries(source: number[], accumulated: boolean, labelCount: number) { - const display: Array = Array.from({ length: labelCount }).fill(null) as Array - const base: Array = Array.from({ length: labelCount }).fill(null) as Array - const limitIndex = getTodayLimit(labelCount) - - if (limitIndex < 0) - return { display, base } - - let runningTotal = 0 - for (let index = 0; index <= limitIndex; index++) { - const hasValue = index < source.length && typeof source[index] === 'number' && Number.isFinite(source[index]) - const numericValue = hasValue ? source[index] as number : 0 - - base[index] = numericValue - if (accumulated) { - runningTotal += numericValue - display[index] = runningTotal - } - else { - display[index] = numericValue - } - } - - return { display, base } -} - function monthdays() { - return generateMonthDays(props.useBillingPeriod, cycleStart, cycleEnd) -} - -// Check if a hue is in the red or green range (reserved for UpdateStats) -function isReservedHue(hue: number): boolean { - // Red range: 0-30 and 330-360 - // Green range: 90-160 - return (hue >= 0 && hue <= 30) || (hue >= 330 && hue <= 360) || (hue >= 90 && hue <= 160) -} - -// Get the nth safe hue that skips red/green colors -function getSafeHue(targetIndex: number): number { - let i = 0 - let safeCount = 0 - - while (safeCount <= targetIndex && i < targetIndex * 3 + 10) { - const hue = (210 + i * 137.508) % 360 - i++ - - if (!isReservedHue(hue)) { - if (safeCount === targetIndex) - return hue - safeCount++ - } - } - - // Fallback to blue if we somehow can't find enough safe hues - return 210 -} - -// Generate infinite distinct pastel colors starting with blue, skipping red/green -function generateChannelColors(channelCount: number) { - const colors = [] - - for (let colorIndex = 0; colorIndex < channelCount; colorIndex++) { - const hue = getSafeHue(colorIndex) - - // Use pastel-friendly saturation and lightness values - const saturation = 50 + (colorIndex % 3) * 8 // 50%, 58%, 66% - softer colors - const lightness = 60 + (colorIndex % 4) * 5 // 60%, 65%, 70%, 75% - lighter, more pastel - - const backgroundColor = `hsla(${hue}, ${saturation}%, ${lightness}%, 0.8)` - - colors.push(backgroundColor) - } - - return colors + return generateMonthDays(props.useBillingPeriod, cycleStart.value, cycleEnd.value) } const chartData = computed>(() => { @@ -229,13 +98,13 @@ const chartData = computed>(() => { // Process data for cumulative mode if (props.accumulated) { - processed = transformSeries(props.data as number[], true, labelCount) + processed = transformDailySeries(props.data as number[], true, labelCount) // Use LineChartStats color scheme for line mode borderColor = `hsl(210, 65%, 45%)` backgroundColor = `hsla(210, 50%, 60%, 0.6)` } else { - processed = transformSeries(props.data as number[], false, labelCount) + processed = transformDailySeries(props.data as number[], false, labelCount) // Use existing bar chart colors for bar mode backgroundColor = 'hsla(210, 50%, 70%, 0.8)' borderColor = 'hsl(210, 50%, 55%)' @@ -268,7 +137,7 @@ const chartData = computed>(() => { } // Multiple items view - show breakdown by channel or app - const itemColors = generateChannelColors(itemIds.length) + const itemColors = generateAppChartColors(itemIds.length) const datasets = itemIds.map((itemId, index) => { const itemData = dataSource[itemId] as number[] @@ -278,16 +147,16 @@ const chartData = computed>(() => { // Process data for cumulative mode if (props.accumulated) { - processed = transformSeries(itemData, true, labelCount) + processed = transformDailySeries(itemData, true, labelCount) // Use safe hue that skips red/green (reserved for UpdateStats) - const hue = getSafeHue(index) + const hue = getSafeChartHue(index) const saturation = 50 + (index % 3) * 8 const lightness = 60 + (index % 4) * 5 borderColor = `hsl(${hue}, ${saturation + 15}%, ${lightness - 15}%)` backgroundColor = `hsla(${hue}, ${saturation}%, ${lightness}%, 0.6)` } else { - processed = transformSeries(itemData, false, labelCount) + processed = transformDailySeries(itemData, false, labelCount) // Use existing bar chart colors for bar mode backgroundColor = itemColors[index] borderColor = backgroundColor.replace('hsla', 'hsl').replace(', 0.8)', ')').replace(/(\d+)%\)/, (_, lightness) => { @@ -328,29 +197,14 @@ const chartData = computed>(() => { const legendItems = computed(() => hasBreakdownData.value ? createChartLegendItems(chartData.value.datasets, 'breakdownId') : []) const todayLineOptions = computed(() => { - if (!props.useBillingPeriod) - return { enabled: false } - const labels = Array.isArray(chartData.value.labels) ? chartData.value.labels : [] - const index = getTodayLimit(labels.length) - - if (index < 0 || index >= labels.length) - return { enabled: false } - - const strokeColor = isDark.value ? 'rgba(165, 180, 252, 0.75)' : 'rgba(99, 102, 241, 0.7)' - const glowColor = isDark.value ? 'rgba(129, 140, 248, 0.35)' : 'rgba(165, 180, 252, 0.35)' - const badgeFill = isDark.value ? 'rgba(67, 56, 202, 0.45)' : 'rgba(199, 210, 254, 0.85)' - const textColor = isDark.value ? '#e0e7ff' : '#312e81' - - return { - enabled: true, - xIndex: index, + return createTodayLineOptions({ + useBillingPeriod: props.useBillingPeriod, + index: todayLimit(labels.length), + labelCount: labels.length, label: t('today'), - color: strokeColor, - glowColor, - badgeFill, - textColor, - } + isDark: isDark.value, + }) }) const chartOptions = computed(() => { @@ -362,7 +216,7 @@ const chartOptions = computed(() => { title: { display: false, }, - tooltip: createTooltipConfig(hasBreakdownData.value, props.accumulated, props.useBillingPeriod ? cycleStart : false, tooltipClickHandler.value), + tooltip: createTooltipConfig(hasBreakdownData.value, props.accumulated, props.useBillingPeriod ? cycleStart.value : false, tooltipClickHandler.value), todayLine: todayLineOptions.value, }, } diff --git a/src/components/dashboard/DevicesStats.vue b/src/components/dashboard/DevicesStats.vue index 833e6e5aec..78d2a4477e 100644 --- a/src/components/dashboard/DevicesStats.vue +++ b/src/components/dashboard/DevicesStats.vue @@ -11,7 +11,7 @@ import { useRoute, useRouter } from 'vue-router' import { createChartScales } from '~/services/chartConfig' import { useChartData } from '~/services/chartDataService' import { createTooltipConfig, todayLinePlugin, verticalLinePlugin } from '~/services/chartTooltip' -import { generateChartDayLabels, getChartDateRange, normalizeToStartOfDay } from '~/services/date' +import { generateChartDayLabels, getChartDateRange, normalizeToUtcStartOfDay } from '~/services/date' import { formatNumberValue } from '~/services/formatLocale' import { useSupabase } from '~/services/supabase' import { useDashboardAppsStore } from '~/stores/dashboardApps' @@ -48,12 +48,12 @@ const props = defineProps({ // Demo data generator for devices stats when forceDemo is true function generateDemoDevicesData(days: number, usageKind: string = 'bundle'): { labels: string[], datasets: { label: string, data: number[] }[] } { const labels: string[] = [] - const today = new Date() + const today = normalizeToUtcStartOfDay() for (let i = days - 1; i >= 0; i--) { const date = new Date(today) - date.setDate(date.getDate() - i) - labels.push(date.toISOString().split('T')[0]) + date.setUTCDate(date.getUTCDate() - i) + labels.push(date.toISOString().slice(0, 10)) } // Generate realistic version adoption data @@ -337,7 +337,7 @@ const processedChartData = computed | null>(() => { if (!currentRange.value) return rawValues.length - 1 - const today = normalizeToStartOfDay(new Date()) + const today = normalizeToUtcStartOfDay(new Date()) const diff = Math.floor((today.getTime() - currentRange.value.startDate.getTime()) / (24 * 60 * 60 * 1000)) if (Number.isNaN(diff)) @@ -495,7 +495,7 @@ const todayLineOptions = computed(() => { if (!props.useBillingPeriod || !currentRange.value) return { enabled: false } - const today = normalizeToStartOfDay(new Date()) + const today = normalizeToUtcStartOfDay(new Date()) const { startDate, endDate } = currentRange.value if (today < startDate || today > endDate) diff --git a/src/components/dashboard/LineChartStats.vue b/src/components/dashboard/LineChartStats.vue index bc757b2abb..8cd2f97c35 100644 --- a/src/components/dashboard/LineChartStats.vue +++ b/src/components/dashboard/LineChartStats.vue @@ -3,25 +3,15 @@ import type { ChartData, ChartOptions, Plugin } from 'chart.js' import type { AnnotationOptions } from '../../services/chartAnnotations' import type { TooltipClickHandler } from '../../services/chartTooltip' import { useDark } from '@vueuse/core' -import { - BarController, - BarElement, - CategoryScale, - Chart, - Filler, - LinearScale, - LineController, - LineElement, - PointElement, - Tooltip, -} from 'chart.js' import { computed } from 'vue' import { Bar, Line } from 'vue-chartjs' import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' +import { useDashboardDailyChartCycle } from '~/composables/useOrgBillingCycleChart' import { createLegendConfig, createStackedChartScales } from '~/services/chartConfig' -import { generateMonthDays, getCurrentDayMonth, getDaysInCurrentMonth } from '~/services/date' -import { useOrganizationStore } from '~/stores/organization' +import { createTodayLineOptions, getSafeChartHue } from '~/services/chartTodayLine' +import { registerDashboardCharts } from '~/services/dashboardChartRegister' +import { generateMonthDays, getCurrentDayMonth, getDaysInCurrentUtcMonth } from '~/services/date' import { inlineAnnotationPlugin } from '../../services/chartAnnotations' import { createTooltipConfig, todayLinePlugin, verticalLinePlugin } from '../../services/chartTooltip' import { createChartLegendItems } from './chartLegend' @@ -39,7 +29,7 @@ const props = defineProps({ title: { type: String, default: '' }, colors: { type: Object, default: () => ({}) }, limits: { type: Object, default: () => ({}) }, - data: { type: Array, default: Array.from({ length: getDaysInCurrentMonth() }).fill(undefined) as number[] }, + data: { type: Array, default: Array.from({ length: getDaysInCurrentUtcMonth() }).fill(undefined) as number[] }, dataByApp: { type: Object, default: () => ({}), @@ -49,48 +39,29 @@ const props = defineProps({ default: () => ({}), }, }) + +registerDashboardCharts() + const isDark = useDark() const { t } = useI18n() const router = useRouter() -const organizationStore = useOrganizationStore() -const cycleStart = new Date(organizationStore.currentOrganization?.subscription_start ?? new Date()) -const cycleEnd = new Date(organizationStore.currentOrganization?.subscription_end ?? new Date()) -// Reset to start of day for consistent date handling -cycleStart.setHours(0, 0, 0, 0) -cycleEnd.setHours(0, 0, 0, 0) - -// Create a reverse mapping from app name to app ID for tooltip clicks -const appIdByLabel = computed(() => { - const mapping: Record = {} - // appNames prop is { appId: appName }, we need { appName: appId } - Object.entries(props.appNames as Record).forEach(([appId, appName]) => { - mapping[appName] = appId - }) - return mapping -}) +const { cycleStart, cycleEnd, todayLimit } = useDashboardDailyChartCycle(() => props.useBillingPeriod) -// Click handler for tooltip items - navigates to app detail page -const tooltipClickHandler = computed(() => ({ - onAppClick: (appId: string) => { - router.push(`/app/${appId}`) - }, - appIdByLabel: appIdByLabel.value, -})) +const tooltipClickHandler = computed(() => { + const appIdByLabel: Record = {} + for (const [appId, appName] of Object.entries(props.appNames as Record)) + appIdByLabel[appName] = appId + return { + onAppClick: (appId: string) => router.push(`/app/${appId}`), + appIdByLabel, + } +}) -// View mode is now controlled by parent component const viewMode = computed(() => props.accumulated ? 'cumulative' : 'daily') -Chart.register( - Tooltip, - BarController, - BarElement, - LineController, - PointElement, - CategoryScale, - LinearScale, - LineElement, - Filler, -) +function monthdays() { + return generateMonthDays(props.useBillingPeriod, cycleStart.value, cycleEnd.value) +} const accumulateData = computed(() => { const monthDay = getCurrentDayMonth() @@ -134,7 +105,7 @@ const projectionData = computed(() => { const lastDay = arrWithoutUndefined[arrWithoutUndefined.length - 1] // create a projection of the evolution, start after the last value of the array, put undefined for the beginning of the month // each value is the previous value + the evolution, the first value is the last value of the array - let res = new Array(getDaysInCurrentMonth()).fill(undefined) + let res = new Array(getDaysInCurrentUtcMonth()).fill(undefined) res = res.reduce((acc: number[], val: number, i: number) => { let newVal const last = acc[acc.length - 1] ?? 0 @@ -153,10 +124,6 @@ const projectionData = computed(() => { return res }) -function monthdays() { - return generateMonthDays(props.useBillingPeriod, cycleStart, cycleEnd) -} - function createAnnotation(id: string, y: number, title: string, lineColor: string, bgColor: string) { const obj: any = {} obj[`line_${id}`] = { @@ -169,7 +136,7 @@ function createAnnotation(id: string, y: number, title: string, lineColor: strin } obj[`label_${id}`] = { type: 'label', - xValue: getDaysInCurrentMonth() / 2, + xValue: getDaysInCurrentUtcMonth() / 2, yValue: y, backgroundColor: bgColor, content: [title], @@ -212,54 +179,16 @@ const generateAnnotations = computed(() => { return annotations }) -// Check if a hue is in the red or green range (reserved for UpdateStats) -function isReservedHue(hue: number): boolean { - // Red range: 0-30 and 330-360 - // Green range: 90-160 - return (hue >= 0 && hue <= 30) || (hue >= 330 && hue <= 360) || (hue >= 90 && hue <= 160) -} - -// Get the nth safe hue that skips red/green colors -function getSafeHue(targetIndex: number): number { - let i = 0 - let safeCount = 0 - - while (safeCount <= targetIndex && i < targetIndex * 3 + 10) { - const hue = (210 + i * 137.508) % 360 - i++ - - if (!isReservedHue(hue)) { - if (safeCount === targetIndex) - return hue - safeCount++ - } - } - - // Fallback to blue if we somehow can't find enough safe hues - return 210 -} - -// Generate infinite distinct pastel colors starting with blue, skipping red/green function generateAppColors(appCount: number) { - const colors = [] - - for (let colorIndex = 0; colorIndex < appCount; colorIndex++) { - const hue = getSafeHue(colorIndex) - - // Use pastel-friendly saturation and lightness values - const saturation = 50 + (colorIndex % 3) * 8 // 50%, 58%, 66% - softer colors - const lightness = 60 + (colorIndex % 4) * 5 // 60%, 65%, 70%, 75% - lighter, more pastel - - const borderColor = `hsl(${hue}, ${saturation + 15}%, ${lightness - 15}%)` - const backgroundColor = `hsla(${hue}, ${saturation}%, ${lightness}%, 0.6)` - - colors.push({ - border: borderColor, - bg: backgroundColor, - }) - } - - return colors + return Array.from({ length: appCount }, (_, colorIndex) => { + const hue = getSafeChartHue(colorIndex) + const saturation = 50 + (colorIndex % 3) * 8 + const lightness = 60 + (colorIndex % 4) * 5 + return { + border: `hsl(${hue}, ${saturation + 15}%, ${lightness - 15}%)`, + bg: `hsla(${hue}, ${saturation}%, ${lightness}%, 0.6)`, + } + }) } const chartData = computed>(() => { @@ -296,7 +225,7 @@ const chartData = computed>(() => { } else { // Use safe hue that skips red/green (reserved for UpdateStats) - const hue = getSafeHue(index) + const hue = getSafeChartHue(index) const saturation = 50 + (index % 3) * 8 const lightness = 60 + (index % 4) * 5 backgroundColor = `hsla(${hue}, ${saturation}%, ${lightness}%, 0.8)` @@ -384,35 +313,14 @@ const hasAppData = computed(() => Object.keys(props.dataByApp || {}).length > 0) const legendItems = computed(() => hasAppData.value ? createChartLegendItems(chartData.value.datasets, 'appId') : []) const todayLineOptions = computed(() => { - if (!props.useBillingPeriod) - return { enabled: false } - - const today = new Date() - today.setHours(0, 0, 0, 0) - - if (today < cycleStart || today > cycleEnd) - return { enabled: false } - - const diff = Math.floor((today.getTime() - cycleStart.getTime()) / (1000 * 60 * 60 * 24)) const labels = Array.isArray(chartData.value.labels) ? chartData.value.labels : [] - - if (diff < 0 || diff >= labels.length) - return { enabled: false } - - const strokeColor = isDark.value ? 'rgba(165, 180, 252, 0.75)' : 'rgba(99, 102, 241, 0.7)' - const glowColor = isDark.value ? 'rgba(129, 140, 248, 0.35)' : 'rgba(165, 180, 252, 0.35)' - const badgeFill = isDark.value ? 'rgba(67, 56, 202, 0.45)' : 'rgba(199, 210, 254, 0.85)' - const textColor = isDark.value ? '#e0e7ff' : '#312e81' - - return { - enabled: true, - xIndex: diff, + return createTodayLineOptions({ + useBillingPeriod: props.useBillingPeriod, + index: todayLimit(labels.length), + labelCount: labels.length, label: t('today'), - color: strokeColor, - glowColor, - badgeFill, - textColor, - } + isDark: isDark.value, + }) }) // Calculate appropriate Y-axis max based on actual data values @@ -460,7 +368,7 @@ const chartOptions = computed today ? today : billingStart const rangeStart = props.useBillingPeriod ? safeBillingStart : last30DaysStart @@ -188,8 +186,8 @@ async function calculateStats(forceRefetch = false) { ? Math.max(0, Math.floor((today.getTime() - rangeStart.getTime()) / DAY_IN_MS) + 1) : 30 - const startDate = rangeStart.toISOString().split('T')[0] - const endDate = today.toISOString().split('T')[0] + const startDate = formatUtcDateParam(rangeStart) + const endDate = formatUtcDateParam(today) // Cache key includes org, app, and range to avoid stale data between periods const cacheKey = `${currentOrgId ?? 'none'}:${props.appId || 'org'}:${startDate}:${endDate}` @@ -268,8 +266,7 @@ async function calculateStats(forceRefetch = false) { // Process each stat entry data.forEach((stat: any) => { if (stat.date) { - const statDate = new Date(stat.date) - statDate.setHours(0, 0, 0, 0) + const statDate = normalizeToUtcStartOfDay(new Date(stat.date)) // Calculate days since start of range const daysDiff = Math.floor((statDate.getTime() - rangeStart.getTime()) / DAY_IN_MS) diff --git a/src/components/dashboard/UpdateStatsChart.vue b/src/components/dashboard/UpdateStatsChart.vue index d5533e8040..2157e4d565 100644 --- a/src/components/dashboard/UpdateStatsChart.vue +++ b/src/components/dashboard/UpdateStatsChart.vue @@ -2,39 +2,26 @@ import type { ChartData, ChartOptions, Plugin } from 'chart.js' import type { TooltipClickHandler } from '~/services/chartTooltip' import { useDark } from '@vueuse/core' -import { - BarController, - BarElement, - CategoryScale, - Chart, - LinearScale, - LineController, - LineElement, - PointElement, - Tooltip, -} from 'chart.js' -import dayjs from 'dayjs' import { computed } from 'vue' import { Bar, Line } from 'vue-chartjs' import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' +import { useOrgBillingCycleChart } from '~/composables/useOrgBillingCycleChart' import { createLegendConfig, createStackedChartScales } from '~/services/chartConfig' -import { generateMonthDays, getDaysInCurrentMonth } from '~/services/date' +import { createTodayLineOptions } from '~/services/chartTodayLine' +import { dailyChartBaseProps } from '~/services/dailyChartProps' +import { registerDashboardCharts } from '~/services/dashboardChartRegister' +import { generateMonthDays, getUtcDayBounds } from '~/services/date' import { useOrganizationStore } from '~/stores/organization' import { createTooltipConfig, todayLinePlugin, verticalLinePlugin } from '../../services/chartTooltip' const props = defineProps({ - title: { type: String, default: '' }, - colors: { type: Object, default: () => ({}) }, - limits: { type: Object, default: () => ({}) }, - data: { type: Array, default: () => Array.from({ length: getDaysInCurrentMonth() }).fill(0) as number[] }, - dataByApp: { type: Object, default: () => ({}) }, - appNames: { type: Object, default: () => ({}) }, - useBillingPeriod: { type: Boolean, default: true }, - accumulated: { type: Boolean, default: false }, + ...dailyChartBaseProps(), appId: { type: String, default: '' }, }) +registerDashboardCharts() + const isDark = useDark() const { t } = useI18n() const router = useRouter() @@ -44,20 +31,14 @@ const effectiveOrganization = computed(() => { return organizationStore.getOrgByAppId(props.appId) ?? organizationStore.currentOrganization return organizationStore.currentOrganization }) -const cycleStart = computed(() => { - const start = new Date(effectiveOrganization.value?.subscription_start ?? new Date()) - start.setHours(0, 0, 0, 0) - return start -}) -const cycleEnd = computed(() => { - const end = new Date(effectiveOrganization.value?.subscription_end ?? new Date()) - end.setHours(0, 0, 0, 0) - const today = new Date() - today.setHours(0, 0, 0, 0) - return end < today ? today : end -}) - -const DAY_IN_MS = 1000 * 60 * 60 * 24 +const chartCycle = useOrgBillingCycleChart( + () => props.useBillingPeriod, + () => effectiveOrganization.value?.subscription_start, + () => effectiveOrganization.value?.subscription_end, +) +const cycleStart = computed(() => chartCycle.resolveCycleStart()) +const cycleEnd = computed(() => chartCycle.resolveCycleEnd()) +const { todayLimit, transformDailySeries } = chartCycle const UPDATE_FAILURE_ACTIONS = [ 'set_fail', @@ -101,12 +82,11 @@ const tooltipClickHandler = computed(() => { const params = new URLSearchParams() filterActions.forEach(action => params.append('action', action)) - // Add date range if provided (selected day) + // Add date range if provided (selected UTC calendar day) if (clickContext?.date) { - const startOfDay = dayjs(clickContext.date).startOf('day') - const endOfDay = dayjs(clickContext.date).endOf('day') - params.set('start', startOfDay.toISOString()) - params.set('end', endOfDay.toISOString()) + const { start, end } = getUtcDayBounds(clickContext.date) + params.set('start', start.toISOString()) + params.set('end', end.toISOString()) } router.push(`/app/${props.appId}/observe/logs?${params.toString()}`) @@ -115,17 +95,6 @@ const tooltipClickHandler = computed(() => { } }) -Chart.register( - Tooltip, - BarController, - BarElement, - LineController, - LineElement, - PointElement, - CategoryScale, - LinearScale, -) - const ACTION_STYLES: Record = { requested: { barBackground: 'hsla(210, 65%, 60%, 0.8)', @@ -147,52 +116,6 @@ const ACTION_STYLES: Record = Array.from({ length: labelCount }).fill(null) as Array - const base: Array = Array.from({ length: labelCount }).fill(null) as Array - const limitIndex = getTodayLimit(labelCount) - - if (limitIndex < 0) - return { display, base } - - let runningTotal = 0 - for (let index = 0; index <= limitIndex; index++) { - const hasValue = index < source.length && typeof source[index] === 'number' && Number.isFinite(source[index]) - const numericValue = hasValue ? source[index] as number : 0 - - base[index] = numericValue - if (accumulated) { - runningTotal += numericValue - display[index] = runningTotal - } - else { - display[index] = numericValue - } - } - - return { display, base } -} - function monthdays() { return generateMonthDays(props.useBillingPeriod, cycleStart.value, cycleEnd.value) } @@ -207,7 +130,7 @@ const chartData = computed>(() => { const actionName = props.appNames[action] || action const style = ACTION_STYLES[action] ?? ACTION_STYLES.requested const rawData = actionData && actionData.length ? actionData : Array.from({ length: labels.length }).fill(0) as Array - const processed = transformSeries(rawData, props.accumulated, labelCount) + const processed = transformDailySeries(rawData, props.accumulated, labelCount) const backgroundColor = props.accumulated ? style.lineBackground : style.barBackground const borderColor = props.accumulated ? style.lineBorder : style.barBorder @@ -241,29 +164,14 @@ const chartData = computed>(() => { }) const todayLineOptions = computed(() => { - if (!props.useBillingPeriod) - return { enabled: false } - const labels = Array.isArray(chartData.value.labels) ? chartData.value.labels : [] - const index = getTodayLimit(labels.length) - - if (index < 0 || index >= labels.length) - return { enabled: false } - - const strokeColor = isDark.value ? 'rgba(165, 180, 252, 0.75)' : 'rgba(99, 102, 241, 0.7)' - const glowColor = isDark.value ? 'rgba(129, 140, 248, 0.35)' : 'rgba(165, 180, 252, 0.35)' - const badgeFill = isDark.value ? 'rgba(67, 56, 202, 0.45)' : 'rgba(199, 210, 254, 0.85)' - const textColor = isDark.value ? '#e0e7ff' : '#312e81' - - return { - enabled: true, - xIndex: index, + return createTodayLineOptions({ + useBillingPeriod: props.useBillingPeriod, + index: todayLimit(labels.length), + labelCount: labels.length, label: t('today'), - color: strokeColor, - glowColor, - badgeFill, - textColor, - } + isDark: isDark.value, + }) }) const chartOptions = computed(() => { diff --git a/src/components/dashboard/Usage.vue b/src/components/dashboard/Usage.vue index ee25895e26..9705e364cc 100644 --- a/src/components/dashboard/Usage.vue +++ b/src/components/dashboard/Usage.vue @@ -24,7 +24,7 @@ import { requestOrgChartRefresh, shouldAutoRequestChartRefresh, } from '~/services/dashboardRefresh' -import { formatLocalDate, formatLocalDateTime, formatUtcDateTimeAsLocal } from '~/services/date' +import { addUtcDays, formatLocalDate, formatLocalDateTime, formatUtcDateTimeAsLocal, normalizeToUtcStartOfDay } from '~/services/date' import { DEMO_APP_NAMES, generateDemoBandwidthData, generateDemoMauData, generateDemoStorageData } from '~/services/demoChartData' import { getPlans } from '~/services/supabase' import { useDashboardAppsStore } from '~/stores/dashboardApps' @@ -401,14 +401,10 @@ async function handleReloadClick() { // Function to reload all chart data async function reloadAllCharts() { // Force reload of main dashboard data - // End date should be tomorrow at midnight to include all of today's data - const last30DaysEnd = new Date() - last30DaysEnd.setHours(0, 0, 0, 0) - last30DaysEnd.setDate(last30DaysEnd.getDate() + 1) // Tomorrow midnight - // Start date should be 29 days ago at midnight (to get 30 days total including today) - const last30DaysStart = new Date() - last30DaysStart.setHours(0, 0, 0, 0) - last30DaysStart.setDate(last30DaysStart.getDate() - 29) + // End date should be next UTC midnight to include all of today's UTC data + const todayUtc = normalizeToUtcStartOfDay() + const last30DaysEnd = addUtcDays(todayUtc, 1) + const last30DaysStart = addUtcDays(todayUtc, -29) const orgId = effectiveOrganization.value?.gid if (orgId) { @@ -517,9 +513,7 @@ async function getAppStats(rangeStart: Date, rangeEnd: Date) { // Helper function to filter 30-day data to billing period function filterToBillingPeriod(fullData: { mau: number[], storage: number[], storageByteHours: number[], bandwidth: number[] }, last30DaysStart: Date, billingStart: Date) { - const currentDate = new Date() - // Reset current date to start of day for consistent comparison - currentDate.setHours(0, 0, 0, 0) + const currentDate = normalizeToUtcStartOfDay() // Calculate billing period length - use getDaysBetweenDates for consistency // Simply calculate days between billing start and current date + 1 (to include today) @@ -535,10 +529,7 @@ function filterToBillingPeriod(fullData: { mau: number[], storage: number[], sto // Map 30-day data to billing period for (let i = 0; i < 30; i++) { - const dataDate = new Date(last30DaysStart) - dataDate.setDate(dataDate.getDate() + i) - // Reset to start of day for consistent comparison - dataDate.setHours(0, 0, 0, 0) + const dataDate = addUtcDays(last30DaysStart, i) // Check if this date falls within current billing period if (dataDate >= billingStart && dataDate <= currentDate) { @@ -556,20 +547,14 @@ function filterToBillingPeriod(fullData: { mau: number[], storage: number[], sto } async function getUsages(forceRefetch = false) { - // Always work with last 30 days of data - // End date should be tomorrow at midnight to include all of today's data - const last30DaysEnd = new Date() - last30DaysEnd.setHours(0, 0, 0, 0) - last30DaysEnd.setDate(last30DaysEnd.getDate() + 1) // Tomorrow midnight - // Start date should be 29 days ago at midnight (to get 30 days total including today) - const last30DaysStart = new Date() - last30DaysStart.setHours(0, 0, 0, 0) - last30DaysStart.setDate(last30DaysStart.getDate() - 29) + // Always work with last 30 UTC days of data + // End date should be next UTC midnight to include all of today's UTC data + const todayUtc = normalizeToUtcStartOfDay() + const last30DaysEnd = addUtcDays(todayUtc, 1) + const last30DaysStart = addUtcDays(todayUtc, -29) // Get billing period dates for filtering - const billingStart = new Date(effectiveOrganization.value?.subscription_start ?? new Date()) - // Reset to start of day to match calculation in store - billingStart.setHours(0, 0, 0, 0) + const billingStart = normalizeToUtcStartOfDay(new Date(effectiveOrganization.value?.subscription_start ?? new Date())) const currentOrgId = effectiveOrganization.value?.gid ?? null @@ -641,9 +626,7 @@ async function getUsages(forceRefetch = false) { const { global: globalStats, byApp: byAppStats, appNames: appNamesMap } = await getAppStats(last30DaysStart, last30DaysEnd) const finalData = globalStats.map((item: any) => { - const itemDate = new Date(item.date) - // Reset to start of day for consistent date handling - itemDate.setHours(0, 0, 0, 0) + const itemDate = normalizeToUtcStartOfDay(new Date(item.date)) return { ...item, date: itemDate, diff --git a/src/composables/useBuildChartConfig.ts b/src/composables/useBuildChartConfig.ts index 0076c8b577..138f6c6f92 100644 --- a/src/composables/useBuildChartConfig.ts +++ b/src/composables/useBuildChartConfig.ts @@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n' import { getTodayLimit } from '~/services/buildCharts' import { createLegendConfig, createStackedChartScales } from '~/services/chartConfig' import { createTooltipConfig } from '~/services/chartTooltip' -import { generateMonthDays } from '~/services/date' +import { generateMonthDays, normalizeToUtcStartOfDay } from '~/services/date' import { useOrganizationStore } from '~/stores/organization' interface BuildChartConfigProps { @@ -23,9 +23,7 @@ export function useBuildChartConfig(props: BuildChartConfigProps, options: { sta function resolveCycle(field: 'subscription_start' | 'subscription_end') { const org = organizationStore.getOrgByAppId(props.appId) ?? organizationStore.currentOrganization - const date = new Date(org?.[field] ?? new Date()) - date.setHours(0, 0, 0, 0) - return date + return normalizeToUtcStartOfDay(new Date(org?.[field] ?? new Date())) } const cycleStart = computed(() => resolveCycle('subscription_start')) const cycleEnd = computed(() => resolveCycle('subscription_end')) diff --git a/src/composables/useOrgBillingCycleChart.ts b/src/composables/useOrgBillingCycleChart.ts new file mode 100644 index 0000000000..18bcb68d95 --- /dev/null +++ b/src/composables/useOrgBillingCycleChart.ts @@ -0,0 +1,72 @@ +import type { MaybeRefOrGetter } from 'vue' +import { computed, toValue } from 'vue' +import { getTodayLimit, transformSeries } from '~/services/buildCharts' +import { normalizeToUtcStartOfDay } from '~/services/date' +import { useOrganizationStore } from '~/stores/organization' + +/** + * Shared UTC billing-cycle helpers for dashboard daily charts. + * Keeps today-limit / series transform logic in one place for Sonar + consistency. + */ +export function useOrgBillingCycleChart( + useBillingPeriod: MaybeRefOrGetter, + subscriptionStart?: MaybeRefOrGetter, + subscriptionEnd?: MaybeRefOrGetter, +) { + function resolveCycleStart() { + return normalizeToUtcStartOfDay(new Date(toValue(subscriptionStart) ?? new Date())) + } + + function resolveCycleEnd() { + const today = normalizeToUtcStartOfDay() + const rawEnd = toValue(subscriptionEnd) + if (!rawEnd) + return today + + const end = normalizeToUtcStartOfDay(new Date(rawEnd)) + if (Number.isNaN(end.getTime())) + return today + + // Active cycle: stop at today so future empty days are not rendered. + // Expired cycle: keep subscription_end so the period does not stretch to today. + return end.getTime() > today.getTime() ? today : end + } + + function todayLimit(labelCount: number) { + return getTodayLimit(labelCount, toValue(useBillingPeriod), resolveCycleStart(), resolveCycleEnd()) + } + + function transformDailySeries(source: number[], accumulated: boolean, labelCount: number) { + return transformSeries(source, accumulated, labelCount, todayLimit(labelCount)) + } + + return { + resolveCycleStart, + resolveCycleEnd, + todayLimit, + transformDailySeries, + } +} + +/** Convenience wrapper for charts scoped to the current organization. */ +export function useCurrentOrgBillingCycleChart(useBillingPeriod: MaybeRefOrGetter) { + const organizationStore = useOrganizationStore() + return useOrgBillingCycleChart( + useBillingPeriod, + () => organizationStore.currentOrganization?.subscription_start, + () => organizationStore.currentOrganization?.subscription_end, + ) +} + +/** Reactive current-org cycle bounds plus today/transform helpers. */ +export function useDashboardDailyChartCycle(useBillingPeriod: MaybeRefOrGetter) { + const chartCycle = useCurrentOrgBillingCycleChart(useBillingPeriod) + const cycleStart = computed(() => chartCycle.resolveCycleStart()) + const cycleEnd = computed(() => chartCycle.resolveCycleEnd()) + return { + cycleStart, + cycleEnd, + todayLimit: chartCycle.todayLimit, + transformDailySeries: chartCycle.transformDailySeries, + } +} diff --git a/src/services/buildCharts.ts b/src/services/buildCharts.ts index 04ab3ece92..0a78c6510c 100644 --- a/src/services/buildCharts.ts +++ b/src/services/buildCharts.ts @@ -1,5 +1,7 @@ // Shared helpers for the per-app build charts (Builds by status + Build time). +import { addUtcDays, normalizeToUtcStartOfDay } from '~/services/date' + const DAY_IN_MS = 1000 * 60 * 60 * 24 export const WINDOW_DAYS = 30 @@ -45,7 +47,7 @@ export function buildSeriesKey(platform: string | null | undefined, outcome: 'su } export interface BuildChartWindow { - windowStart: Date // local midnight of the first rendered day + windowStart: Date // UTC midnight of the first rendered day startISO: string // inclusive lower bound for created_at queries endISO: string // inclusive upper bound (end of today) dayCount: number // number of days from windowStart..today inclusive @@ -57,25 +59,22 @@ export interface BuildChartWindow { // mode it is the trailing 30 days. Either way data is bucketed by day from // windowStart, so there is no separate billing remapping step. export function getBuildChartWindow(useBillingPeriod: boolean, subscriptionStart?: string | null): BuildChartWindow { - const todayStart = new Date() - todayStart.setHours(0, 0, 0, 0) + const todayStart = normalizeToUtcStartOfDay() const endOfToday = new Date() - endOfToday.setHours(23, 59, 59, 999) let windowStart = new Date(todayStart) - const cycleStart = subscriptionStart ? new Date(subscriptionStart) : null + const cycleStart = subscriptionStart ? normalizeToUtcStartOfDay(new Date(subscriptionStart)) : null if (useBillingPeriod && cycleStart && !Number.isNaN(cycleStart.getTime())) { - cycleStart.setHours(0, 0, 0, 0) const elapsedDays = Math.floor((todayStart.getTime() - cycleStart.getTime()) / DAY_IN_MS) // Guard against a future or implausibly old anchor; fall back to 30 days. if (elapsedDays >= 0 && elapsedDays <= 366) windowStart = cycleStart else - windowStart.setDate(windowStart.getDate() - (WINDOW_DAYS - 1)) + windowStart = addUtcDays(windowStart, -(WINDOW_DAYS - 1)) } else { - windowStart.setDate(windowStart.getDate() - (WINDOW_DAYS - 1)) + windowStart = addUtcDays(windowStart, -(WINDOW_DAYS - 1)) } const dayCount = Math.max(Math.floor((todayStart.getTime() - windowStart.getTime()) / DAY_IN_MS) + 1, 1) @@ -121,8 +120,7 @@ export function getTodayLimit(labelCount: number, useBillingPeriod: boolean, cyc if (!useBillingPeriod) return labelCount - 1 - const today = new Date() - today.setHours(0, 0, 0, 0) + const today = normalizeToUtcStartOfDay() if (cycleEnd <= today) return labelCount - 1 diff --git a/src/services/chartDataService.ts b/src/services/chartDataService.ts index de66d086bb..6cc6d130b0 100644 --- a/src/services/chartDataService.ts +++ b/src/services/chartDataService.ts @@ -2,27 +2,21 @@ import type { SupabaseClient } from '@supabase/supabase-js' import colors from 'tailwindcss/colors' import { ref } from 'vue' import { invokeCapgoApi } from '~/services/capgoApi' +import { formatUtcDateParam, normalizeToUtcStartOfDay } from '~/services/date' const SKIP_COLOR = 10 const colorKeys = Object.keys(colors) const chartDataCache = ref>(new Map()) -function formatDateParam(date: Date) { - const normalized = new Date(date) - normalized.setUTCHours(0, 0, 0, 0) - return normalized.toISOString().slice(0, 10) -} - function clampToToday(date: Date): Date { - const today = new Date() - today.setUTCHours(0, 0, 0, 0) + const today = normalizeToUtcStartOfDay() return date > today ? today : date } type VersionUsageKind = 'bundle' | 'native' function buildCacheKey(appId: string, from: Date, to: Date, kind: VersionUsageKind) { - return `${appId}|${kind}|${formatDateParam(from)}|${formatDateParam(to)}` + return `${appId}|${kind}|${formatUtcDateParam(from)}|${formatUtcDateParam(to)}` } export async function useChartData(supabase: SupabaseClient, appId: string, from: Date, to: Date, kind: VersionUsageKind = 'bundle') { @@ -33,8 +27,8 @@ export async function useChartData(supabase: SupabaseClient, appId: string, from // Clamp the 'to' date to today - we can't fetch data for future dates const clampedTo = clampToToday(to) - const fromParam = formatDateParam(from) - const toParam = formatDateParam(clampedTo) + const fromParam = formatUtcDateParam(from) + const toParam = formatUtcDateParam(clampedTo) const usagePath = kind === 'native' ? 'native_usage' : 'bundle_usage' const { error, data } = await invokeCapgoApi(`statistics/app/${appId}/${usagePath}?from=${fromParam}&to=${toParam}`, { client: supabase, diff --git a/src/services/chartTodayLine.ts b/src/services/chartTodayLine.ts new file mode 100644 index 0000000000..a79a2870c8 --- /dev/null +++ b/src/services/chartTodayLine.ts @@ -0,0 +1,58 @@ +export function createTodayLineOptions(input: { + useBillingPeriod: boolean + index: number + labelCount: number + label: string + isDark: boolean +}) { + if (!input.useBillingPeriod || input.index < 0 || input.index >= input.labelCount) + return { enabled: false as const } + + const strokeColor = input.isDark ? 'rgba(165, 180, 252, 0.75)' : 'rgba(99, 102, 241, 0.7)' + const glowColor = input.isDark ? 'rgba(129, 140, 248, 0.35)' : 'rgba(165, 180, 252, 0.35)' + const badgeFill = input.isDark ? 'rgba(67, 56, 202, 0.45)' : 'rgba(199, 210, 254, 0.85)' + const textColor = input.isDark ? '#e0e7ff' : '#312e81' + + return { + enabled: true as const, + xIndex: input.index, + label: input.label, + color: strokeColor, + glowColor, + badgeFill, + textColor, + } +} + +/** Skip red/green hues reserved for update success/fail series. */ +export function isReservedChartHue(hue: number): boolean { + return (hue >= 0 && hue <= 30) || (hue >= 330 && hue <= 360) || (hue >= 90 && hue <= 160) +} + +export function getSafeChartHue(targetIndex: number): number { + let i = 0 + let safeCount = 0 + + while (safeCount <= targetIndex && i < targetIndex * 3 + 10) { + const hue = (210 + i * 137.508) % 360 + i++ + if (!isReservedChartHue(hue)) { + if (safeCount === targetIndex) + return hue + safeCount++ + } + } + + return 210 +} + +export function generateAppChartColors(appCount: number): string[] { + const colors: string[] = [] + for (let colorIndex = 0; colorIndex < appCount; colorIndex++) { + const hue = getSafeChartHue(colorIndex) + const saturation = 50 + (colorIndex % 3) * 8 + const lightness = 60 + (colorIndex % 4) * 5 + colors.push(`hsla(${hue}, ${saturation}%, ${lightness}%, 0.8)`) + } + return colors +} diff --git a/src/services/chartTooltip.ts b/src/services/chartTooltip.ts index ba58aa47dd..00db71826f 100644 --- a/src/services/chartTooltip.ts +++ b/src/services/chartTooltip.ts @@ -1,6 +1,6 @@ import type { Chart, TooltipItem as ChartTooltipItem, TooltipLabelStyle, TooltipModel } from 'chart.js' import { useDark } from '@vueuse/core' -import { formatLocalDateLong } from '~/services/date' +import { formatLocalDateLong, utcCalendarDayAsLocalDate } from '~/services/date' import { formatNumberValue } from '~/services/formatLocale' interface TooltipContext { @@ -61,19 +61,19 @@ function formatTooltipValue(value: unknown) { */ function getDateFromIndex(dataIndex: number, dateStartOrUseBillingPeriod?: Date | boolean): Date { const today = new Date() - today.setHours(0, 0, 0, 0) + today.setUTCHours(0, 0, 0, 0) if (dateStartOrUseBillingPeriod instanceof Date) { - // Billing period mode: start from billing start date + // Billing period mode: start from billing start date (UTC day) const date = new Date(dateStartOrUseBillingPeriod) - date.setHours(0, 0, 0, 0) - date.setDate(date.getDate() + dataIndex) + date.setUTCHours(0, 0, 0, 0) + date.setUTCDate(date.getUTCDate() + dataIndex) return date } // Last 30 days mode (dateStartOrUseBillingPeriod is false or undefined) const date = new Date(today) - date.setDate(date.getDate() - 29 + dataIndex) // 29 days ago + index + date.setUTCDate(date.getUTCDate() - 29 + dataIndex) // 29 days ago + index return date } @@ -81,7 +81,7 @@ function getDateFromIndex(dataIndex: number, dateStartOrUseBillingPeriod?: Date * Format a date for tooltip display using the app's locale (e.g., "December 10" in English, "10 décembre" in French) */ function formatDateForTooltip(date: Date): string { - return formatLocalDateLong(date) + return formatLocalDateLong(utcCalendarDayAsLocalDate(date)) } function getDatasetBaseValue( diff --git a/src/services/conversion.ts b/src/services/conversion.ts index 968a18f89d..595833d8ae 100644 --- a/src/services/conversion.ts +++ b/src/services/conversion.ts @@ -33,9 +33,9 @@ export function getDaysBetweenDates(date1: string | Date, date2: string | Date) const oneDay = 24 * 60 * 60 * 1000 const firstDate = new Date(date1) const secondDate = new Date(date2) - // Normalize both dates to midnight (start of day) to avoid timezone/time-of-day issues - firstDate.setHours(0, 0, 0, 0) - secondDate.setHours(0, 0, 0, 0) + // Normalize both dates to UTC midnight — dashboard stats are bucketed by UTC day + firstDate.setUTCHours(0, 0, 0, 0) + secondDate.setUTCHours(0, 0, 0, 0) const res = Math.round(Math.abs((firstDate.valueOf() - secondDate.valueOf()) / oneDay)) return res } diff --git a/src/services/dailyChartProps.ts b/src/services/dailyChartProps.ts new file mode 100644 index 0000000000..7583f70fb0 --- /dev/null +++ b/src/services/dailyChartProps.ts @@ -0,0 +1,15 @@ +import { getDaysInCurrentUtcMonth } from '~/services/date' + +/** Shared prop definitions for dashboard daily bar/line charts. */ +export function dailyChartBaseProps() { + return { + title: { type: String, default: '' }, + colors: { type: Object, default: () => ({}) }, + limits: { type: Object, default: () => ({}) }, + data: { type: Array, default: () => Array.from({ length: getDaysInCurrentUtcMonth() }).fill(0) as number[] }, + dataByApp: { type: Object, default: () => ({}) }, + appNames: { type: Object, default: () => ({}) }, + useBillingPeriod: { type: Boolean, default: true }, + accumulated: { type: Boolean, default: false }, + } +} diff --git a/src/services/dashboardChartRegister.ts b/src/services/dashboardChartRegister.ts new file mode 100644 index 0000000000..fe20da9411 --- /dev/null +++ b/src/services/dashboardChartRegister.ts @@ -0,0 +1,34 @@ +import { + BarController, + BarElement, + CategoryScale, + Chart, + Filler, + Legend, + LinearScale, + LineController, + LineElement, + PointElement, + Tooltip, +} from 'chart.js' + +let registered = false + +/** Register Chart.js controllers once for dashboard bar/line charts. */ +export function registerDashboardCharts() { + if (registered) + return + Chart.register( + Tooltip, + Legend, + BarController, + BarElement, + LineController, + LineElement, + PointElement, + CategoryScale, + LinearScale, + Filler, + ) + registered = true +} diff --git a/src/services/date.ts b/src/services/date.ts index 1fff9f700f..e808d0879b 100644 --- a/src/services/date.ts +++ b/src/services/date.ts @@ -129,26 +129,106 @@ export function getDaysInCurrentMonth() { ).getDate() } +/** Days in the current UTC calendar month (for UTC-bucketed dashboard charts). */ +export function getDaysInCurrentUtcMonth() { + const date = new Date() + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0)).getUTCDate() +} + export function getCurrentDayMonth() { const date = new Date() return date.getDate() } -export function normalizeToStartOfDay(date: Date) { +/** Inclusive UTC day start and end instants for log / range navigation. */ +export function getUtcDayBounds(date: Date) { + const start = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0, 0)) + const end = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 23, 59, 59, 999)) + return { start, end } +} + +/** + * Start of the UTC calendar day. + * Dashboard daily stats (daily_mau, daily_version, CF Analytics) are bucketed in UTC, + * so chart ranges and API date params must use UTC day boundaries — not the browser's local midnight. + */ +export function normalizeToUtcStartOfDay(date: Date = new Date()) { const normalized = new Date(date) - normalized.setHours(0, 0, 0, 0) + normalized.setUTCHours(0, 0, 0, 0) return normalized } +export function addUtcDays(date: Date, days: number) { + const next = new Date(date) + next.setUTCDate(next.getUTCDate() + days) + return next +} + +/** + * Parse a billing / chart range boundary as a UTC calendar day. + * Date-only strings stay on that UTC day (unlike parseDatePreservingUtc local midnight). + */ +function parseUtcRangeBoundary(date: Date | string | undefined | null): Date | null { + if (!date) + return null + + if (date instanceof Date) + return Number.isNaN(date.getTime()) ? null : date + + const dateOnlyMatch = DATE_ONLY_RE.exec(date) + if (dateOnlyMatch) { + const [, year, month, day] = dateOnlyMatch + const parsedYear = Number(year) + const parsedMonth = Number(month) + const parsedDay = Number(day) + const parsed = new Date(Date.UTC(parsedYear, parsedMonth - 1, parsedDay)) + if ( + Number.isNaN(parsed.getTime()) + || parsed.getUTCFullYear() !== parsedYear + || parsed.getUTCMonth() !== parsedMonth - 1 + || parsed.getUTCDate() !== parsedDay + ) { + return null + } + return parsed + } + + const normalized = ZONELESS_ISO_DATETIME_RE.test(date) ? `${date}Z` : date + const parsed = new Date(normalized) + return Number.isNaN(parsed.getTime()) ? null : parsed +} + +/** + * YYYY-MM-DD for API / DB date filters using the UTC calendar day. + * Never derive this from local midnight via toISOString() — that shifts the day for viewers east of UTC. + */ +export function formatUtcDateParam(date: Date | string = new Date()) { + let parsed: Date + if (typeof date === 'string' && DATE_ONLY_RE.test(date)) + parsed = new Date(`${date}T00:00:00.000Z`) + else if (typeof date === 'string' && ZONELESS_ISO_DATETIME_RE.test(date)) + parsed = new Date(`${date}Z`) + else + parsed = new Date(date) + if (Number.isNaN(parsed.getTime())) + return '' + return parsed.toISOString().slice(0, 10) +} + +/** Local Date with the same Y-M-D as the UTC calendar day (for localized chart labels). */ +export function utcCalendarDayAsLocalDate(date: Date) { + return new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) +} + function getDatesInRange(startDate: Date, endDate: Date) { const dates = [] - const currentDate = normalizeToStartOfDay(startDate) - const normalizedEndDate = normalizeToStartOfDay(endDate) + const currentDate = normalizeToUtcStartOfDay(startDate) + const normalizedEndDate = normalizeToUtcStartOfDay(endDate) while (currentDate.getTime() <= normalizedEndDate.getTime()) { - dates.push(new Date(currentDate)) - currentDate.setDate(currentDate.getDate() + 1) + dates.push(utcCalendarDayAsLocalDate(currentDate)) + currentDate.setUTCDate(currentDate.getUTCDate() + 1) } return dates @@ -156,16 +236,13 @@ function getDatesInRange(startDate: Date, endDate: Date) { export function getChartDateRange(useBillingPeriod: boolean, billingStart?: Date | string | null, billingEnd?: Date | string | null) { if (useBillingPeriod) { - const startDate = parseDatePreservingUtc(billingStart) ?? new Date() - const endDate = parseDatePreservingUtc(billingEnd) ?? new Date() - startDate.setHours(0, 0, 0, 0) - endDate.setHours(0, 0, 0, 0) + const startDate = normalizeToUtcStartOfDay(parseUtcRangeBoundary(billingStart) ?? new Date()) + const endDate = normalizeToUtcStartOfDay(parseUtcRangeBoundary(billingEnd) ?? new Date()) return { startDate, endDate } } - const endDate = normalizeToStartOfDay(new Date()) - const startDate = new Date(endDate) - startDate.setDate(startDate.getDate() - 29) + const endDate = normalizeToUtcStartOfDay(new Date()) + const startDate = addUtcDays(endDate, -29) return { startDate, endDate } } diff --git a/src/services/supabase.ts b/src/services/supabase.ts index 1031296ccb..012e108ffe 100644 --- a/src/services/supabase.ts +++ b/src/services/supabase.ts @@ -394,12 +394,13 @@ function parseDashboardRangeDate(value?: string) { } export function normalizeDashboardDateRange(startDate?: string, endDate?: string, now: Date = new Date()) { + // Exclusive end = next UTC midnight so the current UTC day is fully included const fallbackEnd = new Date(now) - fallbackEnd.setHours(0, 0, 0, 0) - fallbackEnd.setDate(fallbackEnd.getDate() + 1) + fallbackEnd.setUTCHours(0, 0, 0, 0) + fallbackEnd.setUTCDate(fallbackEnd.getUTCDate() + 1) const fallbackStart = new Date(fallbackEnd) - fallbackStart.setDate(fallbackStart.getDate() - 30) + fallbackStart.setUTCDate(fallbackStart.getUTCDate() - 30) const parsedStart = parseDashboardRangeDate(startDate) const parsedEnd = parseDashboardRangeDate(endDate) diff --git a/src/stores/main.ts b/src/stores/main.ts index e1555a9e5f..92d5345ecd 100644 --- a/src/stores/main.ts +++ b/src/stores/main.ts @@ -4,6 +4,7 @@ import type { Database } from '~/types/supabase.types' import { acceptHMRUpdate, defineStore } from 'pinia' import { ref } from 'vue' import { getDaysBetweenDates } from '~/services/conversion' +import { normalizeToUtcStartOfDay } from '~/services/date' import { reset } from '~/services/posthog' import { clearSpoof, @@ -87,15 +88,9 @@ export const useMainStore = defineStore('main', () => { } const calculateMonthDay = (subscriptionStart: string | undefined) => { - // Parse dates consistently - ensure we're handling them the same way - // If subscriptionStart is provided, parse it as-is (should be in ISO format from DB) - // Otherwise use current date - const startDate = subscriptionStart ? new Date(subscriptionStart) : new Date() - const currentDate = new Date() - - // Reset both dates to start of day to avoid time component issues - startDate.setHours(0, 0, 0, 0) - currentDate.setHours(0, 0, 0, 0) + // Parse dates consistently using UTC day boundaries (dashboard stats are UTC-bucketed) + const startDate = normalizeToUtcStartOfDay(subscriptionStart ? new Date(subscriptionStart) : new Date()) + const currentDate = normalizeToUtcStartOfDay() const daysInMonth = new Date(Date.UTC(currentDate.getUTCFullYear(), currentDate.getUTCMonth() + 1, 0)).getUTCDate() return (getDaysBetweenDates(startDate, currentDate) % daysInMonth || daysInMonth) - 1 @@ -138,12 +133,8 @@ export const useMainStore = defineStore('main', () => { const appData = dashboardByapp.value.filter(d => d.app_id === appId) // Calculate how many days into the billing cycle we are - const startDate = subscriptionStart ? new Date(subscriptionStart) : new Date() - const currentDate = new Date() - - // Reset to start of day for consistent comparison - startDate.setHours(0, 0, 0, 0) - currentDate.setHours(0, 0, 0, 0) + const startDate = normalizeToUtcStartOfDay(subscriptionStart ? new Date(subscriptionStart) : new Date()) + const currentDate = normalizeToUtcStartOfDay() // Calculate days in billing cycle const daysInBillingCycle = Math.floor((currentDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) + 1 diff --git a/src/stores/organization.ts b/src/stores/organization.ts index 96047ad06e..562b188df1 100644 --- a/src/stores/organization.ts +++ b/src/stores/organization.ts @@ -3,6 +3,7 @@ import type { ComputedRef, Ref } from 'vue' import type { Database } from '~/types/supabase.types' import { defineStore } from 'pinia' import { computed, ref, watch } from 'vue' +import { addUtcDays, normalizeToUtcStartOfDay } from '~/services/date' import { createSignedImageUrl, getImmediateImageUrl, resolveImagePath } from '~/services/storage' import { isPlatformAdmin, stripeEnabled, useSupabase } from '~/services/supabase' import { clearWebsitePaidUserCookie, setWebsitePaidUserCookie, syncWebsitePaidUserCookieFromOrganizations } from '~/services/websiteAuthCookie' @@ -356,14 +357,11 @@ export const useOrganizationStore = defineStore('organization', () => { dashboardAppsStore.fetchApps(true) } - // Always fetch last 30 days of data and filter client-side for billing period - // End date should be tomorrow at midnight to include all of today's data - const last30DaysEnd = new Date() - last30DaysEnd.setHours(0, 0, 0, 0) - last30DaysEnd.setDate(last30DaysEnd.getDate() + 1) // Tomorrow midnight - const last30DaysStart = new Date() - last30DaysStart.setHours(0, 0, 0, 0) - last30DaysStart.setDate(last30DaysStart.getDate() - 29) // 30 days including today + // Always fetch last 30 UTC days of data and filter client-side for billing period + // End date should be next UTC midnight to include all of today's UTC data + const todayUtc = normalizeToUtcStartOfDay() + const last30DaysEnd = addUtcDays(todayUtc, 1) + const last30DaysStart = addUtcDays(todayUtc, -29) try { await main.updateDashboard(currentOrganizationRaw.gid, last30DaysStart.toISOString(), last30DaysEnd.toISOString()) } diff --git a/src/utils/chartOptimizations.ts b/src/utils/chartOptimizations.ts index ae96156c99..1e81e0afd5 100644 --- a/src/utils/chartOptimizations.ts +++ b/src/utils/chartOptimizations.ts @@ -2,6 +2,10 @@ * Optimized chart data processing utilities */ +import { addUtcDays, normalizeToUtcStartOfDay } from '~/services/date' + +const DAY_MS = 1000 * 60 * 60 * 24 + /** * Fast array initialization with undefined values */ @@ -17,3 +21,64 @@ export function createUndefinedArray(length: number): (number | undefined)[] { export function incrementArrayValue(arr: (number | undefined)[], index: number, increment: number): void { arr[index] = (arr[index] === undefined ? 0 : arr[index]) + increment } + +export interface DashboardDailySeriesWindow { + /** Inclusive UTC midnight of the first fetched/bucketed day */ + seriesStart: Date + /** UTC midnight of today */ + todayUtc: Date + /** Exclusive upper bound (next UTC midnight) for timestamp queries */ + exclusiveEnd: Date + dayCount: number +} + +/** + * Fetch/render window for dashboard daily series. + * Billing mode starts at the cycle anchor so 31-day cycles keep day 1. + */ +export function resolveDashboardDailySeriesWindow( + useBillingPeriod: boolean, + billingStart: Date, + now: Date = new Date(), +): DashboardDailySeriesWindow { + const todayUtc = normalizeToUtcStartOfDay(now) + const exclusiveEnd = addUtcDays(todayUtc, 1) + let seriesStart = addUtcDays(todayUtc, -29) + const cycleStart = normalizeToUtcStartOfDay(billingStart) + + if (useBillingPeriod && !Number.isNaN(cycleStart.getTime())) { + const elapsedDays = Math.floor((todayUtc.getTime() - cycleStart.getTime()) / DAY_MS) + if (elapsedDays >= 0 && elapsedDays <= 366) + seriesStart = cycleStart + } + + const dayCount = Math.max(Math.floor((todayUtc.getTime() - seriesStart.getTime()) / DAY_MS) + 1, 1) + return { seriesStart, todayUtc, exclusiveEnd, dayCount } +} + +/** + * Remap a trailing UTC daily series onto the current billing cycle length. + * Days are indexed from seriesStart (UTC midnight). + */ +export function filterDailySeriesToBillingPeriod(fullData: number[], seriesStart: Date, billingStart: Date) { + const currentDate = normalizeToUtcStartOfDay() + const cycleStart = normalizeToUtcStartOfDay(billingStart) + + if (Number.isNaN(cycleStart.getTime()) || cycleStart.getTime() > currentDate.getTime()) + return { data: [] as number[] } + + const currentBillingDay = Math.floor((currentDate.getTime() - cycleStart.getTime()) / DAY_MS) + 1 + const billingData = Array.from({ length: currentBillingDay }).fill(0) as number[] + const windowStart = normalizeToUtcStartOfDay(seriesStart) + + for (let i = 0; i < fullData.length; i++) { + const dataDate = addUtcDays(windowStart, i) + if (dataDate.getTime() < cycleStart.getTime() || dataDate.getTime() > currentDate.getTime()) + continue + const billingIndex = Math.floor((dataDate.getTime() - cycleStart.getTime()) / DAY_MS) + if (billingIndex >= 0 && billingIndex < currentBillingDay) + billingData[billingIndex] = fullData[i] ?? 0 + } + + return { data: billingData } +} diff --git a/src/utils/minDelay.ts b/src/utils/minDelay.ts new file mode 100644 index 0000000000..0c90dba352 --- /dev/null +++ b/src/utils/minDelay.ts @@ -0,0 +1,6 @@ +/** Ensure at least `minMs` elapsed since `startTime` before continuing (spinner UX). */ +export async function ensureMinDelay(startTime: number, minMs = 300) { + const elapsed = Date.now() - startTime + if (elapsed < minMs) + await new Promise(resolve => setTimeout(resolve, minMs - elapsed)) +} diff --git a/tests/chart-optimizations.unit.test.ts b/tests/chart-optimizations.unit.test.ts new file mode 100644 index 0000000000..9ef5a0aa93 --- /dev/null +++ b/tests/chart-optimizations.unit.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest' +import { + filterDailySeriesToBillingPeriod, + resolveDashboardDailySeriesWindow, +} from '../src/utils/chartOptimizations' + +describe('chart optimizations billing window', () => { + it('uses elapsed UTC days across month-length boundaries', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-04-01T12:00:00.000Z')) + + try { + const billingStart = new Date('2026-03-30T00:00:00.000Z') + const seriesStart = new Date('2026-03-03T00:00:00.000Z') + const fullData = Array.from({ length: 30 }).fill(0) as number[] + // Index for March 30, 31, April 1 relative to seriesStart (Mar 3) + fullData[27] = 1 + fullData[28] = 2 + fullData[29] = 3 + + const { data } = filterDailySeriesToBillingPeriod(fullData, seriesStart, billingStart) + expect(data).toHaveLength(3) + expect(data).toEqual([1, 2, 3]) + } + finally { + vi.useRealTimers() + } + }) + + it.concurrent('starts billing fetch windows at the cycle anchor for 31-day cycles', () => { + const now = new Date('2026-03-31T15:00:00.000Z') + const billingStart = new Date('2026-03-01T00:00:00.000Z') + const window = resolveDashboardDailySeriesWindow(true, billingStart, now) + + expect(window.seriesStart.toISOString()).toBe('2026-03-01T00:00:00.000Z') + expect(window.dayCount).toBe(31) + expect(window.exclusiveEnd.toISOString()).toBe('2026-04-01T00:00:00.000Z') + }) + + it.concurrent('keeps trailing 30 days when not using billing period', () => { + const now = new Date('2026-03-31T15:00:00.000Z') + const billingStart = new Date('2026-03-01T00:00:00.000Z') + const window = resolveDashboardDailySeriesWindow(false, billingStart, now) + + expect(window.seriesStart.toISOString()).toBe('2026-03-02T00:00:00.000Z') + expect(window.dayCount).toBe(30) + }) +}) diff --git a/tests/dashboard-date-range.unit.test.ts b/tests/dashboard-date-range.unit.test.ts index 2fdef21b45..c057cdcbd7 100644 --- a/tests/dashboard-date-range.unit.test.ts +++ b/tests/dashboard-date-range.unit.test.ts @@ -3,11 +3,11 @@ import { normalizeDashboardDateRange } from '~/services/supabase' function createFallbackWindow(now: Date) { const end = new Date(now) - end.setHours(0, 0, 0, 0) - end.setDate(end.getDate() + 1) + end.setUTCHours(0, 0, 0, 0) + end.setUTCDate(end.getUTCDate() + 1) const start = new Date(end) - start.setDate(start.getDate() - 30) + start.setUTCDate(start.getUTCDate() - 30) return { start: start.toISOString(), @@ -35,6 +35,18 @@ describe('dashboard date range normalization', () => { expect(normalizeDashboardDateRange(undefined, undefined, now)).toEqual(createFallbackWindow(now)) }) + it.concurrent('uses UTC day boundaries so Europe and Brazil share the same fallback window', () => { + // Same UTC instant: afternoon UTC on Apr 21 + const now = new Date('2026-04-21T15:45:00.000Z') + const window = normalizeDashboardDateRange(undefined, undefined, now) + + // UTC today is Apr 21 → exclusive end is Apr 22 00:00Z; start is 30 days before that + expect(window).toEqual({ + start: '2026-03-23T00:00:00.000Z', + end: '2026-04-22T00:00:00.000Z', + }) + }) + it.concurrent('falls back to the default window when either bound is invalid', () => { const now = new Date('2026-04-21T15:45:00.000Z') expect(normalizeDashboardDateRange('not-a-date', '2026-04-30T00:00:00.000Z', now)).toEqual(createFallbackWindow(now)) diff --git a/tests/date.unit.test.ts b/tests/date.unit.test.ts index ef13238dbd..44e5ba095e 100644 --- a/tests/date.unit.test.ts +++ b/tests/date.unit.test.ts @@ -7,11 +7,16 @@ import { formatLocalDateTimeWithSeconds, formatLocalMonthYear, formatLocalTime, + formatUtcDateParam, formatUtcDateTimeAsLocal, generateChartDayLabels, generateMonthDays, + getChartDateRange, getDateLocale, + getUtcDayBounds, + normalizeToUtcStartOfDay, resolveDateLocale, + utcCalendarDayAsLocalDate, } from '../src/services/date' import { useMainStore } from '../src/stores/main' @@ -89,24 +94,67 @@ describe('date helpers', () => { }) it('generates localized labels for chart date ranges', () => { - const startDate = new Date(2026, 0, 31) - const endDate = new Date(2026, 1, 2) + const startDate = new Date(Date.UTC(2026, 0, 31)) + const endDate = new Date(Date.UTC(2026, 1, 2)) expect(generateChartDayLabels(true, startDate, endDate)).toEqual([ - formatLocalDateShort(startDate), - formatLocalDateShort(new Date(2026, 1, 1)), - formatLocalDateShort(endDate), + formatLocalDateShort(utcCalendarDayAsLocalDate(startDate)), + formatLocalDateShort(utcCalendarDayAsLocalDate(new Date(Date.UTC(2026, 1, 1)))), + formatLocalDateShort(utcCalendarDayAsLocalDate(endDate)), ]) }) it('generates localized labels for billing-cycle charts', () => { - const cycleStart = new Date(2026, 2, 30) - const cycleEnd = new Date(2026, 3, 1) + const cycleStart = new Date(Date.UTC(2026, 2, 30)) + const cycleEnd = new Date(Date.UTC(2026, 3, 1)) expect(generateMonthDays(true, cycleStart, cycleEnd)).toEqual([ - formatLocalDateShort(cycleStart), - formatLocalDateShort(new Date(2026, 2, 31)), - formatLocalDateShort(cycleEnd), + formatLocalDateShort(utcCalendarDayAsLocalDate(cycleStart)), + formatLocalDateShort(utcCalendarDayAsLocalDate(new Date(Date.UTC(2026, 2, 31)))), + formatLocalDateShort(utcCalendarDayAsLocalDate(cycleEnd)), ]) }) + + it('formats UTC date params without shifting for local midnight east of UTC', () => { + // Europe-like: local Aug 7 00:00 is still Aug 6 in UTC for positive offsets. + // formatUtcDateParam must use the instant's UTC calendar day, not local→ISO. + const europeLocalMidnight = new Date('2026-08-06T22:00:00.000Z') // CEST Aug 7 00:00 + expect(formatUtcDateParam(europeLocalMidnight)).toBe('2026-08-06') + + const utcMidnight = new Date('2026-08-07T00:00:00.000Z') + expect(formatUtcDateParam(utcMidnight)).toBe('2026-08-07') + expect(formatUtcDateParam(normalizeToUtcStartOfDay(europeLocalMidnight))).toBe('2026-08-06') + }) + + it('builds last-30-days chart ranges on UTC midnight boundaries', () => { + const range = getChartDateRange(false) + expect(range.endDate.getUTCHours()).toBe(0) + expect(range.endDate.getUTCMinutes()).toBe(0) + expect(range.startDate.getUTCHours()).toBe(0) + expect(formatUtcDateParam(range.endDate)).toBe(formatUtcDateParam(normalizeToUtcStartOfDay(new Date()))) + const daySpan = Math.round((range.endDate.getTime() - range.startDate.getTime()) / (24 * 60 * 60 * 1000)) + expect(daySpan).toBe(29) + }) + + it('keeps formatUtcDateParam stable for date-only strings', () => { + expect(formatUtcDateParam('2026-08-07')).toBe('2026-08-07') + }) + + it('treats zone-less ISO datetimes as UTC in formatUtcDateParam', () => { + expect(formatUtcDateParam('2026-08-07T15:30:00')).toBe('2026-08-07') + }) + + it('parses date-only billing boundaries as UTC days in getChartDateRange', () => { + const range = getChartDateRange(true, '2026-08-07', '2026-09-07') + expect(formatUtcDateParam(range.startDate)).toBe('2026-08-07') + expect(formatUtcDateParam(range.endDate)).toBe('2026-09-07') + expect(range.startDate.toISOString()).toBe('2026-08-07T00:00:00.000Z') + }) + + it('builds UTC day bounds for log navigation without local shift', () => { + const selected = new Date('2026-08-07T00:00:00.000Z') + const { start, end } = getUtcDayBounds(selected) + expect(start.toISOString()).toBe('2026-08-07T00:00:00.000Z') + expect(end.toISOString()).toBe('2026-08-07T23:59:59.999Z') + }) })