The T9.9 token sweep pointed seven files (help.js, auth-guard.js, wp-format.js, project-data.js, index.html, field.html, wp-creation-app.js) at Carbon names the theme never defined: --cds-layer-01/-02, --cds-border-subtle-01/-strong-01, --cds-layer-hover-01. theme-light.css carries no -01 suffixes. An undefined var() invalidates the whole declaration, so the help-centre modal, the change-password and language dialogs, the print popup's inlined values, the creator nav drawer and the sync badge all rendered TRANSPARENT backgrounds - reported by Nick against the help menu, 2026-08-20. Renamed every consumer to the canonical tokens (--cds-layer, --cds-layer-accent, --cds-layer-hover, --cds-border-subtle, --cds-border-strong), matched to the hex each replacement originally stood in for. color_check gains check 3: every var() consumed anywhere must resolve to a definition somewhere - the class of this bug, pinned. Verified live: the modal computes rgb(255,255,255) over an opaque gray nav, and the language dialog is opaque too. BL-025 logged for the one wrong-base-colour rgba tint noticed in passing. Item: C4 (regression in its own enforcement). Probe: color_check 5/5. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
233 lines
12 KiB
JavaScript
233 lines
12 KiB
JavaScript
/* 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 =
|
|
'<div style="background:var(--cds-layer);color:var(--cds-text-primary);border-radius:10px;max-width:460px;width:100%;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;">' +
|
|
'<div style="padding:14px 18px;border-bottom:1px solid var(--cds-border-subtle);font-weight:700;">Language & time</div>' +
|
|
'<div style="padding:4px 18px 16px;">' +
|
|
'<div id="wp-prefs-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin:12px 0 0;"></div>' +
|
|
'<label style="' + lbl + '">Language & number format</label>' +
|
|
'<select id="wp-prefs-locale" style="' + fld + '"></select>' +
|
|
'<div style="' + hint + '">Sets how dates and numbers are written. It does not translate the app.</div>' +
|
|
'<label style="' + lbl + '">Time zone</label>' +
|
|
'<select id="wp-prefs-tz" style="' + fld + '"></select>' +
|
|
'<div style="' + hint + '">Times (MIMO windows, history, notifications) are shown in this zone. ' +
|
|
'Calendar dates like a due date are never shifted.</div>' +
|
|
'<div id="wp-prefs-preview" style="margin-top:14px;padding:10px 12px;background:var(--cds-layer-accent);border-radius:6px;font-size:12.5px;"></div>' +
|
|
'</div>' +
|
|
'<div style="padding:12px 18px;border-top:1px solid var(--cds-border-subtle);display:flex;gap:8px;justify-content:flex-end;">' +
|
|
'<button type="button" id="wp-prefs-cancel" style="padding:8px 14px;border:1px solid var(--cds-border-strong);background:var(--cds-layer);border-radius:6px;cursor:pointer;font-weight:600;">Cancel</button>' +
|
|
'<button type="button" id="wp-prefs-save" style="padding:8px 14px;border:none;background:var(--cds-interactive-01);color:var(--cds-text-on-color);border-radius:6px;cursor:pointer;font-weight:600;">Save</button>' +
|
|
'</div>' +
|
|
'</div>';
|
|
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 '<option value="' + p[0] + '"' + (p[0] === (u.locale || '') ? ' selected' : '') + '>' + p[1] + '</option>';
|
|
}).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 = '<option value=""' + (cur ? '' : ' selected') + '>Browser default' +
|
|
(browser ? ' (' + browser + ')' : '') + '</option>';
|
|
html += '<optgroup label="Common">' + COMMON_ZONES.map(function (z) {
|
|
return '<option value="' + z + '"' + (z === cur ? ' selected' : '') + '>' + z + '</option>';
|
|
}).join('') + '</optgroup>';
|
|
var rest = (all || []).filter(function (z) { return COMMON_ZONES.indexOf(z) < 0; });
|
|
if (rest.length) {
|
|
html += '<optgroup label="All time zones">' + rest.map(function (z) {
|
|
return '<option value="' + z + '"' + (z === cur ? ' selected' : '') + '>' + z + '</option>';
|
|
}).join('') + '</optgroup>';
|
|
} else if (cur && COMMON_ZONES.indexOf(cur) < 0) {
|
|
html += '<option value="' + cur + '" selected>' + cur + '</option>';
|
|
}
|
|
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 = '<strong>Preview</strong><br>Right now: ' +
|
|
String(out).replace(/[<>]/g, '') +
|
|
'<br>A due date (2026-08-03) always reads: ' + window.wpFormatDate('2026-08-03');
|
|
}
|
|
locSel.addEventListener('change', updatePreview);
|
|
tzSel.addEventListener('change', updatePreview);
|
|
|
|
// The picker offers exactly what the server will accept.
|
|
fetch('/api/timezones', { headers: { Accept: 'application/json' } })
|
|
.then(function (r) { return r.ok ? r.json() : []; })
|
|
.then(fillZones)
|
|
.catch(function () { fillZones([]); });
|
|
|
|
document.getElementById('wp-prefs-cancel').onclick = close;
|
|
document.getElementById('wp-prefs-save').onclick = function () {
|
|
var body = { locale: locSel.value || '', timezone: tzSel.value || '' };
|
|
fetch('/api/auth/preferences', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
|
})
|
|
.then(function (r) { return r.json().catch(function () { return null; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); })
|
|
.then(function (res) {
|
|
if (!res.ok) { msg((res.j && res.j.detail) || ('Could not save (HTTP ' + res.status + ').'), false); return; }
|
|
if (res.j && res.j.user) window.WP_USER = res.j.user;
|
|
try { localStorage.setItem('wp_auth_cache', JSON.stringify({ user: window.WP_USER, at: Date.now() })); } catch (e) {}
|
|
msg('Saved. Reloading so every date on the page agrees…', true);
|
|
// Dates are formatted at render time all over the app; a reload is the
|
|
// honest way to apply the change everywhere at once.
|
|
setTimeout(function () { location.reload(); }, 700);
|
|
})
|
|
.catch(function () { msg('Could not reach the server.', false); });
|
|
};
|
|
};
|
|
})();
|