Merge pull request #20472 from nina-hu/fix/timezone-daily-spend-filtering

fix(ui): adjust daily spend date filtering for user timezone
This commit is contained in:
yuneng-jiang
2026-02-05 12:12:44 -08:00
committed by GitHub
3 changed files with 70 additions and 4 deletions
@@ -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)
@@ -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:
@@ -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}`;