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:
80
frontend/src/utils/datetime.js
Normal file
80
frontend/src/utils/datetime.js
Normal 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 }
|
||||
@@ -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] || ''
|
||||
|
||||
Reference in New Issue
Block a user