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:

View File

@@ -231,13 +231,17 @@ function openEventFromTooltip(evt) {
function formatDate(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleDateString('en-US', {
// allDay events carry a site-local date-only value (YYYY-MM-DD). Build the
// Date from local parts so it is not shifted a day by UTC-midnight parsing.
const parts = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr)
const date = parts
? new Date(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3]))
: new Date(dateStr)
return date.toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
day: 'numeric'
})
}
</script>

View File

@@ -169,7 +169,7 @@
Now
</button>
</div>
<small class="form-hint">When notification becomes visible</small>
<small class="form-hint">When notification becomes visible ({{ siteTimezone }})</small>
</div>
<div class="form-group">
@@ -243,8 +243,9 @@
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { notificationsApi, applicationsApi, businessUnitsApi, employeesApi } from '@/api'
import { notificationsApi, applicationsApi, businessUnitsApi, employeesApi, settingsApi } from '@/api'
import { apiError } from '@/utils/apiError'
import { zonedInputFromUtc, utcFromZonedInput, DEFAULT_TZ } from '@/utils/datetime'
const route = useRoute()
const router = useRouter()
@@ -278,6 +279,10 @@ const form = ref({
employeesso: ''
})
// Site timezone (settings key site_timezone) so form times show/enter in the
// site's wall clock, not the viewer's browser zone. Loaded in onMounted.
const siteTimezone = ref(DEFAULT_TZ)
function selectedTypeName() {
const selectedType = types.value.find(t => t.notificationtypeid === parseInt(form.value.notificationtypeid))
return selectedType?.typename?.toLowerCase() || ''
@@ -303,13 +308,18 @@ const messagePlaceholder = computed(() =>
onMounted(async () => {
try {
// Load dropdown data in parallel
const [typesRes, buRes, appsRes] = await Promise.all([
// Load dropdown data + the site timezone in parallel. Resolve the tz FIRST
// (before any formatDateForInput) so times render in the site's zone.
const [typesRes, buRes, appsRes, tzRes] = await Promise.all([
notificationsApi.types.list(),
businessUnitsApi.list().catch(() => ({ data: { data: [] } })),
applicationsApi.list({ perpage: 500 }).catch(() => ({ data: { data: [] } }))
applicationsApi.list({ perpage: 500 }).catch(() => ({ data: { data: [] } })),
settingsApi.get('site_timezone').catch(() => null)
])
const tzValue = tzRes?.data?.data?.value
if (tzValue) siteTimezone.value = tzValue
types.value = typesRes.data.data || []
businessUnits.value = buRes.data?.data || []
applications.value = appsRes.data?.data || []
@@ -364,14 +374,13 @@ onMounted(async () => {
}
})
// UTC value from the API -> a datetime-local string in the SITE timezone.
function formatDateForInput(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toISOString().slice(0, 16)
return zonedInputFromUtc(dateStr, siteTimezone.value)
}
function setNow(field) {
form.value[field] = formatDateForInput(new Date().toISOString())
form.value[field] = zonedInputFromUtc(new Date(), siteTimezone.value)
}
function onTypeChange() {
@@ -379,7 +388,7 @@ function onTypeChange() {
// (recognition clears at 8 AM Eastern; recertification runs two weeks). Set
// start to now and leave end blank so the backend applies the per-type rule.
if (isEmployeeType.value) {
form.value.starttime = formatDateForInput(new Date().toISOString())
form.value.starttime = zonedInputFromUtc(new Date(), siteTimezone.value)
form.value.endtime = ''
}
}
@@ -481,8 +490,8 @@ async function saveNotification() {
appid: parseInt(form.value.appid) || null,
ticketnumber: form.value.ticketnumber || null,
link: form.value.link || null,
starttime: form.value.starttime ? new Date(form.value.starttime).toISOString() : null,
endtime: form.value.endtime ? new Date(form.value.endtime).toISOString() : null,
starttime: utcFromZonedInput(form.value.starttime, siteTimezone.value),
endtime: utcFromZonedInput(form.value.endtime, siteTimezone.value),
isactive: form.value.isactive,
isshopfloor: form.value.isshopfloor,
employeesso: form.value.employeesso || null

View File

@@ -88,9 +88,10 @@
<script setup>
import { ref, onMounted } from 'vue'
import { notificationsApi } from '@/api'
import { notificationsApi, settingsApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
import { formatInZone, DEFAULT_TZ } from '@/utils/datetime'
const notifications = ref([])
const types = ref([])
@@ -101,10 +102,16 @@ const currentFilter = ref('')
const perPage = ref(20)
const total = ref(0)
const totalPages = ref(1)
const siteTimezone = ref(DEFAULT_TZ)
let searchTimeout = null
onMounted(async () => {
try {
const tzRes = await settingsApi.get('site_timezone')
const tzValue = tzRes?.data?.data?.value
if (tzValue) siteTimezone.value = tzValue
} catch (e) { /* keep default tz */ }
await loadTypes()
await loadNotifications()
})
@@ -170,6 +177,9 @@ function changePerPage(newPerPage) {
function formatDate(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleDateString()
// startdate/enddate are UTC; show the site-zone calendar date.
return formatInZone(dateStr, siteTimezone.value, {
year: 'numeric', month: 'numeric', day: 'numeric', hour: undefined, minute: undefined
})
}
</script>

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,