diff --git a/api/routes/preferences.py b/api/routes/preferences.py index ce27f9f..c1e5640 100644 --- a/api/routes/preferences.py +++ b/api/routes/preferences.py @@ -58,7 +58,7 @@ def register(app): if not data: return flask.jsonify({"error": "missing body"}), 400 - allowed = ["sound_enabled", "haptic_enabled", "show_launch_screen", "celebration_style", "timezone_offset"] + allowed = ["sound_enabled", "haptic_enabled", "show_launch_screen", "celebration_style", "timezone_offset", "timezone_name"] updates = {k: v for k, v in data.items() if k in allowed} if not updates: return flask.jsonify({"error": "no valid fields"}), 400 diff --git a/config/schema.sql b/config/schema.sql index de51ea2..4715cab 100644 --- a/config/schema.sql +++ b/config/schema.sql @@ -154,6 +154,7 @@ CREATE TABLE IF NOT EXISTS user_preferences ( show_launch_screen BOOLEAN DEFAULT TRUE, celebration_style VARCHAR(50) DEFAULT 'standard', timezone_offset INTEGER DEFAULT 0, + timezone_name VARCHAR(100), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); @@ -293,3 +294,7 @@ CREATE TABLE IF NOT EXISTS snitch_log ( CREATE INDEX IF NOT EXISTS idx_snitch_log_user_date ON snitch_log(user_uuid, DATE(sent_at)); CREATE INDEX IF NOT EXISTS idx_snitch_contacts_user ON snitch_contacts(user_uuid, is_active); + +-- ── Migrations ────────────────────────────────────────────── +-- Add IANA timezone name to user preferences (run once on existing DBs) +ALTER TABLE user_preferences ADD COLUMN IF NOT EXISTS timezone_name VARCHAR(100); diff --git a/core/tz.py b/core/tz.py index eed240a..79e5ea8 100644 --- a/core/tz.py +++ b/core/tz.py @@ -1,12 +1,36 @@ """ core/tz.py - Timezone-aware date/time helpers -The frontend sends X-Timezone-Offset (minutes from UTC, same sign as -JavaScript's getTimezoneOffset — positive means behind UTC). -These helpers convert server UTC to the user's local date/time. +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(): @@ -16,7 +40,6 @@ def _get_offset_minutes(): import flask return int(flask.request.headers.get("X-Timezone-Offset", 0)) except (ValueError, TypeError, RuntimeError): - # RuntimeError: outside of request context return 0 @@ -26,14 +49,45 @@ def _offset_to_tz(offset_minutes): def user_now(offset_minutes=None): - """Current datetime in the user's timezone. - If offset_minutes is provided, uses that instead of the request header.""" - if offset_minutes is None: - offset_minutes = _get_offset_minutes() - tz = _offset_to_tz(offset_minutes) + """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() diff --git a/scheduler/daemon.py b/scheduler/daemon.py index 247107b..f10b648 100644 --- a/scheduler/daemon.py +++ b/scheduler/daemon.py @@ -13,6 +13,7 @@ import core.postgres as postgres import core.notifications as notifications import core.adaptive_meds as adaptive_meds import core.snitch as snitch +import core.tz as tz logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -21,14 +22,8 @@ POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", 60)) def _user_now_for(user_uuid): - """Get current datetime in a user's timezone using their stored offset.""" - prefs = postgres.select_one("user_preferences", {"user_uuid": user_uuid}) - offset_minutes = 0 - if prefs and prefs.get("timezone_offset") is not None: - offset_minutes = prefs["timezone_offset"] - # JS getTimezoneOffset: positive = behind UTC, so negate - tz_obj = timezone(timedelta(minutes=-offset_minutes)) - return datetime.now(tz_obj) + """Get current datetime in a user's timezone using their stored preferences.""" + return tz.user_now_for(user_uuid) def check_medication_reminders(): @@ -158,7 +153,8 @@ def check_refills(): def create_daily_adaptive_schedules(): - """Create today's medication schedules with adaptive timing.""" + """Create today's medication schedules with adaptive timing. + Called per-user when it's midnight in their timezone.""" try: from datetime import date as date_type @@ -312,7 +308,7 @@ def check_nagging(): logger.debug(f"Nagging disabled for user {user_uuid}") continue - now = datetime.utcnow() + now = _user_now_for(user_uuid) today = now.date() # Get today's schedules @@ -428,18 +424,55 @@ def check_nagging(): logger.error(f"Error checking nags: {e}") -def poll_callback(): - """Called every POLL_INTERVAL seconds.""" - # Create daily schedules at midnight - now = datetime.utcnow() - if now.hour == 0 and now.minute < POLL_INTERVAL / 60: +def _get_distinct_user_uuids(): + """Return a set of user UUIDs that have active medications or routines.""" + uuids = set() + try: + meds = postgres.select("medications", where={"active": True}) + for m in meds: + uid = m.get("user_uuid") + if uid: + uuids.add(uid) + except Exception: + pass + try: + routines = postgres.select("routines") + for r in routines: + uid = r.get("user_uuid") + if uid: + uuids.add(uid) + except Exception: + pass + return uuids + + +def _check_per_user_midnight_schedules(): + """Create daily adaptive schedules for each user when it's midnight in + their timezone (within the poll window).""" + for user_uuid in _get_distinct_user_uuids(): try: - create_daily_adaptive_schedules() + now = _user_now_for(user_uuid) + if now.hour == 0 and now.minute < POLL_INTERVAL / 60: + user_meds = postgres.select( + "medications", where={"user_uuid": user_uuid, "active": True} + ) + for med in user_meds: + times = med.get("times", []) + if times: + adaptive_meds.create_daily_schedule( + user_uuid, med["id"], times + ) except Exception as e: logger.warning( - f"Could not create adaptive schedules (tables may not exist): {e}" + f"Could not create adaptive schedules for user {user_uuid}: {e}" ) + +def poll_callback(): + """Called every POLL_INTERVAL seconds.""" + # Create daily schedules per-user at their local midnight + _check_per_user_midnight_schedules() + # Check reminders - use both original and adaptive checks logger.info("Checking medication reminders") check_medication_reminders() diff --git a/synculous-client/src/app/dashboard/layout.tsx b/synculous-client/src/app/dashboard/layout.tsx index 130435e..2705a12 100644 --- a/synculous-client/src/app/dashboard/layout.tsx +++ b/synculous-client/src/app/dashboard/layout.tsx @@ -49,12 +49,13 @@ export default function DashboardLayout({ } }, [isAuthenticated, isLoading, router]); - // Sync timezone offset to backend once per session + // Sync timezone to backend once per session useEffect(() => { if (isAuthenticated && !tzSynced.current) { tzSynced.current = true; const offset = new Date().getTimezoneOffset(); - api.preferences.update({ timezone_offset: offset }).catch(() => {}); + const tzName = Intl.DateTimeFormat().resolvedOptions().timeZone; + api.preferences.update({ timezone_offset: offset, timezone_name: tzName }).catch(() => {}); } }, [isAuthenticated]); diff --git a/synculous-client/src/lib/api.ts b/synculous-client/src/lib/api.ts index 2093b7e..fe9fe3d 100644 --- a/synculous-client/src/lib/api.ts +++ b/synculous-client/src/lib/api.ts @@ -21,6 +21,7 @@ async function request( const headers: HeadersInit = { 'Content-Type': 'application/json', 'X-Timezone-Offset': String(new Date().getTimezoneOffset()), + 'X-Timezone-Name': Intl.DateTimeFormat().resolvedOptions().timeZone, ...(token ? { Authorization: `Bearer ${token}` } : {}), ...options.headers, }; @@ -636,6 +637,7 @@ export const api = { show_launch_screen?: boolean; celebration_style?: string; timezone_offset?: number; + timezone_name?: string; }) => { return request>('/api/preferences', { method: 'PUT',