1. config/schema.sql — Added timezone_name VARCHAR(100) to user_preferences table + an ALTER TABLE migration at the bottom for existing DBs. 2. core/tz.py — Rewrote with dual-path timezone support: - Request context: _get_request_tz() now checks X-Timezone-Name header (IANA) first, falls back to X-Timezone-Offset - Background jobs: New tz_for_user(user_uuid) and user_now_for(user_uuid) read stored timezone_name from prefs, fall back to numeric offset, then UTC - All existing function signatures (user_now(), user_today()) preserved for backward compat 3. scheduler/daemon.py — Fixed 3 bugs: - _user_now_for() now delegates to tz.user_now_for() which uses IANA timezone names (DST-safe) - check_nagging() — replaced datetime.utcnow() with _user_now_for(user_uuid) so nags evaluate in user's timezone - poll_callback() — replaced single UTC midnight check with _check_per_user_midnight_schedules() that iterates users and creates daily schedules at their local midnight 4. api/routes/preferences.py — Added "timezone_name" to allowed PUT fields. 5. synculous-client/src/lib/api.ts — Added X-Timezone-Name header to every request + added timezone_name to preferences update type. 6. synculous-client/src/app/dashboard/layout.tsx — Now syncs both timezone_offset and timezone_name (via Intl.DateTimeFormat().resolvedOptions().timeZone) on session start.
94 lines
3.1 KiB
Python
94 lines
3.1 KiB
Python
"""
|
||
core/tz.py - Timezone-aware date/time helpers
|
||
|
||
The frontend sends:
|
||
X-Timezone-Name – IANA timezone (e.g. "America/Chicago"), preferred
|
||
X-Timezone-Offset – minutes from UTC (JS getTimezoneOffset sign), fallback
|
||
|
||
For background tasks (no request context) the scheduler reads the stored
|
||
timezone_name / timezone_offset from user_preferences.
|
||
"""
|
||
|
||
from datetime import datetime, date, timezone, timedelta
|
||
from zoneinfo import ZoneInfo
|
||
|
||
import core.postgres as postgres
|
||
|
||
# ── Request-context helpers (used by Flask route handlers) ────────────
|
||
|
||
def _get_request_tz():
|
||
"""Return a tzinfo from the current Flask request headers.
|
||
Prefers X-Timezone-Name (IANA), falls back to X-Timezone-Offset."""
|
||
try:
|
||
import flask
|
||
name = flask.request.headers.get("X-Timezone-Name")
|
||
if name:
|
||
try:
|
||
return ZoneInfo(name)
|
||
except (KeyError, Exception):
|
||
pass
|
||
offset = int(flask.request.headers.get("X-Timezone-Offset", 0))
|
||
return timezone(timedelta(minutes=-offset))
|
||
except (ValueError, TypeError, RuntimeError):
|
||
return timezone.utc
|
||
|
||
|
||
def _get_offset_minutes():
|
||
"""Read the timezone offset header from the current Flask request.
|
||
Returns 0 if outside a request context or header is absent."""
|
||
try:
|
||
import flask
|
||
return int(flask.request.headers.get("X-Timezone-Offset", 0))
|
||
except (ValueError, TypeError, RuntimeError):
|
||
return 0
|
||
|
||
|
||
def _offset_to_tz(offset_minutes):
|
||
"""Convert JS-style offset (positive = behind UTC) to a timezone object."""
|
||
return timezone(timedelta(minutes=-offset_minutes))
|
||
|
||
|
||
def user_now(offset_minutes=None):
|
||
"""Current datetime in the user's timezone (request-context).
|
||
If offset_minutes is provided, uses that directly.
|
||
Otherwise reads request headers (prefers IANA name over offset)."""
|
||
if offset_minutes is not None:
|
||
tz = _offset_to_tz(offset_minutes)
|
||
else:
|
||
tz = _get_request_tz()
|
||
return datetime.now(tz)
|
||
|
||
|
||
def user_today(offset_minutes=None):
|
||
"""Current date in the user's timezone."""
|
||
return user_now(offset_minutes).date()
|
||
|
||
|
||
# ── Stored-preference helpers (used by scheduler / background jobs) ───
|
||
|
||
def tz_for_user(user_uuid):
|
||
"""Return a tzinfo for *user_uuid* from stored preferences.
|
||
Priority: timezone_name (IANA) > timezone_offset (minutes) > UTC."""
|
||
prefs = postgres.select_one("user_preferences", {"user_uuid": user_uuid})
|
||
if prefs:
|
||
name = prefs.get("timezone_name")
|
||
if name:
|
||
try:
|
||
return ZoneInfo(name)
|
||
except (KeyError, Exception):
|
||
pass
|
||
offset = prefs.get("timezone_offset")
|
||
if offset is not None:
|
||
return timezone(timedelta(minutes=-offset))
|
||
return timezone.utc
|
||
|
||
|
||
def user_now_for(user_uuid):
|
||
"""Current datetime in a user's timezone using their stored preferences."""
|
||
return datetime.now(tz_for_user(user_uuid))
|
||
|
||
|
||
def user_today_for(user_uuid):
|
||
"""Current date in a user's timezone using their stored preferences."""
|
||
return user_now_for(user_uuid).date()
|