fix(tz): interpret calendar and briefing dates in user's local timezone
Two related bugs where the server defaulted naive datetimes to UTC instead of the configured user timezone, causing all-day events to land on the previous day and briefings to "disappear" at UTC midnight. - New services/tz.py helpers: get_user_tz, user_today, user_briefing_date (the briefing day flips at 4am local to align with the compilation slot, so the 00:00-04:00 local window still shows yesterday's briefing until the new one is generated). - calendar create/list/update tools now parse naive datetimes in the user's TZ before converting to UTC for storage, and tool descriptions tell the model to pass plain local dates. - briefing_conversations.get_or_create_today_conversation and the reset-today route use user_briefing_date so the in-progress briefing doesn't get replaced at 19:00 NY / UTC midnight. - _run_profile_closeout targets user-local "yesterday" for consistency. Regression tests added for the TZ helpers and the calendar tool. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -330,10 +330,13 @@ async def reset_today_briefing():
|
||||
driven from the MCP when iterating on prompts. Pair with
|
||||
``POST /api/briefing/trigger`` to immediately regenerate.
|
||||
"""
|
||||
from datetime import date as _date
|
||||
from sqlalchemy import delete as _delete
|
||||
|
||||
today = _date.today()
|
||||
from fabledassistant.services.tz import user_briefing_date
|
||||
|
||||
# User-local briefing day (flips at 4am local), not ``date.today()`` —
|
||||
# see services/tz.py for rationale.
|
||||
today = await user_briefing_date(g.user.id)
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Create and manage briefing conversations."""
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime, timezone
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.conversation import Conversation, Message
|
||||
from fabledassistant.services.tz import user_briefing_date
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -15,7 +16,10 @@ async def get_or_create_today_conversation(user_id: int, model: str) -> Conversa
|
||||
"""
|
||||
Return today's briefing conversation, creating it if it doesn't exist.
|
||||
"""
|
||||
today = date.today()
|
||||
# "Today" is the user-local briefing day (flips at 4am local), not
|
||||
# ``date.today()`` — in a UTC container the latter rolls over at
|
||||
# 19:00 NY local and makes the in-progress briefing disappear.
|
||||
today = await user_briefing_date(user_id)
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
|
||||
@@ -445,9 +445,12 @@ async def _run_profile_closeout(user_id: int, model: str) -> None:
|
||||
"""
|
||||
from fabledassistant.services.user_profile import append_observations
|
||||
from fabledassistant.services.llm import generate_completion
|
||||
from fabledassistant.services.tz import user_today
|
||||
from fabledassistant.models.conversation import Conversation, Message
|
||||
|
||||
yesterday = (date.today() - timedelta(days=1))
|
||||
# User-local "yesterday" so closeout always targets the day that just
|
||||
# ended in the user's timezone, regardless of container TZ.
|
||||
yesterday = (await user_today(user_id)) - timedelta(days=1)
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
|
||||
@@ -14,15 +14,37 @@ from fabledassistant.services.events import (
|
||||
)
|
||||
from fabledassistant.services.tools._helpers import resolve_project
|
||||
from fabledassistant.services.tools._registry import tool
|
||||
from fabledassistant.services.tz import get_user_tz
|
||||
|
||||
|
||||
async def _parse_datetime_in_user_tz(
|
||||
user_id: int, value: str
|
||||
) -> tuple[datetime, bool]:
|
||||
"""Parse a date/datetime string from the model into a UTC-aware datetime.
|
||||
|
||||
Naive inputs are interpreted in the **user's local timezone** and then
|
||||
converted to UTC for storage. Never default to UTC for naive inputs —
|
||||
that's how all-day events landed on the wrong day for non-UTC users.
|
||||
|
||||
Returns ``(utc_datetime, was_date_only)``.
|
||||
"""
|
||||
was_date_only = "T" not in value and " " not in value
|
||||
if was_date_only:
|
||||
value = f"{value}T00:00:00"
|
||||
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if dt.tzinfo is None:
|
||||
user_tz = await get_user_tz(user_id)
|
||||
dt = dt.replace(tzinfo=user_tz)
|
||||
return dt.astimezone(timezone.utc), was_date_only
|
||||
|
||||
|
||||
@tool(
|
||||
name="create_event",
|
||||
description="Create a calendar event for the user. Use this when the user asks to schedule, add, or create a meeting, appointment, or event.",
|
||||
description="Create a calendar event for the user. Use this when the user asks to schedule, add, or create a meeting, appointment, or event. Pass dates and datetimes in the user's local time — a bare date like '2026-09-30' is interpreted as that day in the user's configured timezone.",
|
||||
parameters={
|
||||
"title": {"type": "string", "description": "A descriptive event title"},
|
||||
"start": {"type": "string", "description": "Start date (YYYY-MM-DD) or datetime (ISO 8601 with timezone offset)"},
|
||||
"end": {"type": "string", "description": "Optional end date or datetime"},
|
||||
"start": {"type": "string", "description": "Start date (YYYY-MM-DD) or datetime (YYYY-MM-DDTHH:MM) in the user's local time. Include a timezone offset only if the user explicitly mentions one."},
|
||||
"end": {"type": "string", "description": "Optional end date or datetime in the user's local time"},
|
||||
"duration": {"type": "integer", "description": "Optional duration in minutes (default 60, ignored if end is set or all_day is true)"},
|
||||
"description": {"type": "string", "description": "Optional event description"},
|
||||
"location": {"type": "string", "description": "Optional event location"},
|
||||
@@ -40,23 +62,21 @@ async def create_event_tool(*, user_id, arguments, **_ctx):
|
||||
start_str = arguments["start"]
|
||||
end_str = arguments.get("end")
|
||||
all_day = arguments.get("all_day", False)
|
||||
if "T" not in start_str and " " not in start_str:
|
||||
all_day = True
|
||||
start_str = f"{start_str}T00:00:00"
|
||||
try:
|
||||
start_dt = datetime.fromisoformat(start_str)
|
||||
if start_dt.tzinfo is None:
|
||||
start_dt = start_dt.replace(tzinfo=timezone.utc)
|
||||
# Naive dates/datetimes are interpreted in the user's local
|
||||
# timezone, not UTC. Storing UTC for naive inputs caused all-day
|
||||
# events to land on the previous day for negative-offset users.
|
||||
start_dt, start_was_date_only = await _parse_datetime_in_user_tz(
|
||||
user_id, start_str
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
return {"success": False, "error": f"Invalid start datetime: {start_str!r}"}
|
||||
if start_was_date_only:
|
||||
all_day = True
|
||||
end_dt = None
|
||||
if end_str:
|
||||
if "T" not in end_str and " " not in end_str:
|
||||
end_str = f"{end_str}T00:00:00"
|
||||
try:
|
||||
end_dt = datetime.fromisoformat(end_str)
|
||||
if end_dt.tzinfo is None:
|
||||
end_dt = end_dt.replace(tzinfo=timezone.utc)
|
||||
end_dt, _ = await _parse_datetime_in_user_tz(user_id, end_str)
|
||||
except (ValueError, TypeError):
|
||||
return {"success": False, "error": f"Invalid end datetime: {end_str!r}"}
|
||||
project_id = None
|
||||
@@ -86,25 +106,30 @@ async def create_event_tool(*, user_id, arguments, **_ctx):
|
||||
|
||||
@tool(
|
||||
name="list_events",
|
||||
description="List calendar events in a date range. Use this when the user asks what events or meetings they have. Always use full-day UTC ranges: date_from at T00:00:00Z and date_to at T23:59:59Z for the days of interest so events stored in UTC are not missed.",
|
||||
description="List calendar events in a date range. Use this when the user asks what events or meetings they have. Pass plain local dates (YYYY-MM-DD) — the server interprets them in the user's timezone and expands to a full local day.",
|
||||
parameters={
|
||||
"date_from": {"type": "string", "description": "Start of range in ISO 8601 UTC format (e.g. 2025-01-15T00:00:00Z). Use T00:00:00Z for the start of the day."},
|
||||
"date_to": {"type": "string", "description": "End of range in ISO 8601 UTC format (e.g. 2025-01-22T23:59:59Z). Use T23:59:59Z for the end of the day."},
|
||||
"date_from": {"type": "string", "description": "Start of range as a local date (YYYY-MM-DD) or local datetime. Interpreted in the user's timezone."},
|
||||
"date_to": {"type": "string", "description": "End of range as a local date (YYYY-MM-DD) or local datetime. A bare date is expanded to 23:59:59 local."},
|
||||
},
|
||||
required=["date_from", "date_to"],
|
||||
read_only=True,
|
||||
briefing=True,
|
||||
)
|
||||
async def list_events_tool(*, user_id, arguments, **_ctx):
|
||||
# Bare local dates are expanded to a full local day in the user's TZ:
|
||||
# date_from → 00:00 local, date_to → 23:59:59 local, both converted to
|
||||
# UTC before the DB query. Previously the tool description told the
|
||||
# model to pass UTC ranges, which missed events for non-UTC users.
|
||||
try:
|
||||
date_from = datetime.fromisoformat(arguments["date_from"].replace("Z", "+00:00"))
|
||||
date_to = datetime.fromisoformat(arguments["date_to"].replace("Z", "+00:00"))
|
||||
date_from, _ = await _parse_datetime_in_user_tz(
|
||||
user_id, arguments["date_from"]
|
||||
)
|
||||
date_to_str = arguments["date_to"]
|
||||
if "T" not in date_to_str and " " not in date_to_str:
|
||||
date_to_str = f"{date_to_str}T23:59:59"
|
||||
date_to, _ = await _parse_datetime_in_user_tz(user_id, date_to_str)
|
||||
except (ValueError, TypeError, KeyError) as exc:
|
||||
return {"success": False, "error": f"Invalid date range: {exc}"}
|
||||
if date_from.tzinfo is None:
|
||||
date_from = date_from.replace(tzinfo=timezone.utc)
|
||||
if date_to.tzinfo is None:
|
||||
date_to = date_to.replace(tzinfo=timezone.utc)
|
||||
events = await events_list_events(user_id=user_id, date_from=date_from, date_to=date_to)
|
||||
return {
|
||||
"success": True,
|
||||
@@ -177,9 +202,9 @@ async def update_event_tool(*, user_id, arguments, **_ctx):
|
||||
val = arguments.get(key)
|
||||
if val:
|
||||
try:
|
||||
dt = datetime.fromisoformat(val.replace("Z", "+00:00"))
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
# Naive datetimes are user-local, not UTC — see
|
||||
# ``_parse_datetime_in_user_tz`` docstring.
|
||||
dt, _ = await _parse_datetime_in_user_tz(user_id, val)
|
||||
fields[dt_field] = dt
|
||||
except (ValueError, TypeError):
|
||||
return {"success": False, "error": f"Invalid datetime for {key}: {val!r}"}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""User-timezone helpers.
|
||||
|
||||
All datetimes in the DB are stored as UTC. The helpers here bridge between
|
||||
that UTC storage and the user's configured local timezone (IANA string in
|
||||
the ``user_timezone`` setting). Use these anywhere the model or UI talks
|
||||
in terms of "today", "tomorrow", or a bare calendar date — never
|
||||
``date.today()`` or ``datetime.now(timezone.utc)`` inside a per-user flow.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from fabledassistant.services.settings import get_setting
|
||||
|
||||
# Briefing day boundary — kept in sync with the compilation slot in
|
||||
# ``briefing_scheduler.SLOTS``. The briefing day flips at this local hour
|
||||
# (not midnight) so the 00:00–04:00 local window still shows yesterday's
|
||||
# briefing until the 4am compilation generates the new one.
|
||||
BRIEFING_DAY_START_HOUR = 4
|
||||
|
||||
|
||||
async def get_user_tz(user_id: int) -> ZoneInfo:
|
||||
"""Return the user's IANA ``ZoneInfo``, falling back to UTC."""
|
||||
tz_str = await get_setting(user_id, "user_timezone") or "UTC"
|
||||
try:
|
||||
return ZoneInfo(tz_str)
|
||||
except (ZoneInfoNotFoundError, KeyError):
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
|
||||
async def user_today(user_id: int) -> date:
|
||||
"""Return today's calendar date in the user's local timezone."""
|
||||
tz = await get_user_tz(user_id)
|
||||
return datetime.now(tz).date()
|
||||
|
||||
|
||||
async def user_briefing_date(user_id: int) -> date:
|
||||
"""Return the current "briefing day" in the user's local timezone.
|
||||
|
||||
The briefing day flips at ``BRIEFING_DAY_START_HOUR`` (4am local),
|
||||
aligned with the compilation slot that generates the day's briefing.
|
||||
Between 00:00 and 04:00 local this still returns *yesterday*, so the
|
||||
UI keeps showing the in-progress briefing until the new one is built.
|
||||
"""
|
||||
tz = await get_user_tz(user_id)
|
||||
return (datetime.now(tz) - timedelta(hours=BRIEFING_DAY_START_HOUR)).date()
|
||||
Reference in New Issue
Block a user