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:
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user