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 b85037702..2b0fab05d 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); }
@@ -79,10 +93,17 @@ 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');
+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);