Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions public/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-trend-up"/></svg>';
if (!pph || !pph.length) return { html: `<h3>${icon} Packets / Hour</h3><p class="text-muted">No packets in this period.</p>`, from: null };
Expand All @@ -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 ? ` <span class="text-muted">since ${new Date(from).toISOString().slice(5, 10)}</span>` : '';
const since = from > first ? ` <span class="text-muted">since ${date.format(from)}</span>` : '';
return { html: `<h3>${icon} Packets / ${name}${since}</h3>${barChart(counts, labels, 'var(--accent)')}`, from };
}

Expand Down
29 changes: 25 additions & 4 deletions tests/unit/test-issue-2193-packets-per-hour.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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); }

Expand Down Expand Up @@ -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(/<text [^>]*font-size="9"[^>]*>([^<]+)</g)].map((m) => 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);