Files
Synculous-2/core/tz.py
chelsea 80ebecf0b1 Changes Made
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.
2026-02-17 18:02:07 -06:00

94 lines
3.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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