diff --git a/frontend/src/utils/datetime.js b/frontend/src/utils/datetime.js new file mode 100644 index 00000000..4ad8cce9 --- /dev/null +++ b/frontend/src/utils/datetime.js @@ -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 } diff --git a/frontend/src/views/settings/SiteSettings.vue b/frontend/src/views/settings/SiteSettings.vue index e52c0e0a..d1c399a0 100644 --- a/frontend/src/views/settings/SiteSettings.vue +++ b/frontend/src/views/settings/SiteSettings.vue @@ -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] || '' diff --git a/plugins/notifications/api/routes.py b/plugins/notifications/api/routes.py index a3aa6120..da8d187d 100644 --- a/plugins/notifications/api/routes.py +++ b/plugins/notifications/api/routes.py @@ -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: diff --git a/plugins/notifications/frontend/views/CalendarView.vue b/plugins/notifications/frontend/views/CalendarView.vue index cb2fc628..7bdde034 100644 --- a/plugins/notifications/frontend/views/CalendarView.vue +++ b/plugins/notifications/frontend/views/CalendarView.vue @@ -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' }) } diff --git a/plugins/notifications/frontend/views/NotificationForm.vue b/plugins/notifications/frontend/views/NotificationForm.vue index 956aee78..9087a222 100644 --- a/plugins/notifications/frontend/views/NotificationForm.vue +++ b/plugins/notifications/frontend/views/NotificationForm.vue @@ -169,7 +169,7 @@ Now - When notification becomes visible + When notification becomes visible ({{ siteTimezone }})
@@ -243,8 +243,9 @@ diff --git a/plugins/notifications/models/notification.py b/plugins/notifications/models/notification.py index 42616b7e..18708afb 100644 --- a/plugins/notifications/models/notification.py +++ b/plugins/notifications/models/notification.py @@ -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, diff --git a/shopdb/core/api/settings.py b/shopdb/core/api/settings.py index 4f58da55..a3566865 100644 --- a/shopdb/core/api/settings.py +++ b/shopdb/core/api/settings.py @@ -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',