From 1cc485c328ec014fd5ad267e0188d6a51a143a61 Mon Sep 17 00:00:00 2001 From: R0ck Date: Mon, 12 Oct 2026 21:01:34 +0100 Subject: [PATCH 1/6] =?UTF-8?q?test(analytics):=20red=20=E2=80=94=20Packet?= =?UTF-8?q?s=20/=20Hour=20readable=20on=20long=20histories=20(#2193)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Months of hours should roll up to daily bars with a handful of labels, missing hours count as zero, and the chart should start where the data does rather than at the few old packets a short store keeps. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --- test-all.sh | 1 + .../unit/test-issue-2193-packets-per-hour.js | 74 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 tests/unit/test-issue-2193-packets-per-hour.js diff --git a/test-all.sh b/test-all.sh index 2b17ee0a7..e21ec8230 100755 --- a/test-all.sh +++ b/test-all.sh @@ -122,6 +122,7 @@ node tests/unit/test-issue-1668-m3-typography.js node tests/unit/test-issue-1668-m4-per-route.js node tests/unit/test-issue-1697-mqtt-mobile-e2e.js node tests/unit/test-issue-1699-retransmission-chart.js +node tests/unit/test-issue-2193-packets-per-hour.js node tests/unit/test-issue-1705-subpath-contrast.js node tests/unit/test-issue-1753-copy-url-slash.js node tests/unit/test-issue-1770-mobile-row-clamp.js diff --git a/tests/unit/test-issue-2193-packets-per-hour.js b/tests/unit/test-issue-2193-packets-per-hour.js new file mode 100644 index 000000000..3a1451a07 --- /dev/null +++ b/tests/unit/test-issue-2193-packets-per-hour.js @@ -0,0 +1,74 @@ +/** + * #2193: the Packets / Hour chart on long-retention instances. + * + * 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. + * + * Usage: node tests/unit/test-issue-2193-packets-per-hour.js + */ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +let passed = 0, failed = 0; +function assert(cond, msg) { + if (cond) { passed++; console.log(' ✓ ' + msg); } + else { failed++; console.error(' ✗ ' + msg); } +} + +const ctx = { + console, Math, JSON, Date, Number, String, Array, Object, Set, Map, RegExp, URLSearchParams, + setTimeout, clearTimeout, requestAnimationFrame: () => 0, + getComputedStyle: () => ({ getPropertyValue: () => '' }), + localStorage: { getItem: () => null, setItem() {}, removeItem() {} }, + registerPage: () => {}, api: async () => ({}), CLIENT_TTL: {}, + RegionFilter: { regionQueryString: () => '' }, AreaFilter: { areaQueryString: () => '' }, +}; +ctx.window = ctx; +ctx.document = { + documentElement: {}, createElement: () => ({ style: {}, addEventListener() {} }), + addEventListener() {}, removeEventListener() {}, querySelector: () => null, querySelectorAll: () => [], getElementById: () => null, +}; +vm.createContext(ctx); +vm.runInContext(fs.readFileSync(path.join(__dirname, '..', '..', 'public/analytics.js'), 'utf8'), ctx); +const card = ctx._analyticsPacketsPerHourCard; +if (typeof card !== 'function') { console.error(' ✗ _analyticsPacketsPerHourCard not exposed'); process.exit(1); } + +// n hours ending 2026-10-12T17; count(k) gives each hour's count (0 = left +// out, the way the server sends empty hours). +const END = Date.UTC(2026, 9, 12, 17); +function hours(n, count) { + const out = []; + for (let k = 0; k < n; k++) { + const c = count ? count(k, n - 1 - k) : 20 + (k % 24); + if (c) out.push({ hour: new Date(END - (n - 1 - k) * 3600e3).toISOString().slice(0, 13), count: c }); + } + return out; +} +const bars = (html) => (html.match(/ (html.match(/]*font-size="9"/g) || []).length; + +let c = card(hours(2932)); +assert(/Packets \/ Day/.test(c.html) && bars(c.html) === 123, '122 days: 123 daily bars, titled Packets / Day (got ' + bars(c.html) + ')'); +assert(labels(c.html) >= 4 && labels(c.html) <= 12, 'with 4 to 12 labels (got ' + labels(c.html) + ')'); + +c = card(hours(168)); +assert(/Packets \/ Hour/.test(c.html) && bars(c.html) === 168, '7 days: 168 hourly bars (got ' + bars(c.html) + ')'); + +c = card(hours(48, (k) => (k >= 10 && k < 15 ? 0 : 5))); +assert(bars(c.html) === 48, 'five missing hours drawn as zero: 48 bars (got ' + bars(c.html) + ')'); + +// A 7-day store: weeks of a few old packets a day, then 8 days of data. +c = card(hours(44 * 24, (k, back) => (back < 8 * 24 ? 300 + (k % 140) : k % 24 === 9 ? 1 + (k % 30) : 0))); +assert(c.from === Date.UTC(2026, 9, 4, 18), 'starts on the hour the data does, not at the first straggler (from ' + new Date(c.from || 0).toISOString() + ')'); + +c = card(hours(60 * 24, (k) => (k >= 240 && k < 360 ? 0 : 50))); +assert(c.from === Date.UTC(2026, 7, 13), 'an outage after the data starts is not taken for its start (from ' + new Date(c.from || 0).toISOString() + ')'); + +assert(/No packets/.test(card([]).html), 'no data says so'); + +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed > 0 ? 1 : 0); From e7a0a992531a7cfdae20fe81a1c5e97d73fc68d2 Mon Sep 17 00:00:00 2001 From: R0ck Date: Mon, 12 Oct 2026 21:02:29 +0100 Subject: [PATCH 2/6] fix(analytics): Packets / Hour readable on long histories (#2193) A long history now rolls up to coarser bars (hourly, 6-hourly, daily or weekly; at most 180) with about 10 labels on round UTC times, missing hours count as zero, and the title follows the bucket (Packets / Day). The chart starts where the data does: a short store also keeps a few old packets that were heard again, weeks before the rest. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --- public/analytics.js | 54 +++++++++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/public/analytics.js b/public/analytics.js index 7851bdb2c..8e2810b02 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -71,6 +71,36 @@ return `Sparkline showing trend of ${data.length} data points`; } + // Packets / Hour (#2193): a long history rolls up to coarser bars (at most + // 180, so about 4 units each in barChart's plot) with about 10 labels, and + // the chart starts on the first UTC day with at least 2% of a typical recent + // day (the median of the last seven whole days), at its first hour with a + // tenth of a typical hour: a short store also keeps a few old packets that + // were heard again, weeks before the rest. + function packetsPerHourCard(pph) { + const icon = ''; + if (!pph || !pph.length) return { html: `

${icon} Packets / Hour

No packets in this period.

`, from: null }; + const at = (h) => Date.parse(h.hour.replace(' ', 'T') + ':00:00Z'); + const days = new Map(); + pph.forEach((h) => days.set(h.hour.slice(0, 10), (days.get(h.hour.slice(0, 10)) || 0) + h.count)); + const recent = [...days.values()].slice(-8, -1).sort((a, b) => a - b); + const floor = (recent.length ? recent[recent.length >> 1] : 0) * 0.02; + const day = [...days.keys()].find((d) => days.get(d) >= floor); + const start = 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 = []; + 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 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'; + }); + return { html: `

${icon} Packets / ${name}

${barChart(counts, labels, 'var(--accent)')}`, from }; + } + function barChart(data, labels, colors, w = 800, h = 220, pad = 40) { const max = Math.max(...data, 1); const barW = Math.max(1, Math.min((w - pad * 2) / data.length - 2, 30)); @@ -405,28 +435,7 @@
-
-

Packets / Hour

- ${(() => { - const pph = rf.packetsPerHour; - const counts = pph.map(h => h.count); - // Decimate x-axis labels to avoid overlap - const totalHours = pph.length; - // Pick label interval: <=24h show every 6h, <=72h every 12h, else every 24h - const labelInterval = totalHours <= 24 ? 6 : totalHours <= 72 ? 12 : 24; - const labels = pph.map((h, i) => { - const hh = h.hour.slice(11, 13); // "HH" - const hourNum = parseInt(hh, 10); - if (hourNum % labelInterval === 0) { - // For multi-day ranges, show date on 00h boundaries - if (totalHours > 48 && hourNum === 0) return h.hour.slice(5, 10); - return hh + 'h'; - } - return ''; // skip label - }); - return barChart(counts, labels, 'var(--accent)'); - })()} -
+
${packetsPerHourCard(rf.packetsPerHour).html}
@@ -3184,6 +3193,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet window._analyticsRetransmissionBucketFor = retransmissionBucketFor; window._analyticsRenderRetransmissionChart = renderRetransmissionChart; window._analyticsScopeAdvertsByRoleHtml = scopeAdvertsByRoleHtml; + window._analyticsPacketsPerHourCard = packetsPerHourCard; } // ─── Neighbor Graph Tab ───────────────────────────────────────────────────── From 987891315c2542a6e8739f6ff9d8c341dd355926 Mon Sep 17 00:00:00 2001 From: R0ck Date: Mon, 12 Oct 2026 21:03:38 +0100 Subject: [PATCH 3/6] fix(analytics): skip a theme-refresh that lands before the data Every load fires a theme-refresh about 300ms after the theme resolves, and the page re-rendered the current tab on it. On a slow or just-restarted server the analytics answers land later, so the Overview was drawn from the empty data object and threw (reading 'totalTransmissions'). loadAnalytics() renders the tab, with the current theme, once the answers land, so the early refresh has nothing to do. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --- public/analytics.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/public/analytics.js b/public/analytics.js index 8e2810b02..9f8bdee63 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -290,6 +290,9 @@ // from window.ROLE_COLORS, role swatches use CSS tokens, and the one // cached value (_labelColor) is re-read on restart. if (_currentTab === 'neighbor-graph' && _ngState) { startGraphRenderer(); return; } + // Before the answers land there is nothing to re-render; loadAnalytics() + // renders the tab with the current theme when they do. + if (!_analyticsData.rfData) return; renderTab(_currentTab); }; window.addEventListener('theme-refresh', _themeRefreshHandler); From 3fa23c349ef2513d1c436b6c298917bf5a80b382 Mon Sep 17 00:00:00 2001 From: R0ck Date: Thu, 15 Oct 2026 10:19:03 +0100 Subject: [PATCH 4/6] test(analytics): a theme-refresh before the data lands leaves the tab alone Regression test for 98789131. Drives init() with answers that never land, fires the captured theme-refresh handler, and checks that nothing throws and the tab is not re-rendered. Without the guard it fails with the production error (reading 'totalTransmissions'). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --- test-all.sh | 1 + ...est-analytics-theme-refresh-before-data.js | 76 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 tests/unit/test-analytics-theme-refresh-before-data.js diff --git a/test-all.sh b/test-all.sh index e21ec8230..88e744621 100755 --- a/test-all.sh +++ b/test-all.sh @@ -123,6 +123,7 @@ node tests/unit/test-issue-1668-m4-per-route.js node tests/unit/test-issue-1697-mqtt-mobile-e2e.js node tests/unit/test-issue-1699-retransmission-chart.js node tests/unit/test-issue-2193-packets-per-hour.js +node tests/unit/test-analytics-theme-refresh-before-data.js node tests/unit/test-issue-1705-subpath-contrast.js node tests/unit/test-issue-1753-copy-url-slash.js node tests/unit/test-issue-1770-mobile-row-clamp.js diff --git a/tests/unit/test-analytics-theme-refresh-before-data.js b/tests/unit/test-analytics-theme-refresh-before-data.js new file mode 100644 index 000000000..e3e26c785 --- /dev/null +++ b/tests/unit/test-analytics-theme-refresh-before-data.js @@ -0,0 +1,76 @@ +/** + * Analytics: a theme-refresh that fires before the analytics answers land. + * + * Every page load fires a theme-refresh about 300ms after the theme resolves. + * On a slow server the answers land later, and re-rendering the Overview from + * the empty data object threw (reading 'totalTransmissions'). The handler + * must leave the tab alone until loadAnalytics() renders it. + * + * Usage: node tests/unit/test-analytics-theme-refresh-before-data.js + */ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +let passed = 0, failed = 0; +function assert(cond, msg) { + if (cond) { passed++; console.log(' ✓ ' + msg); } + else { failed++; console.error(' ✗ ' + msg); } +} + +function el() { + return { + style: {}, dataset: {}, value: '', innerHTML: '', + classList: { add() {}, remove() {}, toggle() {}, contains: () => false }, + addEventListener() {}, removeEventListener() {}, + querySelector: () => null, querySelectorAll: () => [], + }; +} +const els = {}; +let contentLookups = 0; +const listeners = {}; +const ctx = { + console, Math, JSON, Date, Number, String, Array, Object, Set, Map, RegExp, URLSearchParams, Promise, + setTimeout, clearTimeout, requestAnimationFrame: () => 0, + getComputedStyle: () => ({ getPropertyValue: () => '' }), + localStorage: { getItem: () => null, setItem() {}, removeItem() {} }, + location: { hash: '#/analytics' }, + addEventListener: (type, fn) => { listeners[type] = fn; }, + removeEventListener() {}, + initTabBar() {}, CLIENT_TTL: {}, + // The answers never land: the refresh below fires while they are pending. + api: () => new Promise(() => {}), + RegionFilter: { init() {}, onChange() {}, regionQueryString: () => '' }, + AreaFilter: { init() {}, onChange() {}, areaQueryString: () => '' }, +}; +let page = null; +ctx.registerPage = (name, p) => { if (name === 'analytics') page = p; }; +ctx.window = ctx; +ctx.document = { + documentElement: {}, createElement: el, + addEventListener() {}, removeEventListener() {}, querySelector: () => null, querySelectorAll: () => [], + getElementById: (id) => { + if (id === 'analyticsContent') contentLookups++; + return els[id] || (els[id] = el()); + }, +}; +vm.createContext(ctx); +vm.runInContext(fs.readFileSync(path.join(__dirname, '..', '..', 'public/analytics.js'), 'utf8'), ctx); + +const rejections = []; +process.on('unhandledRejection', (e) => rejections.push(e)); + +(async () => { + await page.init(el()); + const refresh = listeners['theme-refresh']; + assert(typeof refresh === 'function', 'init registers a theme-refresh handler'); + const before = contentLookups; + let threw = null; + try { refresh(); } catch (e) { threw = e; } + await new Promise((r) => setTimeout(r, 20)); + assert(!threw && rejections.length === 0, 'a refresh before the data lands does not throw (' + (threw || rejections[0] || 'ok') + ')'); + assert(contentLookups === before, 'and does not re-render the tab'); + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +})(); From 35df4dd082f96db10be3ddc54d24d36bdf4256f5 Mon Sep 17 00:00:00 2001 From: R0ck Date: Thu, 15 Oct 2026 10:19:26 +0100 Subject: [PATCH 5/6] =?UTF-8?q?test(analytics):=20red=20=E2=80=94=20Packet?= =?UTF-8?q?s=20/=20Hour=20crops=20only=20under=20"All=20data",=20and=20say?= =?UTF-8?q?s=20so?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review on #2194: the start crop also cut a genuinely quieter early stretch inside a window the user picked (90 days, the first 30 quiet: 30 days hidden), and nothing on the card said so. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --- tests/unit/test-issue-2193-packets-per-hour.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/unit/test-issue-2193-packets-per-hour.js b/tests/unit/test-issue-2193-packets-per-hour.js index 3a1451a07..b85037702 100644 --- a/tests/unit/test-issue-2193-packets-per-hour.js +++ b/tests/unit/test-issue-2193-packets-per-hour.js @@ -70,5 +70,19 @@ assert(c.from === Date.UTC(2026, 7, 13), 'an outage after the data starts is not assert(/No packets/.test(card([]).html), 'no data says so'); +// The crop is for "All data", where a short store's stragglers sit weeks +// before the rest. In a window the user picked, the server has already cut +// to it, so a quieter early stretch is real and stays: 90 days, the first +// 30 at 1 packet an hour, then 100 an hour. +const quietStart = hours(90 * 24, (k) => (k < 30 * 24 ? 1 : 100)); +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 = card(hours(2932), { window: '' }); +assert(!/since/.test(c.html), 'and an uncropped one says nothing'); + console.log(`\n${passed} passed, ${failed} failed`); process.exit(failed > 0 ? 1 : 0); From 3dd90ed4169991523ecad0c03273f94d6043be5d Mon Sep 17 00:00:00 2001 From: R0ck Date: Thu, 15 Oct 2026 10:20:07 +0100 Subject: [PATCH 6/6] fix(analytics): Packets / Hour crops only under "All data", and says so In a picked window the server has already cut the data to it, so a quieter early stretch is real: the chart now starts at the window's first hour. Under "All data" the straggler crop stays, and the card title says "since MM-DD" when it applies. The renderer gets the window the data was fetched with from _analyticsData. Also corrects the comment on which seven days set the floor. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --- public/analytics.js | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/public/analytics.js b/public/analytics.js index 9f8bdee63..b69b451f4 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -72,12 +72,14 @@ } // Packets / Hour (#2193): a long history rolls up to coarser bars (at most - // 180, so about 4 units each in barChart's plot) with about 10 labels, and - // the chart starts on the first UTC day with at least 2% of a typical recent - // day (the median of the last seven whole days), at its first hour with a - // tenth of a typical hour: a short store also keeps a few old packets that - // were heard again, weeks before the rest. - function packetsPerHourCard(pph) { + // 180, so about 4 units each in barChart's plot) with about 10 labels. + // Under "All data" (no opts.window) the chart starts on the first UTC day + // with at least 2% of a typical recent day (the median of the seven days + // before the last one), at its first hour with a tenth of a typical hour: a + // 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. + function packetsPerHourCard(pph, opts) { const icon = ''; if (!pph || !pph.length) return { html: `

${icon} Packets / Hour

No packets in this period.

`, from: null }; const at = (h) => Date.parse(h.hour.replace(' ', 'T') + ':00:00Z'); @@ -86,7 +88,8 @@ const recent = [...days.values()].slice(-8, -1).sort((a, b) => a - b); const floor = (recent.length ? recent[recent.length >> 1] : 0) * 0.02; const day = [...days.keys()].find((d) => days.get(d) >= floor); - const start = at(pph.find((h) => h.hour.slice(0, 10) >= day && h.count >= floor * 5 / 24) || pph[0]); + const first = at(pph[0]); + 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 = []; @@ -98,7 +101,8 @@ if ((t / 3600e3) % every) return ''; return spanH > 48 && (every >= 24 || hh === 0) ? d.toISOString().slice(5, 10) : String(hh).padStart(2, '0') + 'h'; }); - return { html: `

${icon} Packets / ${name}

${barChart(counts, labels, 'var(--accent)')}`, from }; + const since = from > first ? ` since ${new Date(from).toISOString().slice(5, 10)}` : ''; + return { html: `

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

${barChart(counts, labels, 'var(--accent)')}`, from }; } function barChart(data, labels, colors, w = 800, h = 220, pad = 40) { @@ -332,7 +336,7 @@ api('/analytics/hash-collisions' + sepBase, { ttl: CLIENT_TTL.analyticsRF }), api('/analytics/relay-airtime-share' + sepWin, { ttl: CLIENT_TTL.analyticsRF }).catch(() => ({ rows: [] })), ]); - _analyticsData = { hashData, rfData, topoData, chanData, collisionData, airtimeData }; + _analyticsData = { hashData, rfData, topoData, chanData, collisionData, airtimeData, window: twVal }; renderTab(_currentTab); } catch (e) { document.getElementById('analyticsContent').innerHTML = @@ -438,7 +442,7 @@
-
${packetsPerHourCard(rf.packetsPerHour).html}
+
${packetsPerHourCard(rf.packetsPerHour, { window: d.window }).html}