Fix timezone display issues and support hourly visualization (#65)

* Fix timezone display issues and support hourly visualization

* PR feedback

* Remove code

* More cleanup
This commit is contained in:
Matt Rubens 2025-06-02 11:13:52 -04:00 committed by GitHub
parent 692a8ff31e
commit a60938cea3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 560 additions and 120 deletions

View file

@ -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<UsageRecord> => {
if (!orgId) {
return {};
@ -134,7 +134,7 @@ export const getDeveloperUsage = async ({
timePeriod = 90,
}: {
orgId?: string | null;
timePeriod?: TimePeriod;
timePeriod?: AnyTimePeriod;
}): Promise<DeveloperUsage[]> => {
if (!orgId) {
return [];
@ -196,7 +196,7 @@ export const getModelUsage = async ({
timePeriod = 90,
}: {
orgId?: string | null;
timePeriod?: TimePeriod;
timePeriod?: AnyTimePeriod;
}): Promise<ModelUsage[]> => {
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<typeof dailyUsageByUserSchema> & {
export type HourlyUsageByUser = z.infer<typeof hourlyUsageByUserSchema> & {
user: User;
};
export const getDailyUsageByUser = async ({
export const getHourlyUsageByUser = async ({
orgId,
timePeriod = 90,
}: {
orgId?: string | null;
timePeriod?: TimePeriod;
}): Promise<DailyUsageByUser[]> => {
timePeriod?: AnyTimePeriod;
}): Promise<HourlyUsageByUser[]> => {
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);
};

View file

@ -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<TimePeriod>(7);
const [selectedPeriod, setSelectedPeriod] = useState<TimePeriodConfig>(
allTimePeriods.find((p) => p.value === 7 && p.granularity === 'daily')!,
);
const [selectedMetric, setSelectedMetric] = useState<MetricType>('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 = () => {
<div className="flex flex-row gap-3 items-center">
{/* Time period toggles */}
<div className="flex flex-row gap-1">
{timePeriods.map((period) => (
{allTimePeriods.map((periodConfig) => (
<button
key={period}
onClick={() => setTimePeriod(period)}
key={`${periodConfig.value}-${periodConfig.granularity}`}
onClick={() => setSelectedPeriod(periodConfig)}
className={`px-2 py-1 text-xs font-medium rounded transition-colors ${
period === timePeriod
periodConfig.value === selectedPeriod.value &&
periodConfig.granularity === selectedPeriod.granularity
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
}`}
>
{period}d
{periodConfig.label}
</button>
))}
</div>
@ -152,7 +167,7 @@ export const UsageCard = () => {
{/* Chart displayed below on usage page */}
{path === '/usage' && (
<UsageChart
timePeriod={timePeriod}
timePeriodConfig={selectedPeriod}
selectedMetric={selectedMetric}
/>
)}

View file

@ -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<string, ChartDataPoint> = {};
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<string, ChartDataPoint> = {};
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 (
<g transform={`translate(${x},${y})`}>
@ -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 (
<div className="bg-popover border border-border rounded-lg shadow-lg p-3 min-w-[200px]">
@ -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<string, ChartDataPoint>,
);
// 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<string>();
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 (
<div className="space-y-6">
<div className="flex flex-wrap gap-2 justify-center sm:justify-start">
{metricTypes.map((metric) => (
<Skeleton key={metric} className="h-9 w-20 rounded-md" />
))}
</div>
<div className="h-72 w-full rounded-lg border bg-card p-3">
<div className="h-full w-full flex items-center justify-center">
<div className="space-y-6 w-full">
<div className="flex justify-between items-end h-48 px-4">
{Array.from({ length: 7 }).map((_, i) => (
<Skeleton
key={i}
className="w-12 rounded-t-md"
style={{ height: `${Math.random() * 60 + 30}%` }}
<div className="text-center space-y-3">
<div className="w-12 h-12 mx-auto rounded-full bg-muted/20 flex items-center justify-center">
<svg
className="w-6 h-6 text-muted-foreground/50 animate-spin"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
))}
</svg>
</div>
<div className="flex justify-center gap-4 pt-4">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-24" />
<Skeleton className="h-3 w-16" />
<div>
<p className="text-sm font-medium text-foreground">
Loading chart data...
</p>
<p className="text-xs text-muted-foreground">
Processing timezone data
</p>
</div>
</div>
</div>
@ -345,7 +466,11 @@ export const UsageChart = ({
dataKey="date"
axisLine={false}
tickLine={false}
tick={<CustomXAxisTick />}
tick={
<CustomXAxisTick
isHourly={timePeriodConfig.granularity === 'hourly'}
/>
}
height={25}
/>
<YAxis
@ -360,6 +485,7 @@ export const UsageChart = ({
<CustomTooltip
selectedMetric={selectedMetric}
formatValue={formatValue}
isHourly={timePeriodConfig.granularity === 'hourly'}
/>
}
cursor={{

View file

@ -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', () => {

View file

@ -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);
});
});
});

View file

@ -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;
};

76
src/lib/timezoneUtils.ts Normal file
View file

@ -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<string, Record<string, DailyAggregatedUsage>> = {};
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);
});
};

View file

@ -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' },
];