diff --git a/src/fabledassistant/routes/briefing.py b/src/fabledassistant/routes/briefing.py index fba4923..d288d85 100644 --- a/src/fabledassistant/routes/briefing.py +++ b/src/fabledassistant/routes/briefing.py @@ -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( diff --git a/src/fabledassistant/services/briefing_conversations.py b/src/fabledassistant/services/briefing_conversations.py index dc6207b..f442b19 100644 --- a/src/fabledassistant/services/briefing_conversations.py +++ b/src/fabledassistant/services/briefing_conversations.py @@ -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( diff --git a/src/fabledassistant/services/briefing_scheduler.py b/src/fabledassistant/services/briefing_scheduler.py index f5b8134..62826c5 100644 --- a/src/fabledassistant/services/briefing_scheduler.py +++ b/src/fabledassistant/services/briefing_scheduler.py @@ -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( diff --git a/src/fabledassistant/services/tools/calendar.py b/src/fabledassistant/services/tools/calendar.py index a86edc2..90828e2 100644 --- a/src/fabledassistant/services/tools/calendar.py +++ b/src/fabledassistant/services/tools/calendar.py @@ -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}"} diff --git a/src/fabledassistant/services/tz.py b/src/fabledassistant/services/tz.py new file mode 100644 index 0000000..b7b23b1 --- /dev/null +++ b/src/fabledassistant/services/tz.py @@ -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() diff --git a/tests/test_calendar_tool_tz.py b/tests/test_calendar_tool_tz.py new file mode 100644 index 0000000..bab715f --- /dev/null +++ b/tests/test_calendar_tool_tz.py @@ -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) diff --git a/tests/test_tz_helpers.py b/tests/test_tz_helpers.py new file mode 100644 index 0000000..09f70d6 --- /dev/null +++ b/tests/test_tz_helpers.py @@ -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:00–03: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"