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.
This commit is contained in:
@@ -58,7 +58,7 @@ def register(app):
|
|||||||
if not data:
|
if not data:
|
||||||
return flask.jsonify({"error": "missing body"}), 400
|
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}
|
updates = {k: v for k, v in data.items() if k in allowed}
|
||||||
if not updates:
|
if not updates:
|
||||||
return flask.jsonify({"error": "no valid fields"}), 400
|
return flask.jsonify({"error": "no valid fields"}), 400
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ CREATE TABLE IF NOT EXISTS user_preferences (
|
|||||||
show_launch_screen BOOLEAN DEFAULT TRUE,
|
show_launch_screen BOOLEAN DEFAULT TRUE,
|
||||||
celebration_style VARCHAR(50) DEFAULT 'standard',
|
celebration_style VARCHAR(50) DEFAULT 'standard',
|
||||||
timezone_offset INTEGER DEFAULT 0,
|
timezone_offset INTEGER DEFAULT 0,
|
||||||
|
timezone_name VARCHAR(100),
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
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_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);
|
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);
|
||||||
|
|||||||
70
core/tz.py
70
core/tz.py
@@ -1,12 +1,36 @@
|
|||||||
"""
|
"""
|
||||||
core/tz.py - Timezone-aware date/time helpers
|
core/tz.py - Timezone-aware date/time helpers
|
||||||
|
|
||||||
The frontend sends X-Timezone-Offset (minutes from UTC, same sign as
|
The frontend sends:
|
||||||
JavaScript's getTimezoneOffset — positive means behind UTC).
|
X-Timezone-Name – IANA timezone (e.g. "America/Chicago"), preferred
|
||||||
These helpers convert server UTC to the user's local date/time.
|
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 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():
|
def _get_offset_minutes():
|
||||||
@@ -16,7 +40,6 @@ def _get_offset_minutes():
|
|||||||
import flask
|
import flask
|
||||||
return int(flask.request.headers.get("X-Timezone-Offset", 0))
|
return int(flask.request.headers.get("X-Timezone-Offset", 0))
|
||||||
except (ValueError, TypeError, RuntimeError):
|
except (ValueError, TypeError, RuntimeError):
|
||||||
# RuntimeError: outside of request context
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -26,14 +49,45 @@ def _offset_to_tz(offset_minutes):
|
|||||||
|
|
||||||
|
|
||||||
def user_now(offset_minutes=None):
|
def user_now(offset_minutes=None):
|
||||||
"""Current datetime in the user's timezone.
|
"""Current datetime in the user's timezone (request-context).
|
||||||
If offset_minutes is provided, uses that instead of the request header."""
|
If offset_minutes is provided, uses that directly.
|
||||||
if offset_minutes is None:
|
Otherwise reads request headers (prefers IANA name over offset)."""
|
||||||
offset_minutes = _get_offset_minutes()
|
if offset_minutes is not None:
|
||||||
tz = _offset_to_tz(offset_minutes)
|
tz = _offset_to_tz(offset_minutes)
|
||||||
|
else:
|
||||||
|
tz = _get_request_tz()
|
||||||
return datetime.now(tz)
|
return datetime.now(tz)
|
||||||
|
|
||||||
|
|
||||||
def user_today(offset_minutes=None):
|
def user_today(offset_minutes=None):
|
||||||
"""Current date in the user's timezone."""
|
"""Current date in the user's timezone."""
|
||||||
return user_now(offset_minutes).date()
|
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()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import core.postgres as postgres
|
|||||||
import core.notifications as notifications
|
import core.notifications as notifications
|
||||||
import core.adaptive_meds as adaptive_meds
|
import core.adaptive_meds as adaptive_meds
|
||||||
import core.snitch as snitch
|
import core.snitch as snitch
|
||||||
|
import core.tz as tz
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -21,14 +22,8 @@ POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", 60))
|
|||||||
|
|
||||||
|
|
||||||
def _user_now_for(user_uuid):
|
def _user_now_for(user_uuid):
|
||||||
"""Get current datetime in a user's timezone using their stored offset."""
|
"""Get current datetime in a user's timezone using their stored preferences."""
|
||||||
prefs = postgres.select_one("user_preferences", {"user_uuid": user_uuid})
|
return tz.user_now_for(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)
|
|
||||||
|
|
||||||
|
|
||||||
def check_medication_reminders():
|
def check_medication_reminders():
|
||||||
@@ -158,7 +153,8 @@ def check_refills():
|
|||||||
|
|
||||||
|
|
||||||
def create_daily_adaptive_schedules():
|
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:
|
try:
|
||||||
from datetime import date as date_type
|
from datetime import date as date_type
|
||||||
|
|
||||||
@@ -312,7 +308,7 @@ def check_nagging():
|
|||||||
logger.debug(f"Nagging disabled for user {user_uuid}")
|
logger.debug(f"Nagging disabled for user {user_uuid}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
now = datetime.utcnow()
|
now = _user_now_for(user_uuid)
|
||||||
today = now.date()
|
today = now.date()
|
||||||
|
|
||||||
# Get today's schedules
|
# Get today's schedules
|
||||||
@@ -428,18 +424,55 @@ def check_nagging():
|
|||||||
logger.error(f"Error checking nags: {e}")
|
logger.error(f"Error checking nags: {e}")
|
||||||
|
|
||||||
|
|
||||||
def poll_callback():
|
def _get_distinct_user_uuids():
|
||||||
"""Called every POLL_INTERVAL seconds."""
|
"""Return a set of user UUIDs that have active medications or routines."""
|
||||||
# Create daily schedules at midnight
|
uuids = set()
|
||||||
now = datetime.utcnow()
|
|
||||||
if now.hour == 0 and now.minute < POLL_INTERVAL / 60:
|
|
||||||
try:
|
try:
|
||||||
create_daily_adaptive_schedules()
|
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:
|
||||||
|
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:
|
except Exception as e:
|
||||||
logger.warning(
|
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
|
# Check reminders - use both original and adaptive checks
|
||||||
logger.info("Checking medication reminders")
|
logger.info("Checking medication reminders")
|
||||||
check_medication_reminders()
|
check_medication_reminders()
|
||||||
|
|||||||
@@ -49,12 +49,13 @@ export default function DashboardLayout({
|
|||||||
}
|
}
|
||||||
}, [isAuthenticated, isLoading, router]);
|
}, [isAuthenticated, isLoading, router]);
|
||||||
|
|
||||||
// Sync timezone offset to backend once per session
|
// Sync timezone to backend once per session
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated && !tzSynced.current) {
|
if (isAuthenticated && !tzSynced.current) {
|
||||||
tzSynced.current = true;
|
tzSynced.current = true;
|
||||||
const offset = new Date().getTimezoneOffset();
|
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]);
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ async function request<T>(
|
|||||||
const headers: HeadersInit = {
|
const headers: HeadersInit = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-Timezone-Offset': String(new Date().getTimezoneOffset()),
|
'X-Timezone-Offset': String(new Date().getTimezoneOffset()),
|
||||||
|
'X-Timezone-Name': Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
...options.headers,
|
...options.headers,
|
||||||
};
|
};
|
||||||
@@ -636,6 +637,7 @@ export const api = {
|
|||||||
show_launch_screen?: boolean;
|
show_launch_screen?: boolean;
|
||||||
celebration_style?: string;
|
celebration_style?: string;
|
||||||
timezone_offset?: number;
|
timezone_offset?: number;
|
||||||
|
timezone_name?: string;
|
||||||
}) => {
|
}) => {
|
||||||
return request<Record<string, unknown>>('/api/preferences', {
|
return request<Record<string, unknown>>('/api/preferences', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
Reference in New Issue
Block a user