Site comments (8/3) - BIM card: LOD removed, IFF # added next to the coordination status, and required once that status is "Signed off (IFF)" — an unnumbered sign-off isn't traceable. A LOD already stored on a package is preserved and shown as legacy, not blanked. - The blue "from SOP types" subtext under a field is now a SOP chip on the label with the detail in a tooltip. The chip stays visible rather than hover-only: field tablets have no hover, and "this came from the SOP" is the part that matters. The hint elements stay in the DOM (hidden) so the code writing to them keeps working; an observer mirrors their text into the tooltip. - Specification Section is no longer typed per package. Each WP type carries a spec section on the SOP; the field is read-only in the Creator and follows the type, with the SOP's spec folder linked underneath. This reads both spec comments as one intent — stop typing it, derive it. - Assignees and Distribution are multi-selects over the SOP project team, showing each person's job function, with the CM pre-added to Distribution (removable per package) and a free-text option for people with no account. The stored display strings are unchanged so print/export/dashboard keep working; account ids ride alongside for the notification work in wave 3. Localization + time - Per-user locale/timezone (Language & time in the user menu), an app-wide default in the admin console, then the browser. Timezones are validated against the server's zoneinfo and the picker is fed from it. Calendar dates are formatted from their parts so a due date never reads a day early in another zone. - Every displayed timestamp now goes through the shared helpers. Top-bar chrome - Project switcher beside the logo and a centered global search, injected into either generation of top bar; skipped in an iframe so the embedded Creator doesn't get a second one. Ctrl/Cmd-K focuses search. - GET /api/search covers work packages, projects and SOPs, scoped to the caller's projects, hiding archived packages, with LIKE wildcards escaped. Fixed along the way: showForm() cleared every card's inline display, which undid applyKind() — so the Package Type and BIM cards reappeared on an install-only project. Split out applyKindVisibility() and re-apply it there. Verified: 100 API checks on a fresh database (44 permissions + 22 password reset + 34 search/localization), 24 driven UI checks against the real Creator page in headless Chrome (SOP chips, both people pickers, spec auto-fill, critical tags, BIM suppression), and the chrome harness on both bar styles. Screenshots reviewed. Co-Authored-By: Claude Opus 5 (1M context) <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 #8d8d8d;border-radius:4px;font-size:14px;background:#fff;';
|
|
var lbl = 'display:block;font-size:12px;color:#525252;margin:14px 0 4px;font-weight:600;';
|
|
var hint = 'font-size:11.5px;color:#6f6f6f;margin-bottom:6px;';
|
|
ov.innerHTML =
|
|
'<div style="background:#fff;color:#161616;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 #e0e0e0;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:#f4f4f4;border-radius:6px;font-size:12.5px;"></div>' +
|
|
'</div>' +
|
|
'<div style="padding:12px 18px;border-top:1px solid #e0e0e0;display:flex;gap:8px;justify-content:flex-end;">' +
|
|
'<button type="button" id="wp-prefs-cancel" style="padding:8px 14px;border:1px solid #8d8d8d;background:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Cancel</button>' +
|
|
'<button type="button" id="wp-prefs-save" style="padding:8px 14px;border:none;background:#0f62fe;color:#fff;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 ? '#defbe6' : '#fff1f1';
|
|
e.style.color = ok ? '#0e6027' : '#da1e28';
|
|
}
|
|
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); });
|
|
};
|
|
};
|
|
})();
|