"""Timezone helpers. Everything is stored in UTC and *reported* in the user's timezone, so there is exactly one place that knows how to turn "now" into "which day does this count for" — here. """ from __future__ import annotations from datetime import date, datetime, time, timedelta, timezone from zoneinfo import ZoneInfo def tz(name: str) -> ZoneInfo: try: return ZoneInfo(name) except Exception: # pragma: no cover - bad tz falls back rather than crashing return ZoneInfo("UTC") def now_utc() -> datetime: return datetime.now(timezone.utc) def ensure_utc(moment: datetime | None) -> datetime | None: """Attach UTC to a naive datetime. SQLite has no timezone type, so a column declared `DateTime(timezone=True)` comes back naive from SQLite and aware from Postgres. Everything we store is UTC, so normalising on read keeps the arithmetic valid on both. """ if moment is None: return None return moment if moment.tzinfo is not None else moment.replace(tzinfo=timezone.utc) def to_local(moment: datetime, tz_name: str) -> datetime: if moment.tzinfo is None: moment = moment.replace(tzinfo=timezone.utc) return moment.astimezone(tz(tz_name)) def local_date(tz_name: str, moment: datetime | None = None) -> date: """The day a moment belongs to for the user (the report's unit of grouping).""" return to_local(moment or now_utc(), tz_name).date() def local_time(tz_name: str, moment: datetime | None = None) -> time: return to_local(moment or now_utc(), tz_name).timetz().replace(tzinfo=None) def day_bounds_utc(day: date, tz_name: str) -> tuple[datetime, datetime]: """The UTC half-open interval `[start, end)` covering a local calendar day.""" zone = tz(tz_name) start = datetime.combine(day, time.min, tzinfo=zone) end = start + timedelta(days=1) return start.astimezone(timezone.utc), end.astimezone(timezone.utc) def in_window(moment: time, start: time, end: time) -> bool: """Whether a clock time is inside a window, handling windows that wrap midnight.""" if start <= end: return start <= moment <= end return moment >= start or moment <= end def humanize_minutes(total_minutes: int | float) -> str: """`95` -> `1h 35m`, `40` -> `40m`, `0` -> `0m`.""" minutes = round(total_minutes) hours, rest = divmod(minutes, 60) if hours and rest: return f"{hours}h {rest}m" if hours: return f"{hours}h" return f"{rest}m"