/* Localization + time formatting for the Work Package Suite. Every date the app shows should agree, wherever it's rendered. Three sources, most specific first: 1. the signed-in user's own preference (users.locale / users.timezone) 2. the app default set by an admin (Admin console → Localization) 3. the browser's own locale / timezone (the previous behaviour) Why store it server-side: on a shared field tablet the browser's locale isn't the person's, and a package due date that reads a day early because the device sits in another zone is a real scheduling problem — not a cosmetic one. Exposes: wpFormatDate(v) → 3 Aug 2026 (date only) wpFormatDateTime(v) → 3 Aug 2026, 14:07 (date + time) wpFormatTime(v) → 14:07 wpFormatNumber(v) → locale-grouped number wpTimeZoneLabel() → the zone in effect, for a UI hint wpPreferences() → opens the preferences dialog All formatters take an ISO string, Date, or epoch ms, and return '' for empty input (never 'Invalid Date'), so they're safe to drop into a template. */ (function () { 'use strict'; function prefs() { var u = window.WP_USER || {}; var f = window.WP_FLAGS || {}; return { locale: (u.locale || f.default_locale || '') || undefined, timezone: (u.timezone || f.default_timezone || '') || undefined }; } // A date-only value ('2026-08-03') is a calendar date, not an instant. Parsed as // UTC midnight by the platform, it can render as the previous day in a western // zone — so format these from their parts and never apply a timezone. var DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/; function toDate(v) { if (v == null || v === '') return null; if (v instanceof Date) return isNaN(v.getTime()) ? null : v; if (typeof v === 'number') { var n = new Date(v); return isNaN(n.getTime()) ? null : n; } var s = String(v).trim(); if (!s) return null; var d = new Date(s); return isNaN(d.getTime()) ? null : d; } function fmt(v, opts, forceNoTz) { var s = (typeof v === 'string') ? v.trim() : v; var dateOnly = (typeof s === 'string') && DATE_ONLY.test(s); var d = dateOnly ? new Date(s + 'T12:00:00') : toDate(s); // noon: immune to ±12h shifts if (!d) return ''; var p = prefs(); var o = {}; for (var k in opts) if (Object.prototype.hasOwnProperty.call(opts, k)) o[k] = opts[k]; if (p.timezone && !dateOnly && !forceNoTz) o.timeZone = p.timezone; try { return new Intl.DateTimeFormat(p.locale, o).format(d); } catch (e) { // Bad locale/zone (e.g. a preference set before tzdata was available): // fall back to the platform default rather than showing nothing. try { return new Intl.DateTimeFormat(undefined, opts).format(d); } catch (e2) { return String(v); } } } window.wpFormatDate = function (v) { return fmt(v, { year: 'numeric', month: 'short', day: 'numeric' }); }; window.wpFormatDateTime = function (v) { return fmt(v, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); }; window.wpFormatTime = function (v) { return fmt(v, { hour: '2-digit', minute: '2-digit' }); }; window.wpFormatNumber = function (v, opts) { if (v == null || v === '' || isNaN(+v)) return ''; try { return new Intl.NumberFormat(prefs().locale, opts || {}).format(+v); } catch (e) { return String(v); } }; window.wpTimeZoneLabel = function () { var p = prefs(); if (p.timezone) return p.timezone; try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'browser default'; } catch (e) { return 'browser default'; } }; window.wpLocaleLabel = function () { var p = prefs(); if (p.locale) return p.locale; try { return Intl.DateTimeFormat().resolvedOptions().locale || 'browser default'; } catch (e) { return 'browser default'; } }; // ── preferences dialog ───────────────────────────────────────────────────── var COMMON_LOCALES = [ ['', 'Browser default'], ['en-US', 'English (United States) — 8/3/2026, 2:07 PM'], ['en-GB', 'English (United Kingdom) — 03/08/2026, 14:07'], ['en-CA', 'English (Canada)'], ['es-MX', 'Español (México)'], ['es-US', 'Español (Estados Unidos)'], ['fr-CA', 'Français (Canada)'], ['de-DE', 'Deutsch (Deutschland)'], ['ja-JP', '日本語 (日本)'], ['ko-KR', '한국어 (대한민국)'], ['zh-TW', '中文 (台灣)'] ]; // Zones the fabs and offices actually sit in, offered before the full list. var COMMON_ZONES = [ 'America/Chicago', 'America/New_York', 'America/Denver', 'America/Phoenix', 'America/Los_Angeles', 'America/Boise', 'Asia/Tokyo', 'Asia/Taipei', 'Asia/Seoul', 'Asia/Singapore', 'Europe/Dublin', 'Europe/London', 'UTC' ]; window.wpPreferences = function () { if (document.getElementById('wp-prefs-modal')) return; var u = window.WP_USER || {}; var ov = document.createElement('div'); ov.id = 'wp-prefs-modal'; ov.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;' + 'justify-content:center;z-index:10002;padding:20px;font:14px/1.45 "IBM Plex Sans",-apple-system,' + 'BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;'; var fld = 'width:100%;padding:9px 10px;margin-bottom:4px;border:1px solid var(--cds-border-strong);border-radius:4px;font-size:14px;background:var(--cds-layer);'; var lbl = 'display:block;font-size:12px;color:var(--cds-text-secondary);margin:14px 0 4px;font-weight:600;'; var hint = 'font-size:11.5px;color:var(--cds-text-helper);margin-bottom:6px;'; ov.innerHTML = '
'; function close() { var m = document.getElementById('wp-prefs-modal'); if (m) m.remove(); } function msg(text, ok) { var e = document.getElementById('wp-prefs-msg'); e.style.display = 'block'; e.textContent = text; e.style.background = ok ? 'var(--wp-status-success-bg)' : 'var(--wp-status-error-bg)'; e.style.color = ok ? 'var(--wp-status-success-text)' : 'var(--cds-support-error)'; } ov.addEventListener('click', function (e) { if (e.target === ov) close(); }); document.body.appendChild(ov); var locSel = document.getElementById('wp-prefs-locale'); var tzSel = document.getElementById('wp-prefs-tz'); var preview = document.getElementById('wp-prefs-preview'); locSel.innerHTML = COMMON_LOCALES.map(function (p) { return ''; }).join(''); // A stored locale that isn't in the shortlist stays selectable. if (u.locale && !COMMON_LOCALES.some(function (p) { return p[0] === u.locale; })) { locSel.add(new Option(u.locale, u.locale, true, true)); } function fillZones(all) { var cur = u.timezone || ''; var browser = ''; try { browser = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (e) {} var html = ''; html += ''; var rest = (all || []).filter(function (z) { return COMMON_ZONES.indexOf(z) < 0; }); if (rest.length) { html += ''; } else if (cur && COMMON_ZONES.indexOf(cur) < 0) { html += ''; } tzSel.innerHTML = html; updatePreview(); } // Preview uses the picked values, not the saved ones, so the effect is visible // before committing. function updatePreview() { var l = locSel.value || undefined, z = tzSel.value || undefined; var now = new Date(); var out; try { out = new Intl.DateTimeFormat(l, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: z }).format(now); } catch (e) { out = 'Not supported by this browser'; } preview.innerHTML = 'Preview