e4e1d1da49
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>
48 lines
1.9 KiB
Python
48 lines
1.9 KiB
Python
"""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()
|