From 1d25c7e79e0a14e636c84328fcd50ec899eb06c7 Mon Sep 17 00:00:00 2001 From: R0ck Date: Mon, 12 Oct 2026 21:54:37 +0100 Subject: [PATCH 1/2] =?UTF-8?q?test(analytics):=20red=20=E2=80=94=20Packet?= =?UTF-8?q?s=20/=20Hour=20labels=20follow=20the=20viewer's=20locale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dates read dd/mm for a UK viewer and mm/dd for a US one; hours follow the viewer's clock (15:00 or 3:00 PM). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --- .../unit/test-issue-2193-packets-per-hour.js | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/unit/test-issue-2193-packets-per-hour.js b/tests/unit/test-issue-2193-packets-per-hour.js index b85037702..58a69518e 100644 --- a/tests/unit/test-issue-2193-packets-per-hour.js +++ b/tests/unit/test-issue-2193-packets-per-hour.js @@ -4,7 +4,8 @@ * Drives window._analyticsPacketsPerHourCard(pph) from public/analytics.js: * months of hours roll up to daily bars with few labels, missing hours count * as zero, and the chart starts where the data does, not at a thin run of - * old packets a short store keeps because they were heard again. + * old packets a short store keeps because they were heard again. Labels + * follow the viewer's locale (14/09 in the UK, 09/14 in the US). * * Usage: node tests/unit/test-issue-2193-packets-per-hour.js */ @@ -32,8 +33,21 @@ ctx.document = { documentElement: {}, createElement: () => ({ style: {}, addEventListener() {} }), addEventListener() {}, removeEventListener() {}, querySelector: () => null, querySelectorAll: () => [], getElementById: () => null, }; +const src = fs.readFileSync(path.join(__dirname, '..', '..', 'public/analytics.js'), 'utf8'); +// cardIn loads the page as a viewer in locale would see it, with the app's +// timezone setting on UTC so the labels do not depend on this machine's zone. +function cardIn(locale) { + const c = Object.assign({}, ctx, { + Intl: { DateTimeFormat: function (_, o) { return new Intl.DateTimeFormat(locale, o); } }, + getTimestampTimezone: () => 'utc', + }); + c.window = c; + vm.createContext(c); + vm.runInContext(src, c); + return c._analyticsPacketsPerHourCard; +} vm.createContext(ctx); -vm.runInContext(fs.readFileSync(path.join(__dirname, '..', '..', 'public/analytics.js'), 'utf8'), ctx); +vm.runInContext(src, ctx); const card = ctx._analyticsPacketsPerHourCard; if (typeof card !== 'function') { console.error(' ✗ _analyticsPacketsPerHourCard not exposed'); process.exit(1); } @@ -84,5 +98,12 @@ assert(/since 10-04/.test(c.html), 'a cropped chart says where it starts (since c = card(hours(2932), { window: '' }); assert(!/since/.test(c.html), 'and an uncropped one says nothing'); +const texts = (html) => [...html.matchAll(/]*font-size="9"[^>]*>([^<]+) m[1]); +const gb = texts(cardIn('en-GB')(hours(2932)).html), us = texts(cardIn('en-US')(hours(2932)).html); +assert(gb.length > 3 && gb.every((l) => /^\d{2}\/\d{2}$/.test(l)), 'UK dates read dd/mm (got ' + gb.join(' ') + ')'); +assert(us.length === gb.length && gb.every((l) => /\//.test(l)) && us.every((l, i) => l === gb[i].split('/').reverse().join('/')), 'US dates read mm/dd (got ' + us.join(' ') + ')'); +const gbH = texts(cardIn('en-GB')(hours(24)).html), usH = texts(cardIn('en-US')(hours(24)).html); +assert(gbH.includes('12:00') && usH.includes('12:00 PM'), 'hours in the viewer\'s clock: 12:00 in the UK, 12:00 PM in the US (got ' + gbH.join(' ') + ' / ' + usH.join(' ') + ')'); + console.log(`\n${passed} passed, ${failed} failed`); process.exit(failed > 0 ? 1 : 0); From caac33cffc3f88061d3ceed2f0e4a707c6ee829b Mon Sep 17 00:00:00 2001 From: R0ck Date: Mon, 12 Oct 2026 21:55:21 +0100 Subject: [PATCH 2/2] feat(analytics): Packets / Hour labels follow the viewer's locale Dates and hours use the viewer's own format (14/09 or 09/14, 15:00 or 3:00 PM), and the bars and labels sit on their own clock unless the app's timezone setting is UTC, so a label never shows a UTC time in a local format. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --- public/analytics.js | 17 ++++++++++++----- tests/unit/test-issue-2193-packets-per-hour.js | 4 ++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/public/analytics.js b/public/analytics.js index b69b451f4..0770bd06f 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -79,6 +79,8 @@ // short store also keeps a few old packets that were heard again, weeks // before the rest. The card then says where it starts. In a picked window // the server has already cut the data to it, so nothing is cropped. + // Buckets and labels follow the viewer: their locale's date and time + // format, on their own clock unless the app's timezone setting is UTC. function packetsPerHourCard(pph, opts) { const icon = ''; if (!pph || !pph.length) return { html: `

${icon} Packets / Hour

No packets in this period.

`, from: null }; @@ -92,16 +94,21 @@ const start = opts && opts.window ? first : at(pph.find((h) => h.hour.slice(0, 10) >= day && h.count >= floor * 5 / 24) || pph[0]); const last = at(pph[pph.length - 1]), spanH = (last - start) / 3600e3 + 1; const [bh, name] = [[1, 'Hour'], [6, '6 hours'], [24, 'Day']].find(([b]) => spanH / b <= 180) || [168, 'Week']; - const step = bh * 3600e3, from = Math.floor(start / step) * step, counts = []; + const utc = typeof getTimestampTimezone !== 'function' || getTimestampTimezone() === 'utc'; + const off = utc ? 0 : -new Date(start).getTimezoneOffset() * 60e3; + const step = bh * 3600e3, from = Math.floor((start + off) / step) * step - off, counts = []; for (let t = from; t <= last; t += step) counts.push(0); pph.forEach((h) => { const k = Math.floor((at(h) - from) / step); if (k >= 0) counts[k] += h.count; }); const every = [1, 3, 6, 12, 24, 48, 72, 168, 336, 672, 1344].find((e) => e >= bh && counts.length * bh / e <= 10) || 2688; + const zone = utc ? { timeZone: 'UTC' } : {}; + const date = new Intl.DateTimeFormat(undefined, { day: '2-digit', month: '2-digit', ...zone }); + const time = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit', ...zone }); const labels = counts.map((_, i) => { - const t = from + i * step, d = new Date(t), hh = d.getUTCHours(); - if ((t / 3600e3) % every) return ''; - return spanH > 48 && (every >= 24 || hh === 0) ? d.toISOString().slice(5, 10) : String(hh).padStart(2, '0') + 'h'; + const t = from + i * step, hrs = (t + off) / 3600e3; + if (hrs % every) return ''; + return spanH > 48 && (every >= 24 || hrs % 24 === 0) ? date.format(t) : time.format(t); }); - const since = from > first ? ` since ${new Date(from).toISOString().slice(5, 10)}` : ''; + const since = from > first ? ` since ${date.format(from)}` : ''; return { html: `

${icon} Packets / ${name}${since}

${barChart(counts, labels, 'var(--accent)')}`, from }; } diff --git a/tests/unit/test-issue-2193-packets-per-hour.js b/tests/unit/test-issue-2193-packets-per-hour.js index 58a69518e..2b0fab05d 100644 --- a/tests/unit/test-issue-2193-packets-per-hour.js +++ b/tests/unit/test-issue-2193-packets-per-hour.js @@ -93,8 +93,8 @@ c = card(quietStart, { window: '30d' }); assert(bars(c.html) === 91 && !/since/.test(c.html), 'in a picked window nothing is cropped: 91 daily bars (got ' + bars(c.html) + ')'); // Under "All data" a crop is said on the card, so it is never invisible. -c = card(hours(44 * 24, (k, back) => (back < 8 * 24 ? 300 + (k % 140) : k % 24 === 9 ? 1 + (k % 30) : 0)), { window: '' }); -assert(/since 10-04/.test(c.html), 'a cropped chart says where it starts (since 10-04)'); +c = cardIn('en-GB')(hours(44 * 24, (k, back) => (back < 8 * 24 ? 300 + (k % 140) : k % 24 === 9 ? 1 + (k % 30) : 0)), { window: '' }); +assert(/since 04\/10/.test(c.html), 'a cropped chart says where it starts, in the viewer\'s date format (since 04/10)'); c = card(hours(2932), { window: '' }); assert(!/since/.test(c.html), 'and an uncropped one says nothing');