notifications: correct timezone handling + configurable site timezone

Notification start/end times displayed and stored wrong by the tz offset
(a 2:34 PM entry showed 6:34 PM). Two stacked bugs: to_dict emitted stored
UTC as naive ISO (no offset) so the browser read it as local, and the form
filled the datetime-local input from toISOString() (UTC).

Fix and generalize to a configurable site timezone (multi-site):
- New setting site_timezone (default America/New_York), public, editable in
  Settings > Site > Localization (common-zone dropdown).
- Backend tags datetimes UTC (_utc_iso); parse normalizes to naive UTC
  (_parse_utc); daily-reset expiry uses the site zone (_next_site_time);
  calendar allDay events key off the site-local day (_site_date).
- Shared frontend util datetime.js (Intl-based, DST-safe) converts between a
  UTC instant and a site-zone wall clock. Notification form, list, and
  calendar all render/enter in the site zone.
This commit is contained in:
cproudlock
2026-07-30 15:08:59 -04:00
parent 3ad26ba010
commit ea6fae91c3
9 changed files with 920 additions and 40 deletions

View File

@@ -12,7 +12,7 @@ from shopdb.api import db, success_response, error_response, paginated_response,
from ..models import Notification, NotificationType
from shopdb.api import require_permission
from shopdb.api import require_permission, Setting
notifications_bp = Blueprint('notifications', __name__)
@@ -26,16 +26,41 @@ SPLIT_TYPECOLORS = frozenset({'recognition', 'training', 'recertification'})
# an explicit end time, by notification typecolor.
# recognition - clears at the next 8:00 AM Eastern (daily reset)
# recertification - stays up two weeks (employees have time to book the course)
EASTERN = ZoneInfo('America/New_York')
_DEFAULT_TZ = 'America/New_York'
RECERTIFICATION_DAYS = 14
def _next_eastern_time(after, hour, minute=0):
"""Next hour:minute America/New_York strictly after `after` (naive UTC),
returned as naive UTC. Uses the tz database so it is correct across EST/EDT."""
after_east = after.replace(tzinfo=timezone.utc).astimezone(EASTERN)
target = after_east.replace(hour=int(hour), minute=int(minute), second=0, microsecond=0)
if target <= after_east:
def _site_tz():
"""Site-configured IANA timezone (settings key site_timezone), used for the
daily-reset expiry math and any wall-clock computation. Falls back to
America/New_York if unset or invalid."""
row = Setting.query.filter_by(key='site_timezone').first()
name = (row.value if row and row.value else _DEFAULT_TZ)
try:
return ZoneInfo(name)
except Exception:
return ZoneInfo(_DEFAULT_TZ)
def _parse_utc(date_str):
"""Parse an incoming ISO datetime to NAIVE UTC (how starttime/endtime store).
The client submits new Date(local).toISOString(), i.e. UTC with a 'Z'. A
value without an offset is assumed already-UTC (not server-local) so the
stored instant is unambiguous."""
dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc).replace(tzinfo=None)
def _next_site_time(after, hour, minute=0):
"""Next hour:minute in the site timezone strictly after `after` (naive UTC),
returned as naive UTC. Uses the tz database so it is correct across DST."""
tz = _site_tz()
after_local = after.replace(tzinfo=timezone.utc).astimezone(tz)
target = after_local.replace(hour=int(hour), minute=int(minute), second=0, microsecond=0)
if target <= after_local:
target += timedelta(days=1)
return target.astimezone(timezone.utc).replace(tzinfo=None)
@@ -51,13 +76,13 @@ def _auto_endtime(ntype, starttime):
mode = getattr(ntype, 'expirymode', None) or 'none'
if mode == 'dailytime':
hour = ntype.expiryhour if ntype.expiryhour is not None else 8
return _next_eastern_time(starttime, hour, ntype.expiryminute or 0)
return _next_site_time(starttime, hour, ntype.expiryminute or 0)
if mode == 'duration' and ntype.expirydays:
return starttime + timedelta(days=int(ntype.expirydays))
if mode == 'none':
# legacy fallback for rule-bearing types created before the expiry columns
if getattr(ntype, 'typecolor', None) == 'recognition':
return _next_eastern_time(starttime, 8, 0)
return _next_site_time(starttime, 8, 0)
if getattr(ntype, 'typecolor', None) == 'recertification':
return starttime + timedelta(days=RECERTIFICATION_DAYS)
return None
@@ -360,7 +385,7 @@ def create_notification():
if data.get('starttime') or data.get('startdate'):
try:
date_str = data.get('starttime') or data.get('startdate')
starttime = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
starttime = _parse_utc(date_str)
except ValueError:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid starttime format')
@@ -368,7 +393,7 @@ def create_notification():
if data.get('endtime') or data.get('enddate'):
try:
date_str = data.get('endtime') or data.get('enddate')
endtime = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
endtime = _parse_utc(date_str)
except ValueError:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid endtime format')
@@ -447,7 +472,7 @@ def update_notification(notification_id: int):
date_str = data.get('starttime') or data.get('startdate')
if date_str:
try:
n.starttime = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
n.starttime = _parse_utc(date_str)
except ValueError:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid starttime format')
else:
@@ -457,7 +482,7 @@ def update_notification(notification_id: int):
date_str = data.get('endtime') or data.get('enddate')
if date_str:
try:
n.endtime = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
n.endtime = _parse_utc(date_str)
except ValueError:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid endtime format')
else: