From 5904fa159bdc395b6199b30528a8c91025defc53 Mon Sep 17 00:00:00 2001 From: nina-hu Date: Wed, 4 Feb 2026 21:42:34 -0800 Subject: [PATCH] fix(ui): adjust daily spend date filtering for user timezone The daily spend tables store dates in UTC, but the UI sends dates in the user's local timezone. This causes a mismatch where records from the user's evening (stored as the next UTC day) don't appear when filtering by "today". Changes: - Add `_adjust_dates_for_timezone()` helper to expand date range based on timezone offset - Add `timezone` query parameter to `/user/daily/activity` and `/user/daily/activity/aggregated` endpoints - Frontend sends `timezone` using `Date.getTimezoneOffset()` For users west of UTC (e.g., PST), end_date is extended by 1 day. For users east of UTC (e.g., IST), start_date is extended by 1 day earlier. This ensures all records within the user's local date range are captured. --- .../common_daily_activity.py | 58 +++++++++++++++++-- .../internal_user_endpoints.py | 12 ++++ .../src/components/networking.tsx | 4 ++ 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index c52491efc7..99a732f9ef 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -1,5 +1,5 @@ -from datetime import datetime -from typing import Any, Callable, Dict, List, Optional, Set, Union +from datetime import datetime, timedelta +from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union from fastapi import HTTPException, status @@ -336,6 +336,46 @@ async def get_api_key_metadata( } +def _adjust_dates_for_timezone( + start_date: str, + end_date: str, + timezone_offset_minutes: Optional[int], +) -> Tuple[str, str]: + """ + Adjust date range to account for timezone differences. + + The database stores dates in UTC. When a user in a different timezone + selects a local date range, we need to expand the UTC query range to + capture all records that fall within their local date range. + + Args: + start_date: Start date in YYYY-MM-DD format (user's local date) + end_date: End date in YYYY-MM-DD format (user's local date) + timezone_offset_minutes: Minutes behind UTC (positive = west of UTC) + This matches JavaScript's Date.getTimezoneOffset() convention. + For example: PST = +480 (8 hours * 60 = 480 minutes behind UTC) + + Returns: + Tuple of (adjusted_start_date, adjusted_end_date) in YYYY-MM-DD format + """ + if timezone_offset_minutes is None or timezone_offset_minutes == 0: + return start_date, end_date + + start = datetime.strptime(start_date, "%Y-%m-%d") + end = datetime.strptime(end_date, "%Y-%m-%d") + + if timezone_offset_minutes > 0: + # West of UTC (Americas): local evening extends into next UTC day + # e.g., Feb 4 23:59 PST = Feb 5 07:59 UTC + end = end + timedelta(days=1) + else: + # East of UTC (Asia/Europe): local morning starts in previous UTC day + # e.g., Feb 4 00:00 IST = Feb 3 18:30 UTC + start = start - timedelta(days=1) + + return start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d") + + def _build_where_conditions( *, entity_id_field: str, @@ -345,12 +385,18 @@ def _build_where_conditions( model: Optional[str], api_key: Optional[Union[str, List[str]]], exclude_entity_ids: Optional[List[str]] = None, + timezone_offset_minutes: Optional[int] = None, ) -> Dict[str, Any]: """Build prisma where clause for daily activity queries.""" + # Adjust dates for timezone if provided + adjusted_start, adjusted_end = _adjust_dates_for_timezone( + start_date, end_date, timezone_offset_minutes + ) + where_conditions: Dict[str, Any] = { "date": { - "gte": start_date, - "lte": end_date, + "gte": adjusted_start, + "lte": adjusted_end, } } @@ -453,6 +499,7 @@ async def get_daily_activity( page_size: int, exclude_entity_ids: Optional[List[str]] = None, metadata_metrics_func: Optional[Callable[[List[Any]], SpendMetrics]] = None, + timezone_offset_minutes: Optional[int] = None, ) -> SpendAnalyticsPaginatedResponse: """Common function to get daily activity for any entity type.""" @@ -477,6 +524,7 @@ async def get_daily_activity( model=model, api_key=api_key, exclude_entity_ids=exclude_entity_ids, + timezone_offset_minutes=timezone_offset_minutes, ) # Get total count for pagination @@ -542,6 +590,7 @@ async def get_daily_activity_aggregated( model: Optional[str], api_key: Optional[str], exclude_entity_ids: Optional[List[str]] = None, + timezone_offset_minutes: Optional[int] = None, ) -> SpendAnalyticsPaginatedResponse: """Aggregated variant that returns the full result set (no pagination). @@ -568,6 +617,7 @@ async def get_daily_activity_aggregated( model=model, api_key=api_key, exclude_entity_ids=exclude_entity_ids, + timezone_offset_minutes=timezone_offset_minutes, ) # Fetch all matching results (no pagination) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 636ed87d79..c028540785 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -1917,6 +1917,11 @@ async def get_user_daily_activity( page_size: int = fastapi.Query( default=50, description="Items per page", ge=1, le=1000 ), + timezone: Optional[int] = fastapi.Query( + default=None, + description="Timezone offset in minutes from UTC (e.g., 480 for PST). " + "Matches JavaScript's Date.getTimezoneOffset() convention.", + ), user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ) -> SpendAnalyticsPaginatedResponse: """ @@ -1966,6 +1971,7 @@ async def get_user_daily_activity( api_key=api_key, page=page, page_size=page_size, + timezone_offset_minutes=timezone, ) except Exception as e: @@ -2002,6 +2008,11 @@ async def get_user_daily_activity_aggregated( default=None, description="Filter by specific API key", ), + timezone: Optional[int] = fastapi.Query( + default=None, + description="Timezone offset in minutes from UTC (e.g., 480 for PST). " + "Matches JavaScript's Date.getTimezoneOffset() convention.", + ), user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ) -> SpendAnalyticsPaginatedResponse: """ @@ -2037,6 +2048,7 @@ async def get_user_daily_activity_aggregated( end_date=end_date, model=model, api_key=api_key, + timezone_offset_minutes=timezone, ) except Exception as e: diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 84895c64ff..77cc137b73 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1703,6 +1703,8 @@ const buildDailyActivityUrl = ( params.append("end_date", formatDate(endTime)); params.append("page_size", DEFAULT_DAILY_ACTIVITY_PAGE_SIZE); params.append("page", page.toString()); + // Send timezone offset so backend can adjust date range for UTC storage + params.append("timezone", new Date().getTimezoneOffset().toString()); if (extraQueryParams) { Object.entries(extraQueryParams).forEach(([key, value]) => { @@ -3567,6 +3569,8 @@ export const userDailyActivityAggregatedCall = async (accessToken: string, start }; queryParams.append("start_date", formatDate(startTime)); queryParams.append("end_date", formatDate(endTime)); + // Send timezone offset so backend can adjust date range for UTC storage + queryParams.append("timezone", new Date().getTimezoneOffset().toString()); const queryString = queryParams.toString(); if (queryString) { url += `?${queryString}`;