diff --git a/apps/web/src/app/(app)/usage/page.tsx b/apps/web/src/app/(app)/usage/page.tsx index 95ed9552ef..93f05bfaf1 100644 --- a/apps/web/src/app/(app)/usage/page.tsx +++ b/apps/web/src/app/(app)/usage/page.tsx @@ -48,10 +48,11 @@ type UsageResponse = { async function fetchUsageData( groupByModel: boolean, viewType: string, - period: Period + period: Period, + timeZone: string ): Promise { const response = await fetch( - `/api/profile/usage?groupByModel=${groupByModel}&viewType=${viewType}&period=${period}` + `/api/profile/usage?groupByModel=${groupByModel}&viewType=${viewType}&period=${period}&timeZone=${encodeURIComponent(timeZone)}` ); if (!response.ok) { if (response.status === 401) { @@ -66,6 +67,12 @@ async function fetchUsageData( return data; } +// Formats a date in the given timezone as YYYY-MM-DD +// Uses 'en-CA' locale because it formats dates as YYYY-MM-DD by default +function formatDateInTimeZone(date: Date, timeZone: string): string { + return date.toLocaleDateString('en-CA', { timeZone }); +} + function calculateTotals(usage: UsageData[]) { return usage.reduce( (totals, item) => ({ @@ -77,7 +84,7 @@ function calculateTotals(usage: UsageData[]) { ); } -function calculateStreak(usageData: UsageData[]): number { +function calculateStreak(usageData: UsageData[], timeZone: string): number { // Create a set of dates that have usage (any requests > 0) const usageDates = new Set( usageData.filter(item => item.request_count > 0).map(item => item.date) @@ -93,7 +100,8 @@ function calculateStreak(usageData: UsageData[]): number { // Max 365 days to prevent infinite loop const checkDate = new Date(today); checkDate.setDate(today.getDate() - i); - const dateString = checkDate.toISOString().split('T')[0]; // YYYY-MM-DD format + // Use timezone-aware date formatting (YYYY-MM-DD) + const dateString = formatDateInTimeZone(checkDate, timeZone); if (usageDates.has(dateString)) { streak++; @@ -108,7 +116,8 @@ function calculateStreak(usageData: UsageData[]): number { } function transformUsageDataForStreakCalendar( - usageData: UsageData[] + usageData: UsageData[], + timeZone: string ): { date: string; count: number }[] { // Create a map of date -> total request count for that date const dateRequestMap = new Map(); @@ -126,7 +135,8 @@ function transformUsageDataForStreakCalendar( for (let i = 83; i >= 0; i--) { const date = new Date(today); date.setDate(date.getDate() - i); - const dateString = date.toISOString().split('T')[0]; // YYYY-MM-DD format + // Use timezone-aware date formatting (YYYY-MM-DD) + const dateString = formatDateInTimeZone(date, timeZone); const requestCount = dateRequestMap.get(dateString) || 0; @@ -152,6 +162,9 @@ export default function UsagePage() { const [groupByModel, setGroupByModel] = useState(false); const [viewType, setViewType] = useState('personal'); const [period, setPeriod] = useState('week'); + const [timeZone] = useState( + () => Intl.DateTimeFormat().resolvedOptions().timeZone + ); const { data: usageData, @@ -159,8 +172,8 @@ export default function UsagePage() { error, refetch, } = useQuery({ - queryKey: ['usage-data', groupByModel, viewType, period], - queryFn: () => fetchUsageData(groupByModel, viewType, period), + queryKey: ['usage-data', groupByModel, viewType, period, timeZone], + queryFn: () => fetchUsageData(groupByModel, viewType, period, timeZone), }); const { data: autocompleteMetrics, isLoading: isLoadingAutocompleteMetrics } = useQuery( @@ -366,8 +379,8 @@ export default function UsagePage() { } const { totalCost, totalRequests, totalTokens } = calculateTotals(usageData.usage); - const streak = calculateStreak(usageData.usage); - const streakCalendarData = transformUsageDataForStreakCalendar(usageData.usage); + const streak = calculateStreak(usageData.usage, timeZone); + const streakCalendarData = transformUsageDataForStreakCalendar(usageData.usage, timeZone); // Prepare table data const tableColumns: UsageTableColumn[] = [ diff --git a/apps/web/src/app/api/profile/usage/route.ts b/apps/web/src/app/api/profile/usage/route.ts index c76272d895..1a79fc317c 100644 --- a/apps/web/src/app/api/profile/usage/route.ts +++ b/apps/web/src/app/api/profile/usage/route.ts @@ -9,6 +9,16 @@ import { getDateThreshold, type Period } from '@/routers/user-router'; const VALID_PERIODS = new Set(['week', 'month', 'year', 'all']); +// Validate IANA timezone string to prevent SQL injection +function isValidTimezone(tz: string): boolean { + try { + Intl.DateTimeFormat(undefined, { timeZone: tz }); + return true; + } catch { + return false; + } +} + export async function GET(request: NextRequest) { const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false, @@ -21,12 +31,24 @@ export async function GET(request: NextRequest) { const viewType = searchParams.get('viewType') || 'personal'; // 'personal', 'all', or organization ID const periodParam = searchParams.get('period') || 'week'; const period: Period = VALID_PERIODS.has(periodParam) ? (periodParam as Period) : 'week'; + const timeZoneParam = searchParams.get('timeZone'); + const userTimeZone = timeZoneParam && isValidTimezone(timeZoneParam) ? timeZoneParam : 'UTC'; const userId = user.id; + // Helper to get timezone-aware date SQL + const getDateSql = () => { + if (userTimeZone === 'UTC') { + return sql`DATE(${microdollar_usage.created_at})`; + } + // created_at is timestamptz (stored as UTC), convert directly to user's timezone + return sql`(${microdollar_usage.created_at} AT TIME ZONE ${userTimeZone})::date`; + }; + // Build the select object conditionally + const dateSql = getDateSql(); const selectFields = { - date: sql`DATE(${microdollar_usage.created_at})`, + date: dateSql, ...(groupByModel && { model: sql< string | null @@ -42,13 +64,13 @@ export async function GET(request: NextRequest) { // Build the group by and order by clauses conditionally const groupByClause = [ - sql`DATE(${microdollar_usage.created_at})`, + dateSql, ...(groupByModel ? [sql`COALESCE(${microdollar_usage.requested_model}, ${microdollar_usage.model})`] : []), ]; const orderByClause = [ - desc(sql`DATE(${microdollar_usage.created_at})`), + desc(dateSql), ...(groupByModel ? [sql`COALESCE(${microdollar_usage.requested_model}, ${microdollar_usage.model})`] : []),