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:
Carlos Escalante
2026-07-04 19:33:27 -06:00
parent 8f51e33fe0
commit 7b7c741ba2
6 changed files with 96 additions and 18 deletions

View File

@@ -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()],