mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 09:08:46 +02:00
Agent: resolve "today" in the browser's timezone, not hardcoded CR
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -77,7 +77,17 @@ export default function App() {
|
||||
<PrivacyProvider>
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CopilotKit runtimeUrl="/api/copilotkit" agent="wealthysmart">
|
||||
<CopilotKit
|
||||
runtimeUrl="/api/copilotkit"
|
||||
agent="wealthysmart"
|
||||
headers={{
|
||||
// Lets the agent resolve "hoy"/"este mes" in the user's
|
||||
// local time wherever they are; backend falls back to
|
||||
// Costa Rica when absent.
|
||||
"X-Client-Timezone":
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
}}
|
||||
>
|
||||
<AppRoutes />
|
||||
<Toaster richColors position="top-right" closeButton />
|
||||
</CopilotKit>
|
||||
|
||||
Reference in New Issue
Block a user