feat(frontend): implement #286 P1 timestamp controls (#296)

## Summary
Implements issue #286 P1 frontend timestamp features on top of P0:

- Added global timestamp timezone toggle in Display tab (`Local time` /
`UTC`)
- Added absolute-mode timestamp format presets (`iso`, `iso-seconds`,
`locale`)
- Added optional custom format input (only when
`SITE_CONFIG.timestamps.allowCustomFormat === true`)
- Extended `formatTimestamp()` / `formatTimestampWithTooltip()` behavior
to honor timezone + format settings
- Preserved server defaults with localStorage override precedence
- Bumped `public/index.html` cache busters in same commit

## Details
### 1) Timezone toggle
- New Display tab control persisted to `meshcore-timestamp-timezone`
- Reads server default from `window.SITE_CONFIG.timestamps.timezone`
with fallback to `local`
- Formatting logic now supports both local and UTC absolute rendering

### 2) Format presets (absolute mode only)
- New Display tab preset dropdown (shown only when timestamp mode =
`absolute`)
- Presets implemented:
  - `iso` → `YYYY-MM-DD HH:mm:ss`
  - `iso-seconds` → `YYYY-MM-DD HH:mm:ss.SSS`
  - `locale` → `toLocaleString()` (or UTC locale when timezone=utc)
- Persisted to `meshcore-timestamp-format`
- Reads server default from `window.SITE_CONFIG.timestamps.formatPreset`
(fallback `iso`)

### 3) Custom format string (guarded)
- Text input only renders when
`window.SITE_CONFIG.timestamps.allowCustomFormat` is `true`
- Persisted to `meshcore-timestamp-custom-format`
- If non-empty and enabled, custom format overrides preset
- Frontend intentionally does not hard-validate the format string;
unsupported patterns fall back to preset behavior

## Tests
Executed required test commands:

```bash
node test-frontend-helpers.js
node test-packet-filter.js
node test-aging.js
```

Added coverage in `test-frontend-helpers.js` for:
- UTC output behavior
- Local output behavior
- `iso-seconds` includes milliseconds
- `locale` format behavior

All passed locally.

Refs #286

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
This commit is contained in:
Kpa-clawbot
2026-03-30 18:32:05 -07:00
committed by GitHub
co-authored by Kpa-clawbot
parent b654ac6c9f
commit 928a3d995a
4 changed files with 215 additions and 47 deletions
+75 -10
View File
@@ -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();
});
+78 -5
View File
@@ -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 '<div class="cust-panel' + (activeTab === 'display' ? ' active' : '') + '" data-panel="display">' +
'<p class="cust-section-title">Display Settings</p>' +
'<p style="font-size:12px;color:var(--text-muted);margin-bottom:12px">UI preferences that affect how data is shown across pages.</p>' +
@@ -677,7 +702,25 @@
'<option value="absolute"' + (tsMode === 'absolute' ? ' selected' : '') + '>Absolute (ISO timestamp)</option>' +
'</select>' +
'</div>' +
'<p style="font-size:11px;color:var(--text-muted);margin-top:10px">More display controls (UTC/local and format presets) can be added here in future.</p>' +
'<div class="cust-field"><label for="custTimestampTimezone">Timestamp Timezone</label>' +
'<select id="custTimestampTimezone" data-ui="timestampTimezone" style="width:100%;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text)">' +
'<option value="local"' + (tsTimezone === 'local' ? ' selected' : '') + '>Local time</option>' +
'<option value="utc"' + (tsTimezone === 'utc' ? ' selected' : '') + '>UTC</option>' +
'</select>' +
'</div>' +
'<div class="cust-field" data-ts-absolute-only="format"' + showAbsoluteOnly + '><label for="custTimestampFormat">Timestamp Format (Absolute mode)</label>' +
'<select id="custTimestampFormat" data-ui="timestampFormat" style="width:100%;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text)">' +
'<option value="iso"' + (tsFormat === 'iso' ? ' selected' : '') + '>ISO (2024-01-15 14:30:00)</option>' +
'<option value="iso-seconds"' + (tsFormat === 'iso-seconds' ? ' selected' : '') + '>ISO + milliseconds (2024-01-15 14:30:00.123)</option>' +
'<option value="locale"' + (tsFormat === 'locale' ? ' selected' : '') + '>Locale (browser format)</option>' +
'</select>' +
'</div>' +
(canCustomFormat
? ('<div class="cust-field" data-ts-absolute-only="custom"' + showAbsoluteOnly + '><label for="custTimestampCustomFormat">Custom Timestamp Format (Absolute mode)</label>' +
'<input type="text" id="custTimestampCustomFormat" data-ui-input="timestampCustomFormat" value="' + escAttr(customFormat) + '" placeholder="YYYY-MM-DD HH:mm:ss">' +
'<div class="cust-hint">If non-empty, this overrides preset formatting.</div>' +
'</div>')
: '') +
'</div>';
}
@@ -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();
+28 -28
View File
@@ -22,9 +22,9 @@
<meta name="twitter:title" content="CoreScope">
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
<link rel="stylesheet" href="style.css?v=1774918154">
<link rel="stylesheet" href="home.css?v=1774918154">
<link rel="stylesheet" href="live.css?v=1774918154">
<link rel="stylesheet" href="style.css?v=1774920209">
<link rel="stylesheet" href="home.css?v=1774920209">
<link rel="stylesheet" href="live.css?v=1774920209">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="anonymous">
@@ -81,30 +81,30 @@
<main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script>
<script src="roles.js?v=1774918154"></script>
<script src="customize.js?v=1774918154" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774918154"></script>
<script src="hop-resolver.js?v=1774918154"></script>
<script src="hop-display.js?v=1774918154"></script>
<script src="app.js?v=1774918154"></script>
<script src="home.js?v=1774918154"></script>
<script src="packet-filter.js?v=1774918154"></script>
<script src="packets.js?v=1774918154"></script>
<script src="geo-filter-overlay.js?v=1774918154"></script>
<script src="map.js?v=1774918154" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774918154" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774918154" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774918154" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774918154" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774918154" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774918154" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774918154" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774918154" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774918154" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774918154" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774918154" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1774918154" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774918154" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774918154" onerror="console.error('Failed to load:', this.src)"></script>
<script src="roles.js?v=1774920209"></script>
<script src="customize.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774920209"></script>
<script src="hop-resolver.js?v=1774920209"></script>
<script src="hop-display.js?v=1774920209"></script>
<script src="app.js?v=1774920209"></script>
<script src="home.js?v=1774920209"></script>
<script src="packet-filter.js?v=1774920209"></script>
<script src="packets.js?v=1774920209"></script>
<script src="geo-filter-overlay.js?v=1774920209"></script>
<script src="map.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
</body>
</html>
+34 -4
View File
@@ -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 '));
});
}