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

@@ -1,9 +1,48 @@
"""Notifications plugin models - adapted to existing database schema."""
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from shopdb.api import db
_DEFAULT_TZ = 'America/New_York'
def _site_zone():
"""Site-configured IANA zone (settings key site_timezone) for calendar day
placement. Imported lazily to avoid a circular import at model load."""
from shopdb.api import Setting
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 _site_date(dt):
"""The calendar day (YYYY-MM-DD) a stored UTC datetime falls on in the site
zone. allDay events must key off the site-local day, not the UTC day, or a
late-evening notification lands on the wrong date for western sites."""
if not dt:
return None
return dt.replace(tzinfo=timezone.utc).astimezone(_site_zone()).date().isoformat()
def _utc_iso(dt):
"""Serialize a stored datetime as an explicit-UTC ISO string.
starttime/endtime are stored NAIVE but always hold UTC wall-clock (the
create/update parse normalizes to UTC). Emitting a bare naive isoformat let
the browser read it as LOCAL time, shifting displays by the tz offset (a
14:34 EDT notification showed 18:34). Tag it UTC so new Date() parses the
real instant and renders in the viewer's zone.
"""
if not dt:
return None
return dt.replace(tzinfo=timezone.utc).isoformat()
class NotificationType(db.Model):
"""
Notification type classification.
@@ -120,10 +159,10 @@ class Notification(db.Model):
'notification': self.notification,
'title': self.title,
'message': self.notification,
'starttime': self.starttime.isoformat() if self.starttime else None,
'endtime': self.endtime.isoformat() if self.endtime else None,
'startdate': self.starttime.isoformat() if self.starttime else None,
'enddate': self.endtime.isoformat() if self.endtime else None,
'starttime': _utc_iso(self.starttime),
'endtime': _utc_iso(self.endtime),
'startdate': _utc_iso(self.starttime),
'enddate': _utc_iso(self.endtime),
'ticketnumber': self.ticketnumber,
'link': self.link,
'linkurl': self.link,
@@ -175,8 +214,8 @@ class Notification(db.Model):
return {
'id': self.notificationid,
'title': title,
'start': self.starttime.isoformat() if self.starttime else None,
'end': self.endtime.isoformat() if self.endtime else None,
'start': _site_date(self.starttime),
'end': _site_date(self.endtime),
'allDay': True,
'backgroundColor': color,
'borderColor': color,