diff --git a/src/actions/analytics/events.ts b/src/actions/analytics/events.ts index 38ab790657..c95d54b1b8 100644 --- a/src/actions/analytics/events.ts +++ b/src/actions/analytics/events.ts @@ -7,7 +7,7 @@ import { type RooCodeTelemetryEvent, } from '@roo-code/types'; -import type { TimePeriod } from '@/types'; +import type { AnyTimePeriod } from '@/types'; import { taskSchema } from '@/types/analytics'; import { analytics } from '@/lib/server'; import { type User, getUsersById } from '@/db/server'; @@ -80,7 +80,7 @@ export const getUsage = async ({ timePeriod = 90, }: { orgId?: string | null; - timePeriod?: TimePeriod; + timePeriod?: AnyTimePeriod; }): Promise => { if (!orgId) { return {}; @@ -134,7 +134,7 @@ export const getDeveloperUsage = async ({ timePeriod = 90, }: { orgId?: string | null; - timePeriod?: TimePeriod; + timePeriod?: AnyTimePeriod; }): Promise => { if (!orgId) { return []; @@ -196,7 +196,7 @@ export const getModelUsage = async ({ timePeriod = 90, }: { orgId?: string | null; - timePeriod?: TimePeriod; + timePeriod?: AnyTimePeriod; }): Promise => { if (!orgId) { return []; @@ -304,28 +304,28 @@ export const getTasks = async ({ }; /** - * getDailyUsageByUser + * getHourlyUsageByUser */ -const dailyUsageByUserSchema = z.object({ - date: z.string(), +const hourlyUsageByUserSchema = z.object({ + hour_utc: z.string(), userId: z.string(), tasks: z.coerce.number(), tokens: z.coerce.number(), cost: z.coerce.number(), }); -export type DailyUsageByUser = z.infer & { +export type HourlyUsageByUser = z.infer & { user: User; }; -export const getDailyUsageByUser = async ({ +export const getHourlyUsageByUser = async ({ orgId, timePeriod = 90, }: { orgId?: string | null; - timePeriod?: TimePeriod; -}): Promise => { + timePeriod?: AnyTimePeriod; +}): Promise => { if (!orgId) { return []; } @@ -333,7 +333,7 @@ export const getDailyUsageByUser = async ({ const results = await analytics.query({ query: ` SELECT - toString(toDate(fromUnixTimestamp(timestamp))) as date, + toString(toStartOfHour(fromUnixTimestamp(timestamp))) as hour_utc, userId, SUM(CASE WHEN type = '${TelemetryEventName.TASK_CREATED}' THEN 1 ELSE 0 END) AS tasks, SUM(CASE WHEN type = '${TelemetryEventName.LLM_COMPLETION}' THEN COALESCE(inputTokens, 0) + COALESCE(outputTokens, 0) ELSE 0 END) AS tokens, @@ -344,7 +344,7 @@ export const getDailyUsageByUser = async ({ AND timestamp >= toUnixTimestamp(now() - INTERVAL {timePeriod: Int32} DAY) AND type IN ({types: Array(String)}) GROUP BY 1, 2 - ORDER BY date DESC, userId + ORDER BY hour_utc DESC, userId `, format: 'JSONEachRow', query_params: { @@ -358,13 +358,13 @@ export const getDailyUsageByUser = async ({ }, }); - const dailyUsages = z - .array(dailyUsageByUserSchema) + const hourlyUsages = z + .array(hourlyUsageByUserSchema) .parse(await results.json()); - const users = await getUsersById(dailyUsages.map(({ userId }) => userId)); + const users = await getUsersById(hourlyUsages.map(({ userId }) => userId)); - return dailyUsages + return hourlyUsages .map((usage) => ({ ...usage, user: users[usage.userId] })) - .filter((usage): usage is DailyUsageByUser => !!usage.user); + .filter((usage): usage is HourlyUsageByUser => !!usage.user); }; diff --git a/src/components/usage/UsageCard.tsx b/src/components/usage/UsageCard.tsx index 44253c980d..ee3767cbff 100644 --- a/src/components/usage/UsageCard.tsx +++ b/src/components/usage/UsageCard.tsx @@ -10,7 +10,7 @@ import { ArrowRightIcon } from 'lucide-react'; import { TelemetryEventName } from '@roo-code/types'; -import { type TimePeriod, timePeriods } from '@/types'; +import { type TimePeriodConfig, allTimePeriods } from '@/types'; import { getUsage } from '@/actions/analytics'; import { formatCurrency, formatNumber } from '@/lib/formatters'; @@ -30,14 +30,28 @@ type MetricType = 'tasks' | 'tokens' | 'cost'; export const UsageCard = () => { const t = useTranslations('DashboardIndex'); const { orgId } = useAuth(); - const [timePeriod, setTimePeriod] = useState(7); + const [selectedPeriod, setSelectedPeriod] = useState( + allTimePeriods.find((p) => p.value === 7 && p.granularity === 'daily')!, + ); const [selectedMetric, setSelectedMetric] = useState('tasks'); const path = usePathname(); const { data: usage = {}, isPending } = useQuery({ - queryKey: ['usage', orgId, timePeriod], - queryFn: () => getUsage({ orgId, timePeriod }), + queryKey: [ + 'usage', + orgId, + selectedPeriod.value, + selectedPeriod.granularity, + ], + queryFn: () => + getUsage({ + orgId, + timePeriod: + selectedPeriod.granularity === 'daily' + ? (selectedPeriod.value as 7 | 30 | 90) + : (selectedPeriod.value as 1), // Use 1 day for 24h view + }), enabled: !!orgId, }); @@ -71,17 +85,18 @@ export const UsageCard = () => {
{/* Time period toggles */}
- {timePeriods.map((period) => ( + {allTimePeriods.map((periodConfig) => ( ))}
@@ -152,7 +167,7 @@ export const UsageCard = () => { {/* Chart displayed below on usage page */} {path === '/usage' && ( )} diff --git a/src/components/usage/UsageChart.tsx b/src/components/usage/UsageChart.tsx index b386868d2d..2ced3e0d52 100644 --- a/src/components/usage/UsageChart.tsx +++ b/src/components/usage/UsageChart.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useMemo } from 'react'; +import React, { useMemo, useEffect, useState } from 'react'; import { useAuth } from '@clerk/nextjs'; import { useQuery } from '@tanstack/react-query'; import { @@ -13,15 +13,12 @@ import { ResponsiveContainer, } from 'recharts'; -import type { TimePeriod } from '@/types'; -import { getDailyUsageByUser } from '@/actions/analytics'; +import type { TimePeriodConfig } from '@/types'; +import { getHourlyUsageByUser } from '@/actions/analytics'; import { formatNumber } from '@/lib/formatters'; -import { Skeleton } from '@/components/ui'; - +import { aggregateHourlyToDaily } from '@/lib/timezoneUtils'; type MetricType = 'tasks' | 'tokens' | 'cost'; -const metricTypes: MetricType[] = ['tasks', 'tokens', 'cost']; - interface TickProps { x?: number; y?: number; @@ -68,26 +65,140 @@ const generateUserColor = (index: number): string => { return colors[index % colors.length]!; }; +// Helper function to process hourly data for chart display +const processHourlyDataForChart = ( + hourlyData: Array<{ + hour_utc: string; + userId: string; + tasks: number; + tokens: number; + cost: number; + user: { name?: string | null; email?: string | null }; + }>, + selectedMetric: MetricType, +) => { + // Group by UTC hour and user (display conversion happens in chart components) + const hourGroups: Record = {}; + + hourlyData.forEach((item) => { + // Use UTC hour as-is, conversion to local time happens in display components + const utcHour = item.hour_utc; + let isoHour: string; + + try { + // Convert to ISO format for consistent handling + if (utcHour.includes('T')) { + isoHour = utcHour + 'Z'; + } else { + isoHour = utcHour.replace(' ', 'T') + 'Z'; + } + + // Validate the date format + const testDate = new Date(isoHour); + if (isNaN(testDate.getTime())) { + console.warn('Invalid UTC hour format:', utcHour); + return; + } + } catch (error) { + console.warn('Error processing UTC hour:', error, utcHour); + return; + } + + if (!hourGroups[isoHour]) { + hourGroups[isoHour] = { date: isoHour, total: 0 }; + } + + const value = item[selectedMetric]; + const userName = item.user.name || item.user.email || 'Unknown'; + const hourGroup = hourGroups[isoHour]; + if (hourGroup) { + hourGroup[userName] = value; + hourGroup.total += value; + } + }); + + // Convert to array and sort by hour + return Object.values(hourGroups).sort((a, b) => { + return new Date(a.date).getTime() - new Date(b.date).getTime(); + }); +}; + +// Helper function to process daily data for chart display +const processDailyDataForChart = ( + dailyData: Array<{ + date: string; + userId: string; + tasks: number; + tokens: number; + cost: number; + user: { name?: string | null; email?: string | null }; + }>, + selectedMetric: MetricType, +) => { + // Group by date and user + const dateGroups: Record = {}; + + dailyData.forEach((item) => { + const date = item.date; + if (!dateGroups[date]) { + dateGroups[date] = { date, total: 0 }; + } + + const value = item[selectedMetric]; + dateGroups[date][item.user.name || item.user.email || 'Unknown'] = value; + dateGroups[date].total += value; + }); + + // Convert to array and sort by date + return Object.values(dateGroups).sort((a, b) => { + const [yearA, monthA, dayA] = a.date.split('-').map(Number); + const [yearB, monthB, dayB] = b.date.split('-').map(Number); + + if (!yearA || !monthA || !dayA || !yearB || !monthB || !dayB) { + return 0; + } + + const dateA = new Date(yearA, monthA - 1, dayA); + const dateB = new Date(yearB, monthB - 1, dayB); + return dateA.getTime() - dateB.getTime(); + }); +}; + interface UsageChartProps { - timePeriod: TimePeriod; + timePeriodConfig: TimePeriodConfig; selectedMetric?: MetricType; } // Custom tick components for theme-aware labels -const CustomXAxisTick = (props: TickProps) => { - const { x, y, payload } = props; +const CustomXAxisTick = (props: TickProps & { isHourly?: boolean }) => { + const { x, y, payload, isHourly } = props; if (!payload?.value) return null; - // Parse date string as local date to avoid timezone issues - const [year, month, day] = payload.value.split('-').map(Number); - if (!year || !month || !day) return null; + let formattedDate: string; - const date = new Date(year, month - 1, day); // month is 0-indexed - const formattedDate = date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }); + if (isHourly) { + // For hourly data, show hour format + const date = new Date(payload.value); + if (isNaN(date.getTime())) return null; + + // Convert UTC time to local time for display + formattedDate = date.toLocaleTimeString('en-US', { + hour: 'numeric', + hour12: true, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }); + } else { + // For daily data, show date format + const [year, month, day] = payload.value.split('-').map(Number); + if (!year || !month || !day) return null; + + const date = new Date(year, month - 1, day); // month is 0-indexed + formattedDate = date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + } return ( @@ -137,18 +248,36 @@ const CustomTooltip = ({ payload, label, formatValue, -}: TooltipProps) => { + isHourly, +}: TooltipProps & { isHourly?: boolean }) => { if (active && payload && payload.length && label) { - // Parse date string as local date to avoid timezone issues - const [year, month, day] = label.split('-').map(Number); - if (!year || !month || !day) return null; + let formattedDate: string; - const date = new Date(year, month - 1, day); // month is 0-indexed - const formattedDate = date.toLocaleDateString('en-US', { - weekday: 'short', - month: 'short', - day: 'numeric', - }); + if (isHourly) { + // For hourly data, label is an ISO datetime string + const date = new Date(label); + if (isNaN(date.getTime())) return null; + + formattedDate = date.toLocaleString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + hour: 'numeric', + hour12: true, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }); + } else { + // For daily data, label is a date string "YYYY-MM-DD" + const [year, month, day] = label.split('-').map(Number); + if (!year || !month || !day) return null; + + const date = new Date(year, month - 1, day); // month is 0-indexed + formattedDate = date.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + }); + } return (
@@ -185,62 +314,50 @@ const CustomTooltip = ({ }; export const UsageChart = ({ - timePeriod, + timePeriodConfig, selectedMetric = 'tasks', }: UsageChartProps) => { const { orgId } = useAuth(); + const [isClient, setIsClient] = useState(false); - const { data: dailyUsage = [], isPending } = useQuery({ - queryKey: ['getDailyUsageByUser', orgId, timePeriod], - queryFn: () => getDailyUsageByUser({ orgId, timePeriod }), + // Ensure we only run timezone-dependent code on the client + useEffect(() => { + setIsClient(true); + }, []); + + const { data: hourlyUsage = [], isPending } = useQuery({ + queryKey: [ + 'getHourlyUsageByUser', + orgId, + timePeriodConfig.value, + timePeriodConfig.granularity, + ], + queryFn: () => + getHourlyUsageByUser({ orgId, timePeriod: timePeriodConfig.value }), enabled: !!orgId, }); + // Process data based on granularity const chartData = useMemo(() => { - if (!dailyUsage.length) return []; + if (!hourlyUsage.length || !isClient) return []; - // Group data by date - const dateGroups = dailyUsage.reduce( - (acc, item) => { - const date = item.date; - if (!acc[date]) { - acc[date] = { date, total: 0 }; - } - - const value = item[selectedMetric]; - acc[date][item.user.name || item.user.email || 'Unknown'] = value; - acc[date].total += value; - - return acc; - }, - {} as Record, - ); - - // Convert to array and sort by date - const result = Object.values(dateGroups).sort((a, b) => { - // Parse dates as local dates to avoid timezone issues - const [yearA, monthA, dayA] = a.date.split('-').map(Number); - const [yearB, monthB, dayB] = b.date.split('-').map(Number); - - if (!yearA || !monthA || !dayA || !yearB || !monthB || !dayB) { - return 0; - } - - const dateA = new Date(yearA, monthA - 1, dayA); - const dateB = new Date(yearB, monthB - 1, dayB); - return dateA.getTime() - dateB.getTime(); - }); - - return result; - }, [dailyUsage, selectedMetric]); + if (timePeriodConfig.granularity === 'hourly') { + // For hourly view, show hourly data directly + return processHourlyDataForChart(hourlyUsage, selectedMetric); + } else { + // For daily view, aggregate hourly data to daily + const dailyUsage = aggregateHourlyToDaily(hourlyUsage); + return processDailyDataForChart(dailyUsage, selectedMetric); + } + }, [hourlyUsage, isClient, timePeriodConfig.granularity, selectedMetric]); const uniqueUsers = useMemo(() => { const users = new Set(); - dailyUsage.forEach((item) => { + hourlyUsage.forEach((item) => { users.add(item.user.name || item.user.email || 'Unknown'); }); return Array.from(users).sort(); - }, [dailyUsage]); + }, [hourlyUsage]); const formatValue = (value: number) => { switch (selectedMetric) { @@ -255,30 +372,34 @@ export const UsageChart = ({ } }; - if (isPending) { + if (isPending || !isClient) { return (
-
- {metricTypes.map((metric) => ( - - ))} -
-
-
- {Array.from({ length: 7 }).map((_, i) => ( - +
+ + - ))} +
-
- - - +
+

+ Loading chart data... +

+

+ Processing timezone data +

@@ -345,7 +466,11 @@ export const UsageChart = ({ dataKey="date" axisLine={false} tickLine={false} - tick={} + tick={ + + } height={25} /> } cursor={{ diff --git a/src/lib/__tests__/formatters.test.ts b/src/lib/__tests__/formatters.test.ts index e516dba7e4..d01116b0a7 100644 --- a/src/lib/__tests__/formatters.test.ts +++ b/src/lib/__tests__/formatters.test.ts @@ -3,9 +3,8 @@ import { formatNumber, formatCurrency } from '../formatters'; describe('formatNumber', () => { - it('should return empty string for undefined or null values', () => { + it('should return empty string for undefined values', () => { expect(formatNumber(undefined)).toBe(''); - expect(formatNumber(null as unknown as undefined)).toBe(''); }); it('should return "0" for zero', () => { @@ -54,9 +53,8 @@ describe('formatNumber', () => { }); describe('formatCurrency', () => { - it('should return empty string for undefined or null values', () => { + it('should return empty string for undefined values', () => { expect(formatCurrency(undefined)).toBe(''); - expect(formatCurrency(null as unknown as undefined)).toBe(''); }); it('should format USD currency correctly', () => { diff --git a/src/lib/__tests__/timezoneUtils.test.ts b/src/lib/__tests__/timezoneUtils.test.ts new file mode 100644 index 0000000000..2265067057 --- /dev/null +++ b/src/lib/__tests__/timezoneUtils.test.ts @@ -0,0 +1,142 @@ +import { aggregateHourlyToDaily } from '../timezoneUtils'; +import { HourlyUsageByUser } from '@/actions/analytics/events'; + +// Mock timezone to ensure consistent test results +const mockTimezone = 'America/New_York'; + +// Mock Intl.DateTimeFormat to return consistent timezone +Object.defineProperty(Intl, 'DateTimeFormat', { + value: () => ({ + resolvedOptions: () => ({ timeZone: mockTimezone }), + }), + writable: true, +}); + +describe('timezoneUtils', () => { + describe('aggregateHourlyToDaily', () => { + it('should aggregate hourly data to daily data correctly', () => { + const mockUser1 = { + id: 'user1', + orgId: 'org1', + orgRole: 'member', + name: 'John Doe', + email: 'john@example.com', + imageUrl: 'https://example.com/avatar1.jpg', + entity: {}, + lastSyncAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockUser2 = { + id: 'user2', + orgId: 'org1', + orgRole: 'member', + name: 'Jane Smith', + email: 'jane@example.com', + imageUrl: 'https://example.com/avatar2.jpg', + entity: {}, + lastSyncAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockHourlyData: HourlyUsageByUser[] = [ + { + hour_utc: '2025-06-02 14:00:00', + userId: 'user1', + tasks: 5, + tokens: 1000, + cost: 0.05, + user: mockUser1, + }, + { + hour_utc: '2025-06-02 15:00:00', + userId: 'user1', + tasks: 3, + tokens: 500, + cost: 0.03, + user: mockUser1, + }, + { + hour_utc: '2025-06-02 16:00:00', + userId: 'user2', + tasks: 2, + tokens: 300, + cost: 0.02, + user: mockUser2, + }, + ]; + + const result = aggregateHourlyToDaily(mockHourlyData, mockTimezone); + + expect(result).toHaveLength(2); // Two users + + const user1Data = result.find((r) => r.userId === 'user1'); + const user2Data = result.find((r) => r.userId === 'user2'); + + expect(user1Data).toEqual({ + date: '2025-06-02', + userId: 'user1', + tasks: 8, // 5 + 3 + tokens: 1500, // 1000 + 500 + cost: 0.08, // 0.05 + 0.03 + user: mockUser1, + }); + + expect(user2Data).toEqual({ + date: '2025-06-02', + userId: 'user2', + tasks: 2, + tokens: 300, + cost: 0.02, + user: mockUser2, + }); + }); + + it('should handle empty data', () => { + const result = aggregateHourlyToDaily([]); + expect(result).toEqual([]); + }); + + it('should handle data across multiple days', () => { + const mockUser = { + id: 'user1', + orgId: 'org1', + orgRole: 'member', + name: 'John Doe', + email: 'john@example.com', + imageUrl: 'https://example.com/avatar1.jpg', + entity: {}, + lastSyncAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockHourlyData: HourlyUsageByUser[] = [ + { + hour_utc: '2025-06-02 23:00:00', // Late UTC might be next day in some timezones + userId: 'user1', + tasks: 1, + tokens: 100, + cost: 0.01, + user: mockUser, + }, + { + hour_utc: '2025-06-03 01:00:00', // Early UTC might be same day in some timezones + userId: 'user1', + tasks: 2, + tokens: 200, + cost: 0.02, + user: mockUser, + }, + ]; + + const result = aggregateHourlyToDaily(mockHourlyData, mockTimezone); + + // Should have separate entries for different local dates + expect(result.length).toBeGreaterThan(0); + expect(result.every((r) => r.date)).toBe(true); + }); + }); +}); diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts index de5b7d2724..e7b3d69ae7 100644 --- a/src/lib/formatters.ts +++ b/src/lib/formatters.ts @@ -53,3 +53,71 @@ export function formatCurrency( export const formatTimestamp = (timestamp: number) => new Date(timestamp * 1000).toLocaleString(); + +/** + * Convert UTC hour string to user's local date + * @param utcHour UTC hour string in format "2025-06-02 14:00:00" + * @param userTimezone User's timezone (defaults to browser timezone) + * @returns Local date string in YYYY-MM-DD format + */ +export const convertUTCHourToLocalDate = ( + utcHour: string, + userTimezone?: string, +): string => { + // Check if we're in a browser environment + if (typeof window === 'undefined') { + // On server, just return the UTC date part to avoid hydration mismatch + return utcHour.split(' ')[0] || ''; + } + + try { + // Parse UTC hour string - handle different formats + let utcDate: Date; + + if (utcHour.includes('T')) { + // ISO format: "2025-06-02T14:00:00" + utcDate = new Date(utcHour + 'Z'); + } else if (utcHour.includes(' ')) { + // Space format: "2025-06-02 14:00:00" + utcDate = new Date(utcHour.replace(' ', 'T') + 'Z'); + } else { + // Just date: "2025-06-02" + utcDate = new Date(utcHour + 'T00:00:00Z'); + } + + // Validate the date + if (isNaN(utcDate.getTime())) { + console.warn('Invalid UTC hour format:', utcHour); + return utcHour.split(' ')[0] || utcHour.split('T')[0] || ''; + } + + // Get user's timezone (default to browser timezone) + const timezone = + userTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone; + + // Convert to user's local timezone and get the date part + const localDateString = utcDate.toLocaleDateString('en-CA', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + + return localDateString; + } catch (error) { + console.warn('Error converting UTC hour to local date:', error, utcHour); + // Fallback: return the date part of the input + return utcHour.split(' ')[0] || utcHour.split('T')[0] || ''; + } +}; + +/** + * Get user's current timezone + * @returns User's timezone string (e.g., "America/New_York") + */ +export const getUserTimezone = (): string => { + if (typeof window === 'undefined') { + return 'UTC'; // Default to UTC on server + } + return Intl.DateTimeFormat().resolvedOptions().timeZone; +}; diff --git a/src/lib/timezoneUtils.ts b/src/lib/timezoneUtils.ts new file mode 100644 index 0000000000..91e0fba110 --- /dev/null +++ b/src/lib/timezoneUtils.ts @@ -0,0 +1,76 @@ +import { convertUTCHourToLocalDate, getUserTimezone } from './formatters'; +import type { HourlyUsageByUser } from '@/actions/analytics/events'; + +export type DailyAggregatedUsage = { + date: string; + userId: string; + tasks: number; + tokens: number; + cost: number; + user: { + id: string; + name?: string | null; + email?: string | null; + }; +}; + +/** + * Aggregate hourly usage data into daily buckets based on user's local timezone + * @param hourlyData Array of hourly usage data from server + * @param userTimezone Optional timezone override (defaults to browser timezone) + * @returns Array of daily aggregated usage data + */ +export const aggregateHourlyToDaily = ( + hourlyData: HourlyUsageByUser[], + userTimezone?: string, +): DailyAggregatedUsage[] => { + const timezone = userTimezone || getUserTimezone(); + + // Group hourly data by local date and user + const dailyGroups: Record> = {}; + + hourlyData.forEach((hourlyRecord) => { + // Convert UTC hour to user's local date + const localDate = convertUTCHourToLocalDate( + hourlyRecord.hour_utc, + timezone, + ); + const userId = hourlyRecord.userId; + + if (!dailyGroups[localDate]) { + dailyGroups[localDate] = {}; + } + + if (!dailyGroups[localDate][userId]) { + dailyGroups[localDate][userId] = { + date: localDate, + userId, + tasks: 0, + tokens: 0, + cost: 0, + user: hourlyRecord.user, + }; + } + + // Aggregate the values + const dailyRecord = dailyGroups[localDate][userId]; + dailyRecord.tasks += hourlyRecord.tasks; + dailyRecord.tokens += hourlyRecord.tokens; + dailyRecord.cost += hourlyRecord.cost; + }); + + // Flatten the grouped data into an array + const result: DailyAggregatedUsage[] = []; + Object.values(dailyGroups).forEach((dateGroup) => { + Object.values(dateGroup).forEach((dailyRecord) => { + result.push(dailyRecord); + }); + }); + + // Sort by date descending, then by userId + return result.sort((a, b) => { + const dateComparison = b.date.localeCompare(a.date); + if (dateComparison !== 0) return dateComparison; + return a.userId.localeCompare(b.userId); + }); +}; diff --git a/src/types/time-period.ts b/src/types/time-period.ts index 98b928e78c..c2da5b0aae 100644 --- a/src/types/time-period.ts +++ b/src/types/time-period.ts @@ -1,3 +1,18 @@ export const timePeriods = [7, 30, 90] as const; export type TimePeriod = (typeof timePeriods)[number]; +export type AnyTimePeriod = TimePeriod | 1; // 1 for 24h view, 7|30|90 for daily views +export type TimeGranularity = 'hourly' | 'daily'; + +export type TimePeriodConfig = { + value: AnyTimePeriod; + granularity: TimeGranularity; + label: string; +}; + +export const allTimePeriods: TimePeriodConfig[] = [ + { value: 1, granularity: 'hourly', label: '24h' }, + { value: 7, granularity: 'daily', label: '7d' }, + { value: 30, granularity: 'daily', label: '30d' }, + { value: 90, granularity: 'daily', label: '90d' }, +];