Tik op een dag in de verwachtingsstrook voor een uuroverzicht van
temperatuur, regen en wind (richting + snelheid), om de 2 uur, 12
punten per dag. Sluit na 20 seconden vanzelf of bij een tik op de
overlay zelf.
Backend: Open-Meteo levert hourly-data in dezelfde call als daily
(geen extra verzoek, dezelfde 60min-cache). Nieuwe HourlyForecastMapper
groepeert per kalenderdatum en bemonstert om het andere uur, robuust
voor de zomertijddag (23/25 uur). OpenMeteoClient::dailyForecast()
hernoemd naar forecast(), geeft nu {daily, hourly} terug. Elke dag in
de forecast-array krijgt een hourly-lijst.
Frontend: temperatuurlijn hergebruikt sparklinePath (nu ook als
sparklinePoints voor exacte uitlijning met de 12-koloms grid), regen
als kleine staafjes, windpijl (nieuw icoon) wijst waarheen de wind
waait. 20s-balk via CSS transition met reflow-restart. Servicewerker
naar v5.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
190 lines
7.3 KiB
JavaScript
190 lines
7.3 KiB
JavaScript
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import {
|
||
f1, fInt, fmm, markerPos, barHeights, wmoIcon, trendLabel,
|
||
formatDate, formatTime, dayAbbr, monthName, isOlderThan, msUntil,
|
||
} from '../../public/format.js';
|
||
|
||
const TZ = 'Europe/Berlin';
|
||
|
||
test('f1 uses a decimal comma and one decimal', () => {
|
||
assert.equal(f1(16.4), '16,4');
|
||
assert.equal(f1(0), '0,0');
|
||
assert.equal(f1(19.75), '19,8');
|
||
assert.equal(f1(null), '–');
|
||
assert.equal(f1(undefined), '–');
|
||
});
|
||
|
||
test('fInt rounds to whole degrees', () => {
|
||
assert.equal(fInt(18.3), '18');
|
||
assert.equal(fInt(9.5), '10');
|
||
assert.equal(fInt(null), '–');
|
||
});
|
||
|
||
test('fmm shows "0 mm" for zero', () => {
|
||
assert.equal(fmm(0), '0 mm');
|
||
assert.equal(fmm(0.4), '0,4 mm');
|
||
assert.equal(fmm(8.25), '8,3 mm');
|
||
assert.equal(fmm(null), '–');
|
||
});
|
||
|
||
test('markerPos interpolates and clamps', () => {
|
||
assert.ok(Math.abs(markerPos(16.4, 9.2, 19.8) - 67.92) < 0.01);
|
||
assert.equal(markerPos(5, 9.2, 19.8), 0);
|
||
assert.equal(markerPos(25, 9.2, 19.8), 100);
|
||
assert.equal(markerPos(10, 10, 10), 50);
|
||
assert.equal(markerPos(null, 1, 2), 50);
|
||
});
|
||
|
||
test('barHeights scales to the tallest bar with a 2px floor', () => {
|
||
const h = barHeights([1.2, 6.8, 0, 2.4]);
|
||
assert.ok(Math.abs(h[0] - 12.35) < 0.01);
|
||
assert.equal(h[1], 70);
|
||
assert.equal(h[2], 2);
|
||
assert.ok(Math.abs(h[3] - 24.7) < 0.01);
|
||
assert.deepEqual(barHeights([0, 0, 0, 0]), [2, 2, 2, 2]);
|
||
assert.deepEqual(barHeights([null, 0, undefined, 0]), [2, 2, 2, 2]);
|
||
assert.deepEqual(barHeights([null, 5]), [2, 70]);
|
||
});
|
||
|
||
test('wmoIcon maps every documented code', () => {
|
||
assert.equal(wmoIcon(0), 'sun');
|
||
assert.equal(wmoIcon(1), 'partly');
|
||
assert.equal(wmoIcon(2), 'partly');
|
||
assert.equal(wmoIcon(3), 'cloud');
|
||
assert.equal(wmoIcon(45), 'fog');
|
||
assert.equal(wmoIcon(48), 'fog');
|
||
assert.equal(wmoIcon(51), 'drizzle');
|
||
assert.equal(wmoIcon(57), 'drizzle');
|
||
assert.equal(wmoIcon(61), 'rain');
|
||
assert.equal(wmoIcon(67), 'rain');
|
||
assert.equal(wmoIcon(80), 'showers');
|
||
assert.equal(wmoIcon(82), 'showers');
|
||
assert.equal(wmoIcon(71), 'snow');
|
||
assert.equal(wmoIcon(77), 'snow');
|
||
assert.equal(wmoIcon(85), 'snow');
|
||
assert.equal(wmoIcon(86), 'snow');
|
||
assert.equal(wmoIcon(95), 'thunder');
|
||
assert.equal(wmoIcon(99), 'thunder');
|
||
assert.equal(wmoIcon(42), 'cloud');
|
||
assert.equal(wmoIcon(null), 'cloud');
|
||
});
|
||
|
||
test('trendLabel', () => {
|
||
assert.equal(trendLabel('up'), '↗ stijgend');
|
||
assert.equal(trendLabel('down'), '↘ dalend');
|
||
assert.equal(trendLabel('stable'), '→ stabiel');
|
||
assert.equal(trendLabel(null), '');
|
||
});
|
||
|
||
test('formatDate gives a capitalised Dutch date in the station timezone', () => {
|
||
assert.equal(formatDate(new Date('2026-09-11T12:32:05Z'), TZ), 'Vrijdag 11 september');
|
||
// 23:30 UTC is al de volgende dag in Berlijn
|
||
assert.equal(formatDate(new Date('2026-09-11T23:30:00Z'), TZ), 'Zaterdag 12 september');
|
||
});
|
||
|
||
test('formatTime uses HH:MM in the station timezone', () => {
|
||
assert.equal(formatTime('2026-09-11T06:10:00+02:00', TZ), '06:10');
|
||
assert.equal(formatTime('2026-09-11T22:05:00Z', TZ), '00:05');
|
||
assert.equal(formatTime(null, TZ), '–');
|
||
assert.equal(formatTime('kapot', TZ), '–');
|
||
});
|
||
|
||
test('dayAbbr gives two-letter Dutch weekday', () => {
|
||
assert.equal(dayAbbr('2026-09-12', TZ), 'Za');
|
||
assert.equal(dayAbbr('2026-09-13', TZ), 'Zo');
|
||
assert.equal(dayAbbr('2026-09-14', TZ), 'Ma');
|
||
assert.equal(dayAbbr('2026-09-15', TZ), 'Di');
|
||
assert.equal(dayAbbr('2026-09-16', TZ), 'Wo');
|
||
assert.equal(dayAbbr('2026-09-17', TZ), 'Do');
|
||
assert.equal(dayAbbr('2026-09-18', TZ), 'Vr');
|
||
});
|
||
|
||
test('monthName', () => {
|
||
assert.equal(monthName(new Date('2026-09-11T12:00:00Z'), TZ), 'September');
|
||
});
|
||
|
||
test('isOlderThan', () => {
|
||
const now = new Date('2026-09-11T12:32:00Z');
|
||
assert.equal(isOlderThan('2026-09-11T12:10:00Z', now, 30), false);
|
||
assert.equal(isOlderThan('2026-09-11T11:50:00Z', now, 30), true);
|
||
assert.equal(isOlderThan(null, now, 30), true);
|
||
});
|
||
|
||
test('msUntil computes the wait until the next local time', () => {
|
||
const now = new Date('2026-09-11T12:32:05.500Z'); // 14:32:05 in Berlijn
|
||
assert.equal(msUntil(now, TZ, 0), ((24 - 14) * 3600 - 32 * 60 - 5) * 1000 - 500);
|
||
assert.equal(msUntil(now, TZ, 4), ((24 - 14 + 4) * 3600 - 32 * 60 - 5) * 1000 - 500);
|
||
assert.equal(msUntil(now, TZ, 15), (28 * 60 - 5) * 1000 - 500);
|
||
assert.equal(msUntil(new Date('2026-09-11T22:00:00.000Z'), TZ, 0), 86400000, 'precies op het moment zelf: pas morgen weer');
|
||
});
|
||
|
||
import {
|
||
co2Level, fPct, fHpa, seriesRange, sparklinePath, sparklinePoints,
|
||
windArrowDeg, fKmh, formatHour, formatFullDate,
|
||
} from '../../public/format.js';
|
||
|
||
test('co2Level uses the Netatmo thresholds', () => {
|
||
assert.deepEqual(co2Level(612), { level: 'good', label: 'goed' });
|
||
assert.deepEqual(co2Level(1000), { level: 'good', label: 'goed' });
|
||
assert.deepEqual(co2Level(1001), { level: 'medium', label: 'matig' });
|
||
assert.deepEqual(co2Level(1500), { level: 'medium', label: 'matig' });
|
||
assert.deepEqual(co2Level(1501), { level: 'bad', label: 'slecht' });
|
||
assert.deepEqual(co2Level(null), { level: 'unknown', label: '–' });
|
||
});
|
||
|
||
test('fPct and fHpa', () => {
|
||
assert.equal(fPct(48), '48%');
|
||
assert.equal(fPct(47.6), '48%');
|
||
assert.equal(fPct(null), '–');
|
||
assert.equal(fHpa(1014.2), '1014 hPa');
|
||
assert.equal(fHpa(null), '–');
|
||
});
|
||
|
||
test('seriesRange ignores nulls', () => {
|
||
assert.deepEqual(seriesRange([1010, null, 1015.5, 1008]), { min: 1008, max: 1015.5 });
|
||
assert.equal(seriesRange([null, undefined]), null);
|
||
});
|
||
|
||
test('sparklinePath scales points into the box', () => {
|
||
assert.equal(sparklinePath([1000, 1010, 1005], 100, 50, 0), 'M0.0,50.0 L50.0,0.0 L100.0,25.0');
|
||
assert.equal(sparklinePath([1000, 1010], 100, 50, 5), 'M5.0,45.0 L95.0,5.0');
|
||
assert.equal(sparklinePath([1010, 1010, 1010], 100, 50, 0), 'M0.0,25.0 L50.0,25.0 L100.0,25.0', 'vlakke reeks in het midden');
|
||
assert.equal(sparklinePath([1000, null, 1010], 100, 50, 0), 'M0.0,50.0 L100.0,0.0', 'null-punt overgeslagen');
|
||
assert.equal(sparklinePath([1000], 100, 50), '');
|
||
assert.equal(sparklinePath([], 100, 50), '');
|
||
});
|
||
|
||
test('sparklinePoints gives x for every index and null y for unknown values', () => {
|
||
const points = sparklinePoints([1000, null, 1010], 100, 50, 0);
|
||
assert.deepEqual(points.map((p) => p.x), [0, 50, 100]);
|
||
assert.equal(points[0].y, 50);
|
||
assert.equal(points[1].y, null);
|
||
assert.equal(points[2].y, 0);
|
||
});
|
||
|
||
test('windArrowDeg rotates 180 degrees so the arrow points where the wind blows to', () => {
|
||
assert.equal(windArrowDeg(0), 180);
|
||
assert.equal(windArrowDeg(270), 90);
|
||
assert.equal(windArrowDeg(200), 20);
|
||
assert.equal(windArrowDeg(null), null);
|
||
});
|
||
|
||
test('fKmh rounds to whole km/u', () => {
|
||
assert.equal(fKmh(8.4), '8 km/u');
|
||
assert.equal(fKmh(8.6), '9 km/u');
|
||
assert.equal(fKmh(null), '–');
|
||
});
|
||
|
||
test('formatHour gives a two-digit hour in the station timezone', () => {
|
||
assert.equal(formatHour('2026-09-14T00:00:00+02:00', TZ), '00');
|
||
assert.equal(formatHour('2026-09-14T14:00:00+02:00', TZ), '14');
|
||
assert.equal(formatHour('2026-09-13T23:00:00Z', TZ), '01', '23:00 UTC is 01:00 de volgende dag in Berlijn');
|
||
assert.equal(formatHour(null, TZ), '–');
|
||
});
|
||
|
||
test('formatFullDate matches formatDate for the same calendar date', () => {
|
||
assert.equal(formatFullDate('2026-09-14', TZ), 'Maandag 14 september');
|
||
assert.equal(formatFullDate('kapot', TZ), '–');
|
||
});
|