From 26478eb3adfe08b0653f68c30dae8f05cc5e9107 Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Wed, 6 May 2026 18:29:52 -0600 Subject: [PATCH 1/3] fix(usage): use browser timezone for date grouping and streak calculation --- apps/web/src/app/(app)/usage/page.tsx | 42 +++++++++++++++------ apps/web/src/app/api/profile/usage/route.ts | 27 +++++++++++-- 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/apps/web/src/app/(app)/usage/page.tsx b/apps/web/src/app/(app)/usage/page.tsx index 95ed9552ef..2d9699584b 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,12 @@ export default function UsagePage() { const [groupByModel, setGroupByModel] = useState(false); const [viewType, setViewType] = useState('personal'); const [period, setPeriod] = useState('week'); + const [timeZone, setTimeZone] = useState(null); + + // Detect browser timezone on mount + useEffect(() => { + setTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone); + }, []); const { data: usageData, @@ -159,8 +175,12 @@ 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: () => { + if (!timeZone) throw new Error('Timezone not detected'); + return fetchUsageData(groupByModel, viewType, period, timeZone); + }, + enabled: timeZone !== null, }); const { data: autocompleteMetrics, isLoading: isLoadingAutocompleteMetrics } = useQuery( @@ -178,7 +198,7 @@ export default function UsagePage() { const periodLabel = PERIOD_LABELS[period]; - if (isLoading) { + if (!timeZone || isLoading) { return (
@@ -366,8 +386,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..569ef274f3 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 (_e) { + return false; + } +} + export async function GET(request: NextRequest) { const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false, @@ -21,12 +31,23 @@ 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})`; + } + return sql`(${microdollar_usage.created_at} AT TIME ZONE 'UTC' 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 +63,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})`] : []), From cba2961d45b74d85fa7f797ba5980125819a7b92 Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Wed, 6 May 2026 18:53:03 -0600 Subject: [PATCH 2/3] fix(usage): resolve lint error and optimize timezone init --- apps/web/src/app/(app)/usage/page.tsx | 17 +++++------------ apps/web/src/app/api/profile/usage/route.ts | 2 +- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/apps/web/src/app/(app)/usage/page.tsx b/apps/web/src/app/(app)/usage/page.tsx index 2d9699584b..4b07323817 100644 --- a/apps/web/src/app/(app)/usage/page.tsx +++ b/apps/web/src/app/(app)/usage/page.tsx @@ -162,12 +162,9 @@ export default function UsagePage() { const [groupByModel, setGroupByModel] = useState(false); const [viewType, setViewType] = useState('personal'); const [period, setPeriod] = useState('week'); - const [timeZone, setTimeZone] = useState(null); - - // Detect browser timezone on mount - useEffect(() => { - setTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone); - }, []); + const [timeZone, setTimeZone] = useState( + () => Intl.DateTimeFormat().resolvedOptions().timeZone + ); const { data: usageData, @@ -176,11 +173,7 @@ export default function UsagePage() { refetch, } = useQuery({ queryKey: ['usage-data', groupByModel, viewType, period, timeZone], - queryFn: () => { - if (!timeZone) throw new Error('Timezone not detected'); - return fetchUsageData(groupByModel, viewType, period, timeZone); - }, - enabled: timeZone !== null, + queryFn: () => fetchUsageData(groupByModel, viewType, period, timeZone), }); const { data: autocompleteMetrics, isLoading: isLoadingAutocompleteMetrics } = useQuery( @@ -198,7 +191,7 @@ export default function UsagePage() { const periodLabel = PERIOD_LABELS[period]; - if (!timeZone || isLoading) { + if (isLoading) { return (
diff --git a/apps/web/src/app/api/profile/usage/route.ts b/apps/web/src/app/api/profile/usage/route.ts index 569ef274f3..c385de2f18 100644 --- a/apps/web/src/app/api/profile/usage/route.ts +++ b/apps/web/src/app/api/profile/usage/route.ts @@ -14,7 +14,7 @@ function isValidTimezone(tz: string): boolean { try { Intl.DateTimeFormat(undefined, { timeZone: tz }); return true; - } catch (_e) { + } catch { return false; } } From f707edd642193b79c1ede5cbea9be1e7303a6066 Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Wed, 6 May 2026 21:26:44 -0600 Subject: [PATCH 3/3] fix(usage): simplify timezone conversion and remove unused setter --- apps/web/src/app/(app)/usage/page.tsx | 2 +- apps/web/src/app/api/profile/usage/route.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/(app)/usage/page.tsx b/apps/web/src/app/(app)/usage/page.tsx index 4b07323817..93f05bfaf1 100644 --- a/apps/web/src/app/(app)/usage/page.tsx +++ b/apps/web/src/app/(app)/usage/page.tsx @@ -162,7 +162,7 @@ export default function UsagePage() { const [groupByModel, setGroupByModel] = useState(false); const [viewType, setViewType] = useState('personal'); const [period, setPeriod] = useState('week'); - const [timeZone, setTimeZone] = useState( + const [timeZone] = useState( () => Intl.DateTimeFormat().resolvedOptions().timeZone ); diff --git a/apps/web/src/app/api/profile/usage/route.ts b/apps/web/src/app/api/profile/usage/route.ts index c385de2f18..1a79fc317c 100644 --- a/apps/web/src/app/api/profile/usage/route.ts +++ b/apps/web/src/app/api/profile/usage/route.ts @@ -41,7 +41,8 @@ export async function GET(request: NextRequest) { if (userTimeZone === 'UTC') { return sql`DATE(${microdollar_usage.created_at})`; } - return sql`(${microdollar_usage.created_at} AT TIME ZONE 'UTC' AT TIME ZONE ${userTimeZone})::date`; + // 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