This commit is contained in:
timka123 committed 2026-10-18 18:02:05 +03:00
commit 17a0a3c4c9
121 files changed
+17641

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
"""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"