"""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()