notifications: timezone fix + configurable site timezone

This commit is contained in:
2026-07-30 15:09:06 -04:00
parent 4610108a0c
commit ce550aefc6
8 changed files with 223 additions and 40 deletions

View File

@@ -0,0 +1,80 @@
// Site-timezone datetime helpers.
//
// Notification start/end times are stored as UTC and must be shown + entered in
// the site's configured IANA timezone (settings key site_timezone), not the
// viewer's browser zone, so every site and remote admin agrees on the wall
// clock. These convert between a UTC instant and a 'YYYY-MM-DDTHH:mm' string in
// a given zone (the value a datetime-local input holds).
const DEFAULT_TZ = 'America/New_York'
function pad(n) {
return String(n).padStart(2, '0')
}
// Wall-clock parts of a UTC instant AS SEEN in tz. Uses Intl so it is correct
// across DST without shipping a tz table.
function zonedParts(date, tz) {
const dtf = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hourCycle: 'h23',
})
const map = {}
for (const p of dtf.formatToParts(date)) {
if (p.type !== 'literal') map[p.type] = p.value
}
return {
year: Number(map.year), month: Number(map.month), day: Number(map.day),
hour: Number(map.hour), minute: Number(map.minute), second: Number(map.second),
}
}
// tz offset (ms) at the given instant: (wall-clock-as-UTC) - (real UTC).
function tzOffsetMs(date, tz) {
const p = zonedParts(date, tz)
const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second)
return asUtc - date.getTime()
}
// UTC instant (or ISO string) -> 'YYYY-MM-DDTHH:mm' wall clock in tz, for a
// datetime-local input. Empty string on no/invalid input.
export function zonedInputFromUtc(value, tz = DEFAULT_TZ) {
if (!value) return ''
const date = value instanceof Date ? value : new Date(value)
if (isNaN(date.getTime())) return ''
const p = zonedParts(date, tz)
return `${p.year}-${pad(p.month)}-${pad(p.day)}T${pad(p.hour)}:${pad(p.minute)}`
}
// 'YYYY-MM-DDTHH:mm' wall clock in tz -> UTC ISO string. Interprets the entered
// time as tz-local (DST-aware) and returns the real instant. Empty -> null.
export function utcFromZonedInput(localStr, tz = DEFAULT_TZ) {
if (!localStr) return null
const [datePart, timePart = '00:00'] = localStr.split('T')
const [y, mo, d] = datePart.split('-').map(Number)
const [h, mi] = timePart.split(':').map(Number)
if ([y, mo, d, h, mi].some(Number.isNaN)) return null
// Guess the instant as if the wall clock were UTC, then subtract the zone
// offset AT that instant. One correction is exact except in the ~1h/yr DST
// fold, where either side is a fair reading.
const guess = Date.UTC(y, mo - 1, d, h, mi)
const offset = tzOffsetMs(new Date(guess), tz)
return new Date(guess - offset).toISOString()
}
// UTC instant (or ISO) -> human string in tz, e.g. "Jul 30, 2026, 2:34 PM".
export function formatInZone(value, tz = DEFAULT_TZ, opts = {}) {
if (!value) return ''
const date = value instanceof Date ? value : new Date(value)
if (isNaN(date.getTime())) return ''
return new Intl.DateTimeFormat('en-US', {
timeZone: tz,
year: 'numeric', month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit',
...opts,
}).format(date)
}
export { DEFAULT_TZ }

View File

@@ -74,6 +74,7 @@ const LABELS = {
dualpath_single_machine: 'Dualpath as Single Machine',
employee_directory_mode: 'Employee Directory Mode',
usb_directory_mode: 'USB Directory Mode',
site_timezone: 'Site Timezone',
setup_complete: 'Setup Complete'
}
function prettyLabel(key) {
@@ -83,7 +84,12 @@ function prettyLabel(key) {
// Enumerated settings render as dropdowns to prevent typos.
const OPTIONS = {
employee_directory_mode: ['selfhosted', 'external'],
usb_directory_mode: ['selfhosted', 'external']
usb_directory_mode: ['selfhosted', 'external'],
site_timezone: [
'America/New_York', 'America/Chicago', 'America/Denver', 'America/Phoenix',
'America/Los_Angeles', 'America/Anchorage', 'Pacific/Honolulu',
'America/Indiana/Indianapolis'
]
}
function isTrue(setting) {
@@ -97,6 +103,7 @@ const GROUPS = [
{ title: 'Behavior', keys: ['dualpath_single_machine'] },
{ title: 'Naming & Patterns', keys: ['pc_access_domain', 'printer_hostname_template', 'contact_email_domain', 'employeeid_pattern'] },
{ title: 'Data Sources', keys: ['employee_directory_mode', 'usb_directory_mode'] },
{ title: 'Localization', keys: ['site_timezone'] },
{ title: 'System', keys: ['setup_complete'] }
]
@@ -108,7 +115,8 @@ const HELP = {
employeeid_pattern: 'Regular expression that a scanned/typed employee ID must match to be recognized. Default: ^\\d{9}$ (9 digits). An invalid regex is ignored and the default is used.',
printer_hostname_template: 'Template for generating printer hostnames from an IP. Use {ip} where the dash-separated IP goes. Example: Printer-{ip}.printer.geaerospace.net',
contact_email_domain: 'Email domain appended to a support contact SSO to build email (sso@domain) and Microsoft Teams chat links. Example: geaerospace.com. Leave blank to hide the contact action buttons.',
dualpath_single_machine: 'Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in lists, counts, and the floor map. Both bay records are always kept; detail pages stay per-bay with a sibling banner. Enter true or false. Default: true.'
dualpath_single_machine: 'Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in lists, counts, and the floor map. Both bay records are always kept; detail pages stay per-bay with a sibling banner. Enter true or false. Default: true.',
site_timezone: 'IANA timezone for this site. Notification start/end times are shown and entered in this zone, and daily-reset notification expiry is computed here. Default: America/New_York.'
}
function fieldHelp(key) {
return HELP[key] || ''

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,

View File

@@ -56,6 +56,7 @@ PUBLIC_SETTING_CATEGORIES = {'branding', 'map'}
PUBLIC_SETTING_KEYS = {
'site_base_url', 'facility_name', 'printer_hostname_template',
'contact_email_domain', 'servicenow_enabled', 'setup_complete',
'site_timezone',
}
@@ -443,6 +444,13 @@ def build_default_settings():
'category': 'site',
'description': 'Set true once the first-run setup wizard has been finished'
},
{
'key': 'site_timezone',
'value': 'America/New_York',
'valuetype': 'string',
'category': 'site',
'description': "IANA timezone for this site (e.g. America/New_York, America/Chicago). Notification start/end times and daily-reset expiry are computed in this zone."
},
{
'key': 'employee_directory_mode',
'value': 'selfhosted',