Files
Project-SDE-WP-Suite/html/wp-format.js
n.siegfried 61d1cf4bff Wave 2: form cleanups from the site comments, plus localization, project switcher and global search
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>
2026-08-03 15:17:51 -07:00

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 &amp; 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 &amp; 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); });
};
};
})();