From 7b7c741ba22dcece4f6a9950b84ea705ecdfe317 Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Sat, 4 Jul 2026 19:33:27 -0600 Subject: [PATCH] Agent: resolve "today" in the browser's timezone, not hardcoded CR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CR-hardcoded today broke two scenarios: the owner traveling, and any future non-CR user. Now the provider sends the browser's IANA timezone (X-Client-Timezone) with every CopilotKit request — the runtime forwards x-* headers to the agent on its own — and the backend binds it to a per-request ContextVar next to the DB session. get_current_date and the future-cuota bounds use it; the tool also reports the timezone so the model can echo it. Unknown or absent zones (n8n posts, tests) fall back to Costa Rica; comma-joined duplicate header values are tolerated. Co-Authored-By: Claude Fable 5 --- backend/app/agent/agent.py | 7 +++-- backend/app/agent/tools.py | 21 +++++++------- backend/app/main.py | 5 ++++ backend/app/timeutil.py | 46 ++++++++++++++++++++++++++++--- backend/tests/test_agent_tools.py | 23 ++++++++++++++++ frontend/src/App.tsx | 12 +++++++- 6 files changed, 96 insertions(+), 18 deletions(-) diff --git a/backend/app/agent/agent.py b/backend/app/agent/agent.py index ec2259e..8e24381 100644 --- a/backend/app/agent/agent.py +++ b/backend/app/agent/agent.py @@ -21,9 +21,10 @@ Context you can rely on: qualifiers, assume they mean the calendar month unless they mention "cycle", "corte", or their credit card. - You do NOT have a reliable clock, and the server runs in UTC while the - user lives in Costa Rica (UTC-6). Whenever the question involves a - relative date — "hoy", "ayer", "este mes", "este ciclo", "el año - pasado" — call get_current_date FIRST and derive ranges from its answer. + user may be anywhere (their browser reports their timezone per request). + Whenever the question involves a relative date — "hoy", "ayer", "este + mes", "este ciclo", "el año pasado" — call get_current_date FIRST and + derive ranges from its answer. - Amounts are stored as raw numbers in their native currency (see `currency` field on transactions/accounts). Tools that return `total_crc` are already converted; tools that return per-transaction amounts are NOT. diff --git a/backend/app/agent/tools.py b/backend/app/agent/tools.py index a439052..c617fc5 100644 --- a/backend/app/agent/tools.py +++ b/backend/app/agent/tools.py @@ -41,7 +41,7 @@ from app.services.budget_projection import ( get_cycle_range, ) from app.services import exchange_rate as fx -from app.timeutil import today_cr +from app.timeutil import client_tz, today_client from app.services.exchange_rate import ( get_converted_amount_expr, get_current_rate, @@ -145,8 +145,8 @@ def get_recent_transactions( ), # Tasa Cero generates future-dated cuotas; "recent" means already # billed (same rule as /transactions/recent). Bound is end of the - # user's today, Costa Rica time. - Transaction.date < datetime.combine(today_cr() + timedelta(days=1), time.min), + # user's today in their request timezone (CR fallback). + Transaction.date < datetime.combine(today_client() + timedelta(days=1), time.min), ) if source: q = q.where(Transaction.source == TransactionSource(source)) @@ -519,7 +519,7 @@ def get_daily_spending( Transaction.date >= datetime.fromisoformat(start_date), Transaction.date < datetime.fromisoformat(end_date), # Future-dated Tasa Cero cuotas are not money already spent. - Transaction.date < datetime.combine(today_cr() + timedelta(days=1), time.min), + Transaction.date < datetime.combine(today_client() + timedelta(days=1), time.min), ) .group_by(func.date(Transaction.date)) .order_by(func.date(Transaction.date)) @@ -531,12 +531,12 @@ def get_daily_spending( def get_current_date() -> dict: - """Today's date in the user's timezone (Costa Rica, UTC-6) and the - active credit-card billing cycle. ALWAYS call this before resolving any - relative date reference — 'hoy', 'ayer', 'este mes', 'este ciclo', - 'el ciclo pasado' — the server clock and your own assumptions about - today are unreliable.""" - today = today_cr() + """Today's date in the user's own timezone (sent by their browser; + Costa Rica fallback) and the active credit-card billing cycle. ALWAYS + call this before resolving any relative date reference — 'hoy', 'ayer', + 'este mes', 'este ciclo', 'el ciclo pasado' — the server clock and your + own assumptions about today are unreliable.""" + today = today_client() # get_cycle_range(y, m) is the cycle STARTING on the 18th of m; before # the 18th we are still in the cycle that started last month. if today.day >= 18: @@ -549,6 +549,7 @@ def get_current_date() -> dict: return { "date": today.isoformat(), "weekday": today.strftime("%A"), + "timezone": str(client_tz()), "cycle_year": cycle_year, "cycle_month": cycle_month, "cycle_range": [start.date().isoformat(), end.date().isoformat()], diff --git a/backend/app/main.py b/backend/app/main.py index d1b09a3..b84c878 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -23,6 +23,7 @@ from app.auth import ( from app.config import settings from app.db import get_session, run_alembic_upgrade from app.seed import seed_db +from app.timeutil import reset_client_timezone, set_client_timezone from app.services.exchange_rate import refresh_rates_periodically @@ -136,9 +137,13 @@ async def agent_auth_and_session(request: Request, call_next): session_gen = get_session() session = next(session_gen) token_var = set_session(session) + # Browser's IANA timezone, forwarded by the BFF — lets tools resolve + # "today" wherever the user is (falls back to Costa Rica). + tz_token = set_client_timezone(request.headers.get("x-client-timezone")) try: return await call_next(request) finally: + reset_client_timezone(tz_token) reset_session(token_var) try: next(session_gen) diff --git a/backend/app/timeutil.py b/backend/app/timeutil.py index c88bf7b..b340708 100644 --- a/backend/app/timeutil.py +++ b/backend/app/timeutil.py @@ -1,16 +1,54 @@ -from datetime import date, datetime, timedelta, timezone +import contextvars +from datetime import date, datetime, timedelta, timezone, tzinfo +from zoneinfo import ZoneInfo -# Costa Rica has no DST; a fixed offset is exact year-round. +# Costa Rica has no DST; a fixed offset is exact year-round. This is the +# FALLBACK when the request doesn't declare a timezone (n8n posts, tests). CR_TZ = timezone(timedelta(hours=-6)) +# Per-request client timezone, set by the agent middleware from the +# X-Client-Timezone header (IANA name sent by the browser). Falls back to +# Costa Rica so headless callers keep the owner's local semantics. +_client_tz: contextvars.ContextVar[tzinfo] = contextvars.ContextVar( + "client_tz", default=CR_TZ +) -def today_cr() -> date: - """Current date in Costa Rica (UTC-6). + +def set_client_timezone(name: str | None) -> contextvars.Token | None: + """Bind the request's IANA timezone; ignores unknown/absent names. + + Proxies that see the header twice merge the values into a comma list + ("Zone, Zone") — take the first entry. + """ + if not name: + return None + try: + return _client_tz.set(ZoneInfo(name.split(",")[0].strip())) + except (KeyError, ValueError): + return None + + +def reset_client_timezone(token: contextvars.Token | None) -> None: + if token is not None: + _client_tz.reset(token) + + +def client_tz() -> tzinfo: + return _client_tz.get() + + +def today_client() -> date: + """Current date where the USER is (request timezone, CR fallback). The server clock is UTC — at 6 pm in Costa Rica it is already "tomorrow" in UTC. Anything user-facing that means "today" (agent answers, date defaults) must come from here, never date.today(). """ + return datetime.now(client_tz()).date() + + +def today_cr() -> date: + """Current date in Costa Rica (UTC-6) — timezone-independent fallback.""" return datetime.now(CR_TZ).date() diff --git a/backend/tests/test_agent_tools.py b/backend/tests/test_agent_tools.py index a0ac1df..62cf139 100644 --- a/backend/tests/test_agent_tools.py +++ b/backend/tests/test_agent_tools.py @@ -214,3 +214,26 @@ def test_daily_spending_day_scoped(bound_session): assert out[0]["date"] == today.date().isoformat() assert out[0]["total_crc"] == 150.0 assert out[0]["count"] == 2 + + +def test_get_current_date_respects_client_timezone(): + from datetime import datetime, timezone as tz + + from app.timeutil import ( + client_tz, + reset_client_timezone, + set_client_timezone, + ) + + token = set_client_timezone("Asia/Tokyo") + try: + out = tools.get_current_date() + assert out["timezone"] == "Asia/Tokyo" + assert out["date"] == datetime.now(client_tz()).date().isoformat() + finally: + reset_client_timezone(token) + + # Unknown names are ignored — CR fallback stays active. + assert set_client_timezone("Not/AZone") is None + assert set_client_timezone(None) is None + assert tools.get_current_date()["timezone"] == "UTC-06:00" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8b4ca31..60b53d8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -77,7 +77,17 @@ export default function App() { - +