Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 23 additions & 10 deletions apps/web/src/app/(app)/usage/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,11 @@ type UsageResponse = {
async function fetchUsageData(
groupByModel: boolean,
viewType: string,
period: Period
period: Period,
timeZone: string
): Promise<UsageResponse> {
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) {
Expand All @@ -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) => ({
Expand All @@ -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)
Expand All @@ -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++;
Expand All @@ -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<string, number>();
Expand All @@ -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;

Expand All @@ -152,15 +162,18 @@ export default function UsagePage() {
const [groupByModel, setGroupByModel] = useState(false);
const [viewType, setViewType] = useState<string>('personal');
const [period, setPeriod] = useState<Period>('week');
const [timeZone] = useState<string>(
() => Intl.DateTimeFormat().resolvedOptions().timeZone
);

const {
data: usageData,
isLoading,
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(
Expand Down Expand Up @@ -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[] = [
Expand Down
28 changes: 25 additions & 3 deletions apps/web/src/app/api/profile/usage/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string>`DATE(${microdollar_usage.created_at})`;
}
// created_at is timestamptz (stored as UTC), convert directly to user's timezone
return sql<string>`(${microdollar_usage.created_at} AT TIME ZONE ${userTimeZone})::date`;
};

// Build the select object conditionally
const dateSql = getDateSql();
const selectFields = {
date: sql<string>`DATE(${microdollar_usage.created_at})`,
date: dateSql,
...(groupByModel && {
model: sql<
string | null
Expand All @@ -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})`]
: []),
Expand Down