diff --git a/public/app.js b/public/app.js index 1156dff1..c7515348 100644 --- a/public/app.js +++ b/public/app.js @@ -109,15 +109,78 @@ function getTimestampMode() { return serverDefault === 'absolute' ? 'absolute' : 'ago'; } +function getTimestampTimezone() { + const saved = localStorage.getItem('meshcore-timestamp-timezone'); + if (saved === 'utc' || saved === 'local') return saved; + const serverDefault = window.SITE_CONFIG?.timestamps?.timezone; + return serverDefault === 'utc' ? 'utc' : 'local'; +} + +function getTimestampFormatPreset() { + const saved = localStorage.getItem('meshcore-timestamp-format'); + if (saved === 'iso' || saved === 'iso-seconds' || saved === 'locale') return saved; + const serverDefault = window.SITE_CONFIG?.timestamps?.formatPreset; + return (serverDefault === 'iso' || serverDefault === 'iso-seconds' || serverDefault === 'locale') ? serverDefault : 'iso'; +} + +function getTimestampCustomFormat() { + if (window.SITE_CONFIG?.timestamps?.allowCustomFormat !== true) return ''; + const saved = localStorage.getItem('meshcore-timestamp-custom-format'); + if (saved != null) return String(saved); + const serverDefault = window.SITE_CONFIG?.timestamps?.customFormat; + return serverDefault == null ? '' : String(serverDefault); +} + +function pad2(v) { return String(v).padStart(2, '0'); } +function pad3(v) { return String(v).padStart(3, '0'); } + +function formatIsoLike(d, timezone, includeMs) { + const useUtc = timezone === 'utc'; + const year = useUtc ? d.getUTCFullYear() : d.getFullYear(); + const month = useUtc ? d.getUTCMonth() + 1 : d.getMonth() + 1; + const day = useUtc ? d.getUTCDate() : d.getDate(); + const hour = useUtc ? d.getUTCHours() : d.getHours(); + const minute = useUtc ? d.getUTCMinutes() : d.getMinutes(); + const second = useUtc ? d.getUTCSeconds() : d.getSeconds(); + const ms = useUtc ? d.getUTCMilliseconds() : d.getMilliseconds(); + let out = year + '-' + pad2(month) + '-' + pad2(day) + ' ' + pad2(hour) + ':' + pad2(minute) + ':' + pad2(second); + if (includeMs) out += '.' + pad3(ms); + return out; +} + +function formatTimestampCustom(d, formatString, timezone) { + if (!/YYYY|MM|DD|HH|mm|ss|SSS|Z/.test(String(formatString))) return ''; + const useUtc = timezone === 'utc'; + const replacements = { + YYYY: String(useUtc ? d.getUTCFullYear() : d.getFullYear()), + MM: pad2((useUtc ? d.getUTCMonth() : d.getMonth()) + 1), + DD: pad2(useUtc ? d.getUTCDate() : d.getDate()), + HH: pad2(useUtc ? d.getUTCHours() : d.getHours()), + mm: pad2(useUtc ? d.getUTCMinutes() : d.getMinutes()), + ss: pad2(useUtc ? d.getUTCSeconds() : d.getSeconds()), + SSS: pad3(useUtc ? d.getUTCMilliseconds() : d.getMilliseconds()), + Z: (timezone === 'utc' ? 'UTC' : 'local') + }; + return String(formatString).replace(/YYYY|MM|DD|HH|mm|ss|SSS|Z/g, token => replacements[token] || token); +} + function formatAbsoluteTimestamp(iso) { if (!iso) return '—'; const d = new Date(iso); if (!isFinite(d.getTime())) return '—'; - const mobileShort = typeof window !== 'undefined' && window.innerWidth <= 640; - if (mobileShort) { - return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const timezone = getTimestampTimezone(); + const preset = getTimestampFormatPreset(); + const customFormat = getTimestampCustomFormat().trim(); + if (customFormat) { + const customOut = formatTimestampCustom(d, customFormat, timezone); + if (customOut && !/Invalid Date|NaN|undefined|null/.test(customOut)) return customOut; } - return d.toISOString(); + if (preset === 'iso-seconds') return formatIsoLike(d, timezone, true); + if (preset === 'locale') { + if (timezone === 'utc') return d.toLocaleString([], { timeZone: 'UTC' }); + return d.toLocaleString(); + } + return formatIsoLike(d, timezone, false); } function formatTimestamp(isoString, mode) { @@ -649,11 +712,13 @@ window.addEventListener('DOMContentLoaded', () => { // Fetch theme config and apply branding/colors before first render fetch('/api/config/theme', { cache: 'no-store' }).then(r => r.json()).then(cfg => { window.SITE_CONFIG = cfg || {}; - if (!window.SITE_CONFIG.timestamps) { - window.SITE_CONFIG.timestamps = { defaultMode: 'ago' }; - } else if (window.SITE_CONFIG.timestamps.defaultMode !== 'absolute' && window.SITE_CONFIG.timestamps.defaultMode !== 'ago') { - window.SITE_CONFIG.timestamps.defaultMode = 'ago'; - } + if (!window.SITE_CONFIG.timestamps) window.SITE_CONFIG.timestamps = {}; + const tsCfg = window.SITE_CONFIG.timestamps; + if (tsCfg.defaultMode !== 'absolute' && tsCfg.defaultMode !== 'ago') tsCfg.defaultMode = 'ago'; + if (tsCfg.timezone !== 'utc' && tsCfg.timezone !== 'local') tsCfg.timezone = 'local'; + if (tsCfg.formatPreset !== 'iso' && tsCfg.formatPreset !== 'iso-seconds' && tsCfg.formatPreset !== 'locale') tsCfg.formatPreset = 'iso'; + if (typeof tsCfg.customFormat !== 'string') tsCfg.customFormat = ''; + tsCfg.allowCustomFormat = tsCfg.allowCustomFormat === true; // User's localStorage preferences take priority over server config const userTheme = (() => { try { return JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}'); } catch { return {}; } })(); @@ -727,7 +792,7 @@ window.addEventListener('DOMContentLoaded', () => { if (favicon) favicon.href = cfg.branding.faviconUrl; } } - }).catch(() => { window.SITE_CONFIG = { timestamps: { defaultMode: 'ago' } }; }).finally(() => { + }).catch(() => { window.SITE_CONFIG = { timestamps: { defaultMode: 'ago', timezone: 'local', formatPreset: 'iso', customFormat: '', allowCustomFormat: false } }; }).finally(() => { if (!location.hash || location.hash === '#/') location.hash = '#/home'; else navigate(); }); diff --git a/public/customize.js b/public/customize.js index 2fc6bd16..84b2d9a2 100644 --- a/public/customize.js +++ b/public/customize.js @@ -65,7 +65,10 @@ ] }, ui: { - timestampMode: 'ago' + timestampMode: 'ago', + timestampTimezone: 'local', + timestampFormat: 'iso', + timestampCustomFormat: '' } }; @@ -449,11 +452,28 @@ } var mergedHome = mergeSection('home'); var localTsMode = localStorage.getItem('meshcore-timestamp-mode'); + var localTsTimezone = localStorage.getItem('meshcore-timestamp-timezone'); + var localTsFormat = localStorage.getItem('meshcore-timestamp-format'); + var localTsCustomFormat = localStorage.getItem('meshcore-timestamp-custom-format'); var serverTsMode = (cfg.timestamps && cfg.timestamps.defaultMode === 'absolute') ? 'absolute' : 'ago'; + var serverTsTimezone = (cfg.timestamps && cfg.timestamps.timezone === 'utc') ? 'utc' : 'local'; + var serverTsFormat = (cfg.timestamps && (cfg.timestamps.formatPreset === 'iso' || cfg.timestamps.formatPreset === 'iso-seconds' || cfg.timestamps.formatPreset === 'locale')) + ? cfg.timestamps.formatPreset + : 'iso'; + var serverTsCustomFormat = (cfg.timestamps && typeof cfg.timestamps.customFormat === 'string') ? cfg.timestamps.customFormat : ''; var mergedUi = mergeSection('ui'); mergedUi.timestampMode = (localTsMode === 'ago' || localTsMode === 'absolute') ? localTsMode : (mergedUi.timestampMode === 'absolute' || serverTsMode === 'absolute' ? 'absolute' : 'ago'); + mergedUi.timestampTimezone = (localTsTimezone === 'local' || localTsTimezone === 'utc') + ? localTsTimezone + : (mergedUi.timestampTimezone === 'utc' || serverTsTimezone === 'utc' ? 'utc' : 'local'); + mergedUi.timestampFormat = (localTsFormat === 'iso' || localTsFormat === 'iso-seconds' || localTsFormat === 'locale') + ? localTsFormat + : ((mergedUi.timestampFormat === 'iso' || mergedUi.timestampFormat === 'iso-seconds' || mergedUi.timestampFormat === 'locale') ? mergedUi.timestampFormat : serverTsFormat); + mergedUi.timestampCustomFormat = (localTsCustomFormat != null) + ? localTsCustomFormat + : (typeof mergedUi.timestampCustomFormat === 'string' ? mergedUi.timestampCustomFormat : serverTsCustomFormat); state = { branding: mergeSection('branding'), theme: mergeSection('theme'), @@ -666,6 +686,11 @@ function renderDisplay() { var tsMode = state.ui.timestampMode === 'absolute' ? 'absolute' : 'ago'; + var tsTimezone = state.ui.timestampTimezone === 'utc' ? 'utc' : 'local'; + var tsFormat = (state.ui.timestampFormat === 'iso-seconds' || state.ui.timestampFormat === 'locale') ? state.ui.timestampFormat : 'iso'; + var canCustomFormat = !!(window.SITE_CONFIG && window.SITE_CONFIG.timestamps && window.SITE_CONFIG.timestamps.allowCustomFormat === true); + var customFormat = typeof state.ui.timestampCustomFormat === 'string' ? state.ui.timestampCustomFormat : ''; + var showAbsoluteOnly = tsMode === 'absolute' ? '' : ' style="display:none"'; return '
' + '

Display Settings

' + '

UI preferences that affect how data is shown across pages.

' + @@ -677,7 +702,25 @@ '' + '' + '
' + - '

More display controls (UTC/local and format presets) can be added here in future.

' + + '
' + + '' + + '
' + + '
' + + '' + + '
' + + (canCustomFormat + ? ('
' + + '' + + '
If non-empty, this overrides preset formatting.
' + + '
') + : '') + ''; } @@ -886,6 +929,9 @@ // UI var ui = {}; if ((state.ui.timestampMode || 'ago') !== DEFAULTS.ui.timestampMode) ui.timestampMode = state.ui.timestampMode; + if ((state.ui.timestampTimezone || 'local') !== DEFAULTS.ui.timestampTimezone) ui.timestampTimezone = state.ui.timestampTimezone; + if ((state.ui.timestampFormat || 'iso') !== DEFAULTS.ui.timestampFormat) ui.timestampFormat = state.ui.timestampFormat; + if ((state.ui.timestampCustomFormat || '') !== DEFAULTS.ui.timestampCustomFormat) ui.timestampCustomFormat = state.ui.timestampCustomFormat; if (Object.keys(ui).length) out.ui = ui; return out; @@ -981,11 +1027,38 @@ sel.addEventListener('change', function () { var key = sel.dataset.ui; state.ui[key] = sel.value; - if (key === 'timestampMode') { - localStorage.setItem('meshcore-timestamp-mode', sel.value); + if (key === 'timestampMode' || key === 'timestampTimezone' || key === 'timestampFormat') { if (!window.SITE_CONFIG) window.SITE_CONFIG = {}; if (!window.SITE_CONFIG.timestamps) window.SITE_CONFIG.timestamps = {}; - window.SITE_CONFIG.timestamps.defaultMode = sel.value; + if (key === 'timestampMode') { + localStorage.setItem('meshcore-timestamp-mode', sel.value); + window.SITE_CONFIG.timestamps.defaultMode = sel.value; + var formatRow = container.querySelector('[data-ts-absolute-only="format"]'); + if (formatRow) formatRow.style.display = sel.value === 'absolute' ? '' : 'none'; + var customRow = container.querySelector('[data-ts-absolute-only="custom"]'); + if (customRow) customRow.style.display = sel.value === 'absolute' ? '' : 'none'; + } else if (key === 'timestampTimezone') { + localStorage.setItem('meshcore-timestamp-timezone', sel.value); + window.SITE_CONFIG.timestamps.timezone = sel.value; + } else if (key === 'timestampFormat') { + localStorage.setItem('meshcore-timestamp-format', sel.value); + window.SITE_CONFIG.timestamps.formatPreset = sel.value; + } + window.dispatchEvent(new CustomEvent('timestamp-mode-changed')); + } + autoSave(); + }); + }); + + container.querySelectorAll('input[data-ui-input]').forEach(function (inp) { + inp.addEventListener('input', function () { + var key = inp.dataset.uiInput; + state.ui[key] = inp.value; + if (key === 'timestampCustomFormat') { + localStorage.setItem('meshcore-timestamp-custom-format', inp.value); + if (!window.SITE_CONFIG) window.SITE_CONFIG = {}; + if (!window.SITE_CONFIG.timestamps) window.SITE_CONFIG.timestamps = {}; + window.SITE_CONFIG.timestamps.customFormat = inp.value; window.dispatchEvent(new CustomEvent('timestamp-mode-changed')); } autoSave(); diff --git a/public/index.html b/public/index.html index 6d096c3c..36cc440d 100644 --- a/public/index.html +++ b/public/index.html @@ -22,9 +22,9 @@ - - - + + + @@ -81,30 +81,30 @@
- - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index c91320eb..b8a759a0 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -125,22 +125,52 @@ console.log('\n=== app.js: formatTimestamp / formatTimestampWithTooltip ==='); const d = new Date(Date.now() - 300000).toISOString(); assert.strictEqual(formatTimestamp(d, 'ago'), '5m ago'); }); - test('formatTimestamp absolute returns ISO string', () => { + test('formatTimestamp absolute returns formatted timestamp', () => { const d = '2024-01-02T03:04:05.000Z'; - assert.strictEqual(formatTimestamp(d, 'absolute'), d); + const out = formatTimestamp(d, 'absolute'); + assert.ok(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(out)); + }); + test('formatTimestamp absolute with timezone utc uses UTC fields', () => { + const d = '2024-01-02T03:04:05.123Z'; + ctx.localStorage.setItem('meshcore-timestamp-timezone', 'utc'); + ctx.localStorage.setItem('meshcore-timestamp-format', 'iso'); + assert.strictEqual(formatTimestamp(d, 'absolute'), '2024-01-02 03:04:05'); + }); + test('formatTimestamp absolute with timezone local uses local fields', () => { + const d = '2024-01-02T03:04:05.123Z'; + ctx.localStorage.setItem('meshcore-timestamp-timezone', 'local'); + ctx.localStorage.setItem('meshcore-timestamp-format', 'iso'); + const out = formatTimestamp(d, 'absolute'); + const expected = d.replace('T', ' ').slice(0, 19); + assert.strictEqual(out.length, 19); + assert.ok(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(out)); + if (new Date(d).getTimezoneOffset() === 0) assert.strictEqual(out, expected); + else assert.notStrictEqual(out, expected); + }); + test('formatTimestamp absolute iso-seconds includes milliseconds', () => { + const d = '2024-01-02T03:04:05.123Z'; + ctx.localStorage.setItem('meshcore-timestamp-timezone', 'utc'); + ctx.localStorage.setItem('meshcore-timestamp-format', 'iso-seconds'); + assert.strictEqual(formatTimestamp(d, 'absolute'), '2024-01-02 03:04:05.123'); + }); + test('formatTimestamp absolute locale uses toLocaleString', () => { + const d = '2024-01-02T03:04:05.123Z'; + ctx.localStorage.setItem('meshcore-timestamp-timezone', 'local'); + ctx.localStorage.setItem('meshcore-timestamp-format', 'locale'); + assert.strictEqual(formatTimestamp(d, 'absolute'), new Date(d).toLocaleString()); }); test('formatTimestampWithTooltip future returns isFuture true', () => { const d = new Date(Date.now() + 120000).toISOString(); const out = formatTimestampWithTooltip(d, 'ago'); assert.strictEqual(out.isFuture, true); - assert.strictEqual(out.text, d); + assert.ok(typeof out.text === 'string' && out.text.length > 0); assert.strictEqual(out.tooltip, 'in 2m'); }); test('tooltip is opposite format', () => { const d = '2024-01-02T03:04:05.000Z'; const ago = formatTimestampWithTooltip(d, 'ago'); const absolute = formatTimestampWithTooltip(d, 'absolute'); - assert.strictEqual(ago.tooltip, d); + assert.ok(typeof ago.tooltip === 'string' && ago.tooltip.length > 0); assert.ok(absolute.tooltip.endsWith('ago') || absolute.tooltip.startsWith('in ')); }); }