fix(analytics): apply customizer timestamp format to chart axes (closes #756) (#981)

## Summary

Fixes #756 — the customizer timestamp format setting (ISO/ISO+ms/locale)
and timezone (UTC/local) were not applied to chart X-axis labels,
tooltips, or certain inline timestamps in the analytics pages.

## Changes

### `public/app.js`
- Added `formatChartAxisLabel(date, shortForm)` — a shared helper that
reads the customizer's `timestampFormat` and `timestampTimezone`
preferences and formats dates for chart axes accordingly.
`shortForm=true` returns time-only (for intra-day charts),
`shortForm=false` returns date+time (for multi-day ranges).

### `public/analytics.js`
- `rfXAxisLabels()`: now calls `formatChartAxisLabel()` instead of
hardcoded `toLocaleTimeString()`
- `rfTooltipCircles()`: tooltip timestamps now use
`formatAbsoluteTimestamp()` instead of raw ISO
- Subpath detail first/last seen: now uses `formatAbsoluteTimestamp()`
- Neighbor graph last_seen: now uses `formatAbsoluteTimestamp()`

### `public/node-analytics.js`
- Packet timeline chart labels: now use `formatChartAxisLabel()`
(respects short vs long form based on time range)
- SNR over time chart labels: now use `formatChartAxisLabel()`

## Behavior by setting

| Setting | Chart axis (short) | Chart axis (long) |
|---------|-------------------|-------------------|
| ISO | `14:30` | `05-03 14:30` |
| ISO+ms | `14:30:05` | `05-03 14:30:05` |
| Locale | `2:30 PM` | `May 3, 2:30 PM` |

All respect the UTC/local timezone toggle.

## Testing

- Server builds cleanly (`go build`)
- Served `app.js` contains `formatChartAxisLabel` (verified via curl)
- Graceful fallback: all callsites check `typeof formatChartAxisLabel
=== 'function'` before calling, preserving backward compat if script
load order changes

---------

Co-authored-by: you <you@example.com>
This commit is contained in:
Kpa-clawbot
2026-05-02 20:10:29 -07:00
committed by GitHub
parent b3b96b3dda
commit 736b09697d
4 changed files with 107 additions and 7 deletions
+5 -5
View File
@@ -1732,8 +1732,8 @@
<div class="subpath-section">
<h5> Timeline</h5>
<div>First seen: ${data.firstSeen ? new Date(data.firstSeen).toLocaleString() : '—'}</div>
<div>Last seen: ${data.lastSeen ? new Date(data.lastSeen).toLocaleString() : '—'}</div>
<div>First seen: ${data.firstSeen ? (typeof formatAbsoluteTimestamp === 'function' ? formatAbsoluteTimestamp(data.firstSeen) : new Date(data.firstSeen).toLocaleString()) : '—'}</div>
<div>Last seen: ${data.lastSeen ? (typeof formatAbsoluteTimestamp === 'function' ? formatAbsoluteTimestamp(data.lastSeen) : new Date(data.lastSeen).toLocaleString()) : '—'}</div>
</div>
${data.observers.length ? `
@@ -2660,7 +2660,7 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
const name = esc(n.name || n.public_key.slice(0, 12));
const role = n.role ? `<span class="text-muted" style="font-size:0.82em">${esc(n.role)}</span>` : '';
const hs = n.hash_size ? ` <span class="text-muted" style="font-size:0.78em;opacity:0.7">${n.hash_size}B hash</span>` : '';
const when = n.last_seen ? ` <span class="text-muted" style="font-size:0.8em">${new Date(n.last_seen).toLocaleDateString()}</span>` : '';
const when = n.last_seen ? ` <span class="text-muted" style="font-size:0.8em">${(typeof formatAbsoluteTimestamp === 'function') ? formatAbsoluteTimestamp(n.last_seen) : new Date(n.last_seen).toLocaleDateString()}</span>` : '';
return `<div style="padding:3px 0"><a href="#/nodes/${encodeURIComponent(n.public_key)}" class="analytics-link">${name}</a> ${role}${hs}${when}</div>`;
}
@@ -3158,7 +3158,7 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
const t = new Date(d.t);
const x = sx(t.getTime());
const y = sy(d.v);
const ts = t.toISOString().replace('T', ' ').replace(/\.\d+Z/, ' UTC');
const ts = (typeof formatAbsoluteTimestamp === 'function') ? formatAbsoluteTimestamp(d.t) : t.toISOString().replace('T', ' ').replace(/\.\d+Z/, ' UTC');
const tip = `${label}: ${formatV(d.v)}${unit}\n${ts}`;
svg += `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="8" fill="transparent" stroke="none" pointer-events="all"><title>${tip}</title></circle>`;
});
@@ -3172,7 +3172,7 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
const idx = Math.floor(i * (data.length - 1) / Math.max(xTicks - 1, 1));
const t = new Date(data[idx].t);
const x = sx(t.getTime());
const label = t.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const label = (typeof formatChartAxisLabel === 'function') ? formatChartAxisLabel(t, true) : t.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
svg += `<text x="${x.toFixed(1)}" y="${h - 5}" text-anchor="middle" font-size="9" fill="var(--text-muted)">${label}</text>`;
}
return svg;
+33
View File
@@ -309,6 +309,39 @@ function formatTimestampWithTooltip(isoString, mode) {
return { text, tooltip, isFuture };
}
// Format a Date for chart axis labels, respecting customizer timestamp settings.
// shortForm: true = time only (for intra-day), false = date+time (multi-day).
function formatChartAxisLabel(d, shortForm) {
if (!(d instanceof Date) || !isFinite(d.getTime())) return '—';
var timezone = (typeof getTimestampTimezone === 'function') ? getTimestampTimezone() : 'local';
var preset = (typeof getTimestampFormatPreset === 'function') ? getTimestampFormatPreset() : 'iso';
var useUtc = timezone === 'utc';
if (preset === 'locale') {
if (shortForm) {
var opts = { hour: '2-digit', minute: '2-digit' };
if (useUtc) opts.timeZone = 'UTC';
return d.toLocaleTimeString([], opts);
}
var opts2 = { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' };
if (useUtc) opts2.timeZone = 'UTC';
return d.toLocaleString([], opts2);
}
// ISO-style (iso or iso-seconds)
var hour = useUtc ? d.getUTCHours() : d.getHours();
var minute = useUtc ? d.getUTCMinutes() : d.getMinutes();
var timeStr = pad2(hour) + ':' + pad2(minute);
if (preset === 'iso-seconds') {
var sec = useUtc ? d.getUTCSeconds() : d.getSeconds();
timeStr += ':' + pad2(sec);
}
if (shortForm) return timeStr;
var month = useUtc ? d.getUTCMonth() + 1 : d.getMonth() + 1;
var day = useUtc ? d.getUTCDate() : d.getDate();
return pad2(month) + '-' + pad2(day) + ' ' + timeStr;
}
function truncate(str, len) {
if (!str) return '';
return str.length > len ? str.slice(0, len) + '…' : str;
+2 -2
View File
@@ -170,7 +170,7 @@
data: {
labels: tl.map(b => {
const d = new Date(b.bucket);
return currentDays <= 3 ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString([], { month: 'short', day: 'numeric' });
return (typeof formatChartAxisLabel === 'function') ? formatChartAxisLabel(d, currentDays <= 3) : (currentDays <= 3 ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString([], { month: 'short', day: 'numeric' }));
}),
datasets: [{ label: 'Packets', data: tl.map(b => b.count), backgroundColor: 'rgba(74,158,255,0.5)', borderColor: '#4a9eff', borderWidth: 1 }]
},
@@ -197,7 +197,7 @@
const longestObs = Object.values(byObs).sort((a, b) => b.points.length - a.points.length)[0];
const labels = longestObs ? longestObs.points.map(p => {
const d = p.x;
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return (typeof formatChartAxisLabel === 'function') ? formatChartAxisLabel(d, false) : d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}) : [];
const c = new Chart(ctx, {
type: 'line',
+67
View File
@@ -6451,6 +6451,73 @@ console.log('\n=== analytics.js: renderCollisionsFromServer collision table ==='
});
}
// ===== APP.JS: formatChartAxisLabel =====
console.log('\n=== app.js: formatChartAxisLabel ===');
{
const ctx = makeSandbox();
loadInCtx(ctx, 'public/roles.js');
loadInCtx(ctx, 'public/app.js');
const formatChartAxisLabel = ctx.formatChartAxisLabel;
test('formatChartAxisLabel returns dash for invalid date', () => {
assert.strictEqual(formatChartAxisLabel(new Date('invalid'), true), '—');
});
test('formatChartAxisLabel returns dash for non-Date', () => {
assert.strictEqual(formatChartAxisLabel('not a date', true), '—');
});
test('formatChartAxisLabel ISO short form returns HH:MM', () => {
ctx.localStorage.setItem('meshcore-timestamp-format', 'iso');
ctx.localStorage.setItem('meshcore-timestamp-timezone', 'utc');
const d = new Date('2024-06-15T14:30:00Z');
assert.strictEqual(formatChartAxisLabel(d, true), '14:30');
});
test('formatChartAxisLabel ISO long form returns MM-DD HH:MM', () => {
ctx.localStorage.setItem('meshcore-timestamp-format', 'iso');
ctx.localStorage.setItem('meshcore-timestamp-timezone', 'utc');
const d = new Date('2024-06-15T14:30:00Z');
assert.strictEqual(formatChartAxisLabel(d, false), '06-15 14:30');
});
test('formatChartAxisLabel ISO-seconds short form includes seconds', () => {
ctx.localStorage.setItem('meshcore-timestamp-format', 'iso-seconds');
ctx.localStorage.setItem('meshcore-timestamp-timezone', 'utc');
const d = new Date('2024-06-15T14:30:05Z');
assert.strictEqual(formatChartAxisLabel(d, true), '14:30:05');
});
test('formatChartAxisLabel ISO-seconds long form includes seconds', () => {
ctx.localStorage.setItem('meshcore-timestamp-format', 'iso-seconds');
ctx.localStorage.setItem('meshcore-timestamp-timezone', 'utc');
const d = new Date('2024-06-15T14:30:05Z');
assert.strictEqual(formatChartAxisLabel(d, false), '06-15 14:30:05');
});
test('formatChartAxisLabel locale short form returns localized time', () => {
ctx.localStorage.setItem('meshcore-timestamp-format', 'locale');
ctx.localStorage.setItem('meshcore-timestamp-timezone', 'utc');
const d = new Date('2024-06-15T14:30:00Z');
const result = formatChartAxisLabel(d, true);
// Locale output varies by env, but should contain hour digits
assert.ok(result.includes('14') || result.includes('2:'), 'short locale should contain hour: ' + result);
});
test('formatChartAxisLabel locale long form returns date+time', () => {
ctx.localStorage.setItem('meshcore-timestamp-format', 'locale');
ctx.localStorage.setItem('meshcore-timestamp-timezone', 'utc');
const d = new Date('2024-06-15T14:30:00Z');
const result = formatChartAxisLabel(d, false);
// Should contain day reference and time
assert.ok(result.length > 5, 'long locale should be non-trivial: ' + result);
});
// Clean up
ctx.localStorage.removeItem('meshcore-timestamp-format');
ctx.localStorage.removeItem('meshcore-timestamp-timezone');
}
// ===== SUMMARY =====
Promise.allSettled(pendingTests).then(() => {
console.log(`\n${'═'.repeat(40)}`);