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:
2026-04-13 15:35:27 -04:00
parent af6f81e0a7
commit e4e1d1da49
7 changed files with 310 additions and 31 deletions
+5 -2
View File
@@ -330,10 +330,13 @@ async def reset_today_briefing():
driven from the MCP when iterating on prompts. Pair with driven from the MCP when iterating on prompts. Pair with
``POST /api/briefing/trigger`` to immediately regenerate. ``POST /api/briefing/trigger`` to immediately regenerate.
""" """
from datetime import date as _date
from sqlalchemy import delete as _delete 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: async with async_session() as session:
result = await session.execute( result = await session.execute(
select(Conversation).where( select(Conversation).where(
@@ -1,12 +1,13 @@
"""Create and manage briefing conversations.""" """Create and manage briefing conversations."""
import logging import logging
from datetime import date, datetime, timezone from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from fabledassistant.models import async_session from fabledassistant.models import async_session
from fabledassistant.models.conversation import Conversation, Message from fabledassistant.models.conversation import Conversation, Message
from fabledassistant.services.tz import user_briefing_date
logger = logging.getLogger(__name__) 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. 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: async with async_session() as session:
result = await session.execute( result = await session.execute(
select(Conversation).where( 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.user_profile import append_observations
from fabledassistant.services.llm import generate_completion from fabledassistant.services.llm import generate_completion
from fabledassistant.services.tz import user_today
from fabledassistant.models.conversation import Conversation, Message 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: async with async_session() as session:
result = await session.execute( result = await session.execute(
select(Conversation).where( select(Conversation).where(
+51 -26
View File
@@ -14,15 +14,37 @@ from fabledassistant.services.events import (
) )
from fabledassistant.services.tools._helpers import resolve_project from fabledassistant.services.tools._helpers import resolve_project
from fabledassistant.services.tools._registry import tool 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( @tool(
name="create_event", 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={ parameters={
"title": {"type": "string", "description": "A descriptive event title"}, "title": {"type": "string", "description": "A descriptive event title"},
"start": {"type": "string", "description": "Start date (YYYY-MM-DD) or datetime (ISO 8601 with timezone offset)"}, "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"}, "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)"}, "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"}, "description": {"type": "string", "description": "Optional event description"},
"location": {"type": "string", "description": "Optional event location"}, "location": {"type": "string", "description": "Optional event location"},
@@ -40,23 +62,21 @@ async def create_event_tool(*, user_id, arguments, **_ctx):
start_str = arguments["start"] start_str = arguments["start"]
end_str = arguments.get("end") end_str = arguments.get("end")
all_day = arguments.get("all_day", False) 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: try:
start_dt = datetime.fromisoformat(start_str) # Naive dates/datetimes are interpreted in the user's local
if start_dt.tzinfo is None: # timezone, not UTC. Storing UTC for naive inputs caused all-day
start_dt = start_dt.replace(tzinfo=timezone.utc) # 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): except (ValueError, TypeError):
return {"success": False, "error": f"Invalid start datetime: {start_str!r}"} return {"success": False, "error": f"Invalid start datetime: {start_str!r}"}
if start_was_date_only:
all_day = True
end_dt = None end_dt = None
if end_str: if end_str:
if "T" not in end_str and " " not in end_str:
end_str = f"{end_str}T00:00:00"
try: try:
end_dt = datetime.fromisoformat(end_str) end_dt, _ = await _parse_datetime_in_user_tz(user_id, end_str)
if end_dt.tzinfo is None:
end_dt = end_dt.replace(tzinfo=timezone.utc)
except (ValueError, TypeError): except (ValueError, TypeError):
return {"success": False, "error": f"Invalid end datetime: {end_str!r}"} return {"success": False, "error": f"Invalid end datetime: {end_str!r}"}
project_id = None project_id = None
@@ -86,25 +106,30 @@ async def create_event_tool(*, user_id, arguments, **_ctx):
@tool( @tool(
name="list_events", 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={ 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_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 in ISO 8601 UTC format (e.g. 2025-01-22T23:59:59Z). Use T23:59:59Z for the end of the day."}, "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"], required=["date_from", "date_to"],
read_only=True, read_only=True,
briefing=True, briefing=True,
) )
async def list_events_tool(*, user_id, arguments, **_ctx): 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: try:
date_from = datetime.fromisoformat(arguments["date_from"].replace("Z", "+00:00")) date_from, _ = await _parse_datetime_in_user_tz(
date_to = datetime.fromisoformat(arguments["date_to"].replace("Z", "+00:00")) 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: except (ValueError, TypeError, KeyError) as exc:
return {"success": False, "error": f"Invalid date range: {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) events = await events_list_events(user_id=user_id, date_from=date_from, date_to=date_to)
return { return {
"success": True, "success": True,
@@ -177,9 +202,9 @@ async def update_event_tool(*, user_id, arguments, **_ctx):
val = arguments.get(key) val = arguments.get(key)
if val: if val:
try: try:
dt = datetime.fromisoformat(val.replace("Z", "+00:00")) # Naive datetimes are user-local, not UTC — see
if dt.tzinfo is None: # ``_parse_datetime_in_user_tz`` docstring.
dt = dt.replace(tzinfo=timezone.utc) dt, _ = await _parse_datetime_in_user_tz(user_id, val)
fields[dt_field] = dt fields[dt_field] = dt
except (ValueError, TypeError): except (ValueError, TypeError):
return {"success": False, "error": f"Invalid datetime for {key}: {val!r}"} return {"success": False, "error": f"Invalid datetime for {key}: {val!r}"}
+47
View File
@@ -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:0004: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()
+134
View File
@@ -0,0 +1,134 @@
"""Regression tests: calendar tool respects the user's configured timezone."""
from datetime import timezone
from unittest.mock import AsyncMock, patch
import pytest
@pytest.mark.asyncio
async def test_create_event_all_day_bare_date_interprets_local_midnight():
"""Bare date like '2026-09-30' for a NY user = 2026-09-30 00:00 NY,
stored as 2026-09-30 04:00 UTC — NOT 2026-09-30 00:00 UTC (which would
be 2026-09-29 19:00 NY and land on the wrong day)."""
from fabledassistant.services.tools.calendar import create_event_tool
captured = {}
async def fake_create_event(**kwargs):
captured.update(kwargs)
event = AsyncMock()
event.to_dict.return_value = {"id": 1, **{k: v for k, v in kwargs.items() if not callable(v)}}
return event
with patch(
"fabledassistant.services.tools.calendar.get_user_tz",
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
), patch(
"fabledassistant.services.tools.calendar.events_create_event",
side_effect=fake_create_event,
):
result = await create_event_tool(
user_id=1,
arguments={"title": "Birthday", "start": "2026-09-30"},
)
assert result["success"] is True
start_dt = captured["start_dt"]
assert start_dt.tzinfo is not None
# Converted to UTC: 00:00 NY (EDT, UTC-4) == 04:00 UTC on the same day
utc = start_dt.astimezone(timezone.utc)
assert (utc.year, utc.month, utc.day) == (2026, 9, 30)
assert utc.hour == 4 # EDT offset; would be 5 in EST
assert captured["all_day"] is True
@pytest.mark.asyncio
async def test_create_event_naive_datetime_is_user_local():
"""'2026-03-15T14:30' for a NY user means 2:30pm NY, not 2:30pm UTC."""
from fabledassistant.services.tools.calendar import create_event_tool
captured = {}
async def fake_create_event(**kwargs):
captured.update(kwargs)
event = AsyncMock()
event.to_dict.return_value = {"id": 1}
return event
with patch(
"fabledassistant.services.tools.calendar.get_user_tz",
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
), patch(
"fabledassistant.services.tools.calendar.events_create_event",
side_effect=fake_create_event,
):
await create_event_tool(
user_id=1,
arguments={"title": "Meeting", "start": "2026-03-15T14:30"},
)
utc = captured["start_dt"].astimezone(timezone.utc)
# 14:30 NY on 2026-03-15 (after DST start) = 18:30 UTC
assert (utc.year, utc.month, utc.day, utc.hour, utc.minute) == (2026, 3, 15, 18, 30)
@pytest.mark.asyncio
async def test_create_event_explicit_offset_is_respected():
"""Model-supplied timezone offsets must not be overridden by user TZ."""
from fabledassistant.services.tools.calendar import create_event_tool
captured = {}
async def fake_create_event(**kwargs):
captured.update(kwargs)
event = AsyncMock()
event.to_dict.return_value = {"id": 1}
return event
with patch(
"fabledassistant.services.tools.calendar.get_user_tz",
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
), patch(
"fabledassistant.services.tools.calendar.events_create_event",
side_effect=fake_create_event,
):
await create_event_tool(
user_id=1,
arguments={"title": "Meeting", "start": "2026-03-15T14:30+00:00"},
)
utc = captured["start_dt"].astimezone(timezone.utc)
assert (utc.hour, utc.minute) == (14, 30)
@pytest.mark.asyncio
async def test_list_events_bare_date_range_covers_local_day():
"""date_from/date_to as bare dates should cover the full LOCAL day."""
from fabledassistant.services.tools.calendar import list_events_tool
captured = {}
async def fake_list_events(*, user_id, date_from, date_to):
captured["date_from"] = date_from
captured["date_to"] = date_to
return []
with patch(
"fabledassistant.services.tools.calendar.get_user_tz",
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
), patch(
"fabledassistant.services.tools.calendar.events_list_events",
side_effect=fake_list_events,
):
await list_events_tool(
user_id=1,
arguments={"date_from": "2026-09-30", "date_to": "2026-09-30"},
)
df = captured["date_from"].astimezone(timezone.utc)
dt = captured["date_to"].astimezone(timezone.utc)
# 2026-09-30 00:00 NY (EDT) = 04:00 UTC same day
assert (df.year, df.month, df.day, df.hour) == (2026, 9, 30, 4)
# 2026-09-30 23:59:59 NY (EDT) = 03:59:59 UTC next day
assert (dt.year, dt.month, dt.day, dt.hour) == (2026, 10, 1, 3)
+63
View File
@@ -0,0 +1,63 @@
"""Tests for user-timezone helpers (services/tz.py)."""
from datetime import datetime
from unittest.mock import AsyncMock, patch
from zoneinfo import ZoneInfo
import pytest
@pytest.mark.asyncio
async def test_get_user_tz_returns_configured_zone():
from fabledassistant.services import tz as tz_mod
with patch.object(tz_mod, "get_setting", AsyncMock(return_value="America/New_York")):
zone = await tz_mod.get_user_tz(1)
assert zone == ZoneInfo("America/New_York")
@pytest.mark.asyncio
async def test_get_user_tz_falls_back_to_utc_on_bad_input():
from fabledassistant.services import tz as tz_mod
with patch.object(tz_mod, "get_setting", AsyncMock(return_value="Not/AZone")):
zone = await tz_mod.get_user_tz(1)
assert zone == ZoneInfo("UTC")
@pytest.mark.asyncio
async def test_user_briefing_date_before_4am_returns_yesterday():
"""00:0003:59 local still shows yesterday's briefing day."""
from fabledassistant.services import tz as tz_mod
ny = ZoneInfo("America/New_York")
# 02:00 NY on 2026-04-13 → briefing day = 2026-04-12
fake_now = datetime(2026, 4, 13, 2, 0, tzinfo=ny)
class _FakeDatetime(datetime):
@classmethod
def now(cls, tz=None):
return fake_now
with patch.object(tz_mod, "get_user_tz", AsyncMock(return_value=ny)), \
patch.object(tz_mod, "datetime", _FakeDatetime):
day = await tz_mod.user_briefing_date(1)
assert day.isoformat() == "2026-04-12"
@pytest.mark.asyncio
async def test_user_briefing_date_after_4am_returns_today():
from fabledassistant.services import tz as tz_mod
ny = ZoneInfo("America/New_York")
fake_now = datetime(2026, 4, 13, 4, 30, tzinfo=ny)
class _FakeDatetime(datetime):
@classmethod
def now(cls, tz=None):
return fake_now
with patch.object(tz_mod, "get_user_tz", AsyncMock(return_value=ny)), \
patch.object(tz_mod, "datetime", _FakeDatetime):
day = await tz_mod.user_briefing_date(1)
assert day.isoformat() == "2026-04-13"