diff --git a/docs/weerscherm-spec.md b/docs/weerscherm-spec.md index 5509c7a..29c98d0 100644 --- a/docs/weerscherm-spec.md +++ b/docs/weerscherm-spec.md @@ -257,6 +257,12 @@ De iconen `sun`, `partly`, `rain` en `showers` staan in de mockup. Maak `cloud`, - Daarnaast 4 staafjes: de 3 vorige dagen (labels "Di", "Wo", "Do") en vandaag, uitgelicht. De hoogte is `mm / max(4 waarden) × 70px`, met een minimum van 2px. Zijn alle waarden 0, dan zijn alle staafjes 2px. - Daaronder "September 38,5 mm". - **Verwachting (rechtsonder):** 6 kolommen met dagafkorting ("Za"), icoon (50px), "max° min°" als hele getallen (min in `--muted`) en "x,x mm" plus neerslagkans in %. Geen neerslag toont "0 mm". Eerste kolom is vandaag. + _Toegevoegd 2026-09-13: elke dagkolom is tikbaar. Een tik opent een uuroverzicht voor die dag (temperatuur, + regen en wind, elk om de 2 uur — 12 punten), dat na 20 seconden vanzelf sluit of eerder bij een tik erop. + De uurdata komt in dezelfde Open-Meteo-call mee (`hourly=temperature_2m,precipitation,wind_speed_10m, + wind_direction_10m`), dus geen extra netwerkverzoek bij het openen. Windpijl wijst waarheen de wind waait + (richting + 180°). Zie `src/HourlyForecastMapper.php` en `renderHourlyCharts()`/`openHourOverlay()` in + `public/app.js`._ ### 6.4 Verversen @@ -344,6 +350,7 @@ Hangt af van de iPadOS-versie in §0: - [ ] Op de iPad mini, gestart vanaf het beginscherm, draait de app fullscreen zonder Safari-balken en klopt de layout visueel met ontwerp A. - [ ] Alle waarden komen overeen met de Netatmo-app: buiten, 3× binnen, min/max met tijdstippen, regen vandaag en de afgelopen 3 dagen, en het maandtotaal. - [ ] De verwachting toont 6 dagen (vandaag t/m 5 dagen verder) met het juiste icoon per WMO-code. +- [ ] Tikken op een dag opent het uuroverzicht (temperatuur, regen, wind) en sluit na 20 seconden vanzelf of bij een tik erop. - [ ] De data ververst zichzelf. Na middernacht staat de nieuwe datum er binnen 5 minuten. - [ ] Wifi uit: de laatste data blijft staan, met een melding "geen verbinding sinds". Wifi aan: herstelt vanzelf. - [ ] De Netatmo-token ververst meerdere dagen achter elkaar zonder handmatig ingrijpen. diff --git a/public/app.js b/public/app.js index 26e31c0..903dfe3 100644 --- a/public/app.js +++ b/public/app.js @@ -2,8 +2,9 @@ import { f1, fInt, fmm, markerPos, barHeights, wmoIcon, trendLabel, formatDate, formatTime, dayAbbr, monthName, isOlderThan, msUntil, co2Level, fPct, fHpa, seriesRange, sparklinePath, + windArrowDeg, fKmh, formatHour, formatFullDate, } from './format.js'; -import { icon } from './icons.js'; +import { icon, windArrow } from './icons.js'; const CANVAS_W = 1133; const CANVAS_H = 744; @@ -13,6 +14,8 @@ const FETCH_TIMEOUT_MS = 10000; const STALE_MINUTES = 30; const RELOAD_HOUR = 4; const FALLBACK_TZ = 'Europe/Berlin'; +const HOURLY_OVERLAY_MS = 20000; +const HOURLY_WIDTH = 1045; // = stage-breedte 1133 min 2x 44px padding, zelfde marge als de rest van het scherm const $ = (id) => document.getElementById(id); const stage = $('stage'); @@ -104,7 +107,7 @@ function renderPressure(p) { } function renderForecast(days) { - $('forecast').innerHTML = days.map((d) => `
+ $('forecast').innerHTML = days.map((d) => `

${dayAbbr(d.date, state.tz)}

${icon(wmoIcon(d.weather_code))}

${fInt(d.temp_max)}°${fInt(d.temp_min)}°

@@ -112,6 +115,56 @@ function renderForecast(days) {
`).join(''); } +/* ---------- uuroverzicht per dag ---------- */ + +let hourlyOverlayTimer = null; + +function renderHourlyCharts(hourly) { + const temps = hourly.map((h) => h.temp); + $('ho-temp-path').setAttribute('d', sparklinePath(temps, HOURLY_WIDTH, 64, HOURLY_WIDTH / 24)); + $('ho-temp-nums').innerHTML = hourly.map((h) => `${fInt(h.temp)}°`).join(''); + + const heights = barHeights(hourly.map((h) => h.precip_mm), 40, 2); + $('ho-rain').innerHTML = hourly.map((h, i) => `
+ ${fmm(h.precip_mm)} +
`).join(''); + + $('ho-wind').innerHTML = hourly.map((h) => { + const deg = windArrowDeg(h.wind_dir); + const arrow = deg === null + ? '–' + : `${windArrow()}`; + return `
${arrow}${fKmh(h.wind_kmh)}
`; + }).join(''); + + $('ho-hours').innerHTML = hourly.map((h) => `${formatHour(h.at, state.tz)}`).join(''); +} + +function openHourOverlay(date) { + const day = (state.data?.forecast || []).find((d) => d.date === date); + if (!day) return; + + clearTimeout(hourlyOverlayTimer); + setText('ho-date', formatFullDate(day.date, state.tz)); + renderHourlyCharts(day.hourly || []); + + const fill = $('ho-bar-fill'); + fill.style.transition = 'none'; + fill.style.transform = 'scaleX(1)'; + fill.getBoundingClientRect(); // forceer reflow zodat de volgende transitie opnieuw start + fill.style.transition = `transform ${HOURLY_OVERLAY_MS}ms linear`; + fill.style.transform = 'scaleX(0)'; + + $('hour-overlay').hidden = false; + hourlyOverlayTimer = setTimeout(closeHourOverlay, HOURLY_OVERLAY_MS); +} + +function closeHourOverlay() { + clearTimeout(hourlyOverlayTimer); + hourlyOverlayTimer = null; + $('hour-overlay').hidden = true; +} + function renderBanner() { const banner = $('banner'); const errors = state.data ? state.data.errors : []; @@ -259,6 +312,12 @@ function checkForUpdate() { /* ---------- start ---------- */ +$('forecast').addEventListener('click', (e) => { + const day = e.target.closest('.a-day'); + if (day) openHourOverlay(day.dataset.date); +}); +$('hour-overlay').addEventListener('click', closeHourOverlay); + window.addEventListener('resize', fit); window.addEventListener('orientationchange', () => setTimeout(fit, 50)); document.addEventListener('visibilitychange', () => { diff --git a/public/format.js b/public/format.js index 28fbc1f..d823be5 100644 --- a/public/format.js +++ b/public/format.js @@ -148,23 +148,60 @@ export function seriesRange(values) { return { min: Math.min(...nums), max: Math.max(...nums) }; } +/** + * Coördinaten van een reeks binnen een box van w×h met `pad` px marge, geschaald op min..max. + * Een vlakke reeks komt op het midden. `y` is null voor een onbekende waarde; `x` blijft altijd gezet + * zodat labels/staafjes/pijlen op dezelfde plek als de lijn kunnen worden gepositioneerd. + */ +export function sparklinePoints(values, w, h, pad = 3) { + const range = seriesRange(values); + const innerW = w - 2 * pad; + const innerH = h - 2 * pad; + const step = values.length > 1 ? innerW / (values.length - 1) : 0; + const span = range ? range.max - range.min : 0; + return values.map((v, i) => { + const x = pad + i * step; + if (!isNum(v) || !range) return { x, y: null }; + const y = span === 0 ? h / 2 : pad + innerH - ((v - range.min) / span) * innerH; + return { x, y }; + }); +} + /** * SVG-pad voor een sparkline: punten gelijkmatig over de breedte, waarden geschaald op min..max * met `pad` px marge. Een vlakke reeks wordt een lijn door het midden. Minder dan 2 punten -> "". */ export function sparklinePath(values, w, h, pad = 3) { - const range = seriesRange(values); - if (!range || values.length < 2) return ''; - const span = range.max - range.min; - const innerW = w - 2 * pad; - const innerH = h - 2 * pad; - const step = innerW / (values.length - 1); + if (values.length < 2) return ''; const parts = []; - values.forEach((v, i) => { - if (!isNum(v)) return; - const x = pad + i * step; - const y = span === 0 ? h / 2 : pad + innerH - ((v - range.min) / span) * innerH; + sparklinePoints(values, w, h, pad).forEach(({ x, y }) => { + if (y === null) return; parts.push(`${parts.length === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`); }); return parts.join(' '); } + +/** Windrichting (meteorologisch: vanwaar de wind komt) -> rotatie zodat de pijl waarheen wijst. */ +export function windArrowDeg(dir) { + return isNum(dir) ? (dir + 180) % 360 : null; +} + +/** 8.4 -> "8 km/u"; null -> "–" */ +export function fKmh(v) { + return isNum(v) ? `${Math.round(v)} km/u` : DASH; +} + +/** ISO-tijdstip -> "14" (tweecijferig uur) in de tijdzone; null -> "–" */ +export function formatHour(iso, tz) { + const d = iso instanceof Date ? iso : new Date(iso); + if (!iso || Number.isNaN(d.getTime())) return DASH; + const parts = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', hourCycle: 'h23', timeZone: tz }).formatToParts(d); + return parts.find((p) => p.type === 'hour').value; +} + +/** "2026-09-14" -> "Maandag 14 september" in de tijdzone (net als formatDate, maar vanuit een datumstring). */ +export function formatFullDate(dateStr, tz) { + const d = new Date(dateStr + 'T12:00:00Z'); + if (Number.isNaN(d.getTime())) return DASH; + return formatDate(d, tz); +} diff --git a/public/icons.js b/public/icons.js index 256fedb..6c61e53 100644 --- a/public/icons.js +++ b/public/icons.js @@ -30,3 +30,8 @@ export function icon(name) { const inner = ICONS[name] || ICONS.cloud; return ``; } + +/** Compacte windpijl, wijst standaard naar boven. Draai 'm met een inline transform naar de juiste hoek. */ +export function windArrow() { + return ''; +} diff --git a/public/index.html b/public/index.html index 150fb52..bd54daa 100644 --- a/public/index.html +++ b/public/index.html @@ -62,6 +62,23 @@ + +
Draai de iPad een kwartslag
diff --git a/public/styles.css b/public/styles.css index 9c7fbee..05c08ad 100644 --- a/public/styles.css +++ b/public/styles.css @@ -62,7 +62,7 @@ html, body { .warm { color: var(--warm); } .muted { color: var(--muted); } -.ic { fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; width: 100%; height: 100%; display: block; } +.ic, .wind-ic { fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; width: 100%; height: 100%; display: block; } .i-sun { stroke: var(--sun); } .i-drop { stroke: var(--rain); } .i-cloud { fill: var(--iconfill); } @@ -162,6 +162,50 @@ html, body { } .banner small { display: block; margin-top: 16px; font-size: 20px; color: var(--muted); } +/* Uuroverzicht per dag: tik op een dagkolom om te openen, tik erop of wacht 20s om te sluiten. */ +.hour-overlay { + position: absolute; + left: 44px; + right: 44px; + top: 40px; + bottom: 40px; + display: flex; + flex-direction: column; + gap: 22px; + padding: 26px 0 22px; + background: var(--bg); + border: 1px solid var(--line); + border-radius: 18px; + box-shadow: 0 12px 32px rgba(27, 42, 53, .12); + z-index: 5; +} +.hour-overlay[hidden] { display: none; } + +.ho-bar { margin: 0 30px; height: 4px; border-radius: 2px; background: var(--line); overflow: hidden; } +.ho-bar i { display: block; width: 100%; height: 100%; background: var(--rain); transform-origin: left; } + +.ho-head { margin: 0 30px; display: flex; justify-content: space-between; align-items: baseline; } +.ho-date { font-size: 22px; font-weight: 500; } +.ho-hint { font-size: 16px; color: var(--muted); } + +.ho-grid { display: grid; grid-template-columns: repeat(12, 1fr); text-align: center; } + +.ho-temp { display: flex; flex-direction: column; gap: 6px; } +#ho-temp-nums .num { font-size: 22px; font-weight: 500; } +.ho-temp-svg { display: block; width: 100%; height: 64px; } +.ho-temp-line { fill: none; stroke: var(--ink); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; } + +.ho-rain .ho-rbar { display: flex; flex-direction: column; align-items: center; justify-content: flex-end; gap: 6px; height: 66px; } +.ho-rbar i { display: block; width: 14px; border-radius: 3px 3px 0 0; background: var(--rain); opacity: .55; } +.ho-rbar .num { font-size: 14px; color: var(--muted); } + +.ho-wind .ho-wcell { display: flex; flex-direction: column; align-items: center; gap: 6px; } +.ho-warrow { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; color: var(--muted); } +.ho-warrow.off { font-size: 18px; } +.ho-wcell .num { font-size: 15px; color: var(--muted); } + +.ho-hours span { font-size: 16px; color: var(--muted); } + #rotate { display: none; position: fixed; diff --git a/public/sw.js b/public/sw.js index c90ef8b..042f5d9 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,6 +1,6 @@ /* Service worker: app-shell cache-first, API network-first met timeout en fallback op de laatste response. */ -const VERSION = 'weerscherm-v4'; +const VERSION = 'weerscherm-v5'; const SHELL = [ './', './index.html', diff --git a/src/DashboardService.php b/src/DashboardService.php index 271194a..0032b69 100644 --- a/src/DashboardService.php +++ b/src/DashboardService.php @@ -122,11 +122,19 @@ final class DashboardService $this->addError(self::ERR_FORECAST); return ['days' => [], 'stale' => true]; } - $daily = $this->fetch('forecast', fn() => $this->source->forecast($lat, $lon, $tz->getName()), self::ERR_FORECAST); - return [ - 'days' => $daily['data'] === null ? [] : ForecastMapper::map($daily['data']), - 'stale' => $daily['stale'], - ]; + $result = $this->fetch('forecast', fn() => $this->source->forecast($lat, $lon, $tz->getName()), self::ERR_FORECAST); + if ($result['data'] === null) { + return ['days' => [], 'stale' => $result['stale']]; + } + + $days = ForecastMapper::map($result['data']['daily'] ?? []); + $hourlyByDate = HourlyForecastMapper::mapByDate($result['data']['hourly'] ?? [], $tz); + foreach ($days as &$day) { + $day['hourly'] = $hourlyByDate[$day['date']] ?? []; + } + unset($day); + + return ['days' => $days, 'stale' => $result['stale']]; } /** diff --git a/src/FixtureSource.php b/src/FixtureSource.php index 6ee8f5e..304aee7 100644 --- a/src/FixtureSource.php +++ b/src/FixtureSource.php @@ -69,17 +69,46 @@ final class FixtureSource implements WeatherSource public function forecast(float $lat, float $lon, string $timezone): array { + $tmin = [11.0, 10.1, 11.3, 12.0, 9.4, 8.1]; + $tmax = [17.9, 18.3, 20.1, 17.4, 15.2, 16.0]; + $precipDay = [0.0, 0.4, 0.0, 8.2, 3.1, 0.2]; + $dates = []; - for ($i = 0; $i < 6; $i++) { - $dates[] = $this->now->modify("+$i days")->format('Y-m-d'); + $hourlyTime = []; + $hourlyTemp = []; + $hourlyPrecip = []; + $hourlyWind = []; + $hourlyDir = []; + for ($d = 0; $d < 6; $d++) { + $day = $this->now->modify("+$d days"); + $dates[] = $day->format('Y-m-d'); + for ($h = 0; $h < 24; $h++) { + $hourlyTime[] = $day->setTime($h, 0)->format('Y-m-d\TH:i'); + $t = $tmin[$d] + ($tmax[$d] - $tmin[$d]) * (0.5 + 0.5 * sin((($h - 9) / 24) * 2 * M_PI)); + $hourlyTemp[] = round($t, 1); + $p = ($precipDay[$d] > 0 && $h >= 14 && $h <= 16) ? round($precipDay[$d] / 3, 1) : 0.0; + $hourlyPrecip[] = $p; + $hourlyWind[] = round(3 + 6 * (0.5 + 0.5 * sin((($h + $d * 3) / 24) * 2 * M_PI)), 1); + $hourlyDir[] = ($d * 40 + $h * 7) % 360; + } } + return [ - 'time' => $dates, - 'weather_code' => [3, 2, 0, 61, 80, 95], - 'temperature_2m_max' => [17.9, 18.3, 20.1, 17.4, 15.2, 16.0], - 'temperature_2m_min' => [11.0, 10.1, 11.3, 12.0, 9.4, 8.1], - 'precipitation_sum' => [0.0, 0.4, 0.0, 8.2, 3.1, 0.2], - 'precipitation_probability_max' => [10, 20, 5, 80, 60, 15], + 'daily' => [ + 'time' => $dates, + 'weather_code' => [3, 2, 0, 61, 80, 95], + 'temperature_2m_max' => $tmax, + 'temperature_2m_min' => $tmin, + 'precipitation_sum' => $precipDay, + 'precipitation_probability_max' => [10, 20, 5, 80, 60, 15], + ], + 'hourly' => [ + 'time' => $hourlyTime, + 'temperature_2m' => $hourlyTemp, + 'precipitation' => $hourlyPrecip, + 'wind_speed_10m' => $hourlyWind, + 'wind_direction_10m' => $hourlyDir, + ], ]; } } diff --git a/src/HourlyForecastMapper.php b/src/HourlyForecastMapper.php new file mode 100644 index 0000000..339ee60 --- /dev/null +++ b/src/HourlyForecastMapper.php @@ -0,0 +1,74 @@ + $hourly + * @return array> + * sleutel is de kalenderdatum (Y-m-d) in $tz + */ + public static function mapByDate(array $hourly, \DateTimeZone $tz): array + { + $times = is_array($hourly['time'] ?? null) ? $hourly['time'] : []; + + $perDate = []; + foreach ($times as $i => $iso) { + if (!is_string($iso)) { + continue; + } + $at = self::parse($iso, $tz); + if ($at === null) { + continue; + } + $perDate[$at->format('Y-m-d')][] = [ + 'at' => $at->format(\DATE_ATOM), + 'temp' => self::floatOrNull($hourly['temperature_2m'][$i] ?? null), + 'precip_mm' => self::floatOrNull($hourly['precipitation'][$i] ?? null), + 'wind_kmh' => self::floatOrNull($hourly['wind_speed_10m'][$i] ?? null), + 'wind_dir' => self::intOrNull($hourly['wind_direction_10m'][$i] ?? null), + ]; + } + + $out = []; + foreach ($perDate as $date => $points) { + $sampled = []; + foreach (array_values($points) as $index => $point) { + if ($index % self::STEP === 0) { + $sampled[] = $point; + } + } + $out[$date] = $sampled; + } + return $out; + } + + /** Open-Meteo levert lokale tijd zonder offset, bv. "2026-09-14T00:00". */ + private static function parse(string $iso, \DateTimeZone $tz): ?\DateTimeImmutable + { + $dt = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i', $iso, $tz); + return $dt === false ? null : $dt; + } + + private static function floatOrNull(mixed $v): ?float + { + return is_int($v) || is_float($v) ? (float) $v : null; + } + + private static function intOrNull(mixed $v): ?int + { + return is_int($v) || is_float($v) ? (int) round($v) : null; + } +} diff --git a/src/LiveSource.php b/src/LiveSource.php index b2e3acb..5c23249 100644 --- a/src/LiveSource.php +++ b/src/LiveSource.php @@ -41,6 +41,6 @@ final class LiveSource implements WeatherSource public function forecast(float $lat, float $lon, string $timezone): array { - return $this->openMeteo->dailyForecast($lat, $lon, $timezone); + return $this->openMeteo->forecast($lat, $lon, $timezone); } } diff --git a/src/OpenMeteoClient.php b/src/OpenMeteoClient.php index 3452b32..9db7620 100644 --- a/src/OpenMeteoClient.php +++ b/src/OpenMeteoClient.php @@ -13,13 +13,14 @@ final class OpenMeteoClient { } - /** @return array het "daily"-blok */ - public function dailyForecast(float $lat, float $lon, string $timezone): array + /** @return array{daily: array, hourly: array} */ + public function forecast(float $lat, float $lon, string $timezone): array { $res = $this->http->get(self::URL, [ 'latitude' => $lat, 'longitude' => $lon, 'daily' => 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max', + 'hourly' => 'temperature_2m,precipitation,wind_speed_10m,wind_direction_10m', 'timezone' => $timezone, 'forecast_days' => 6, ]); @@ -30,6 +31,9 @@ final class OpenMeteoClient if (!is_array($json['daily'] ?? null)) { throw new UpstreamException('Open-Meteo: geen daily-blok in antwoord'); } - return $json['daily']; + return [ + 'daily' => $json['daily'], + 'hourly' => is_array($json['hourly'] ?? null) ? $json['hourly'] : [], + ]; } } diff --git a/src/WeatherSource.php b/src/WeatherSource.php index e2991c2..33b35cd 100644 --- a/src/WeatherSource.php +++ b/src/WeatherSource.php @@ -16,6 +16,6 @@ interface WeatherSource /** @return array de "body" van Netatmo getmeasure (timestamp => [hPa]) voor het basisstation */ public function pressureHistory(string $deviceId, int $begin, int $end): array; - /** @return array het "daily"-blok van Open-Meteo */ + /** @return array{daily: array, hourly: array} de "daily"- en "hourly"-blokken van Open-Meteo */ public function forecast(float $lat, float $lon, string $timezone): array; } diff --git a/tests/DashboardServiceTest.php b/tests/DashboardServiceTest.php index a3ae9b8..0b05402 100644 --- a/tests/DashboardServiceTest.php +++ b/tests/DashboardServiceTest.php @@ -65,6 +65,9 @@ final class DashboardServiceTest extends TestCase self::assertFalse($p['rain']['stale']); self::assertCount(6, $p['forecast']); self::assertSame('2026-09-11', $p['forecast'][0]['date']); + self::assertCount(12, $p['forecast'][0]['hourly']); + self::assertSame(['at', 'temp', 'precip_mm', 'wind_kmh', 'wind_dir'], array_keys($p['forecast'][0]['hourly'][0])); + self::assertSame('2026-09-11T00:00:00+02:00', $p['forecast'][0]['hourly'][0]['at']); self::assertFalse($p['forecast_stale']); self::assertSame([], $p['errors']); } diff --git a/tests/FakeSource.php b/tests/FakeSource.php index 151f4a7..6a78ed3 100644 --- a/tests/FakeSource.php +++ b/tests/FakeSource.php @@ -25,7 +25,10 @@ final class FakeSource implements WeatherSource 'stations' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getstationsdata.json'), true)['body'], 'measure' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure.json'), true)['body'], 'pressure' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure-pressure.json'), true)['body'], - 'forecast' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true)['daily'], + 'forecast' => array_intersect_key( + json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true), + ['daily' => null, 'hourly' => null], + ), ]; return $s; } diff --git a/tests/HourlyForecastMapperTest.php b/tests/HourlyForecastMapperTest.php new file mode 100644 index 0000000..24ddfa6 --- /dev/null +++ b/tests/HourlyForecastMapperTest.php @@ -0,0 +1,103 @@ +tz = new \DateTimeZone('Europe/Berlin'); + } + + /** @return array */ + private function hourly(): array + { + return json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true)['hourly']; + } + + public function testGroupsByDateAndSamplesEveryOtherHour(): void + { + $byDate = HourlyForecastMapper::mapByDate($this->hourly(), $this->tz); + + self::assertSame(['2026-09-11', '2026-09-12', '2026-09-13', '2026-09-14', '2026-09-15', '2026-09-16'], array_keys($byDate)); + self::assertCount(12, $byDate['2026-09-11']); + + $hours = array_map( + fn(array $p) => (int) (new \DateTimeImmutable($p['at']))->format('H'), + $byDate['2026-09-11'], + ); + self::assertSame([0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22], $hours); + } + + public function testPointShapeAndValues(): void + { + $byDate = HourlyForecastMapper::mapByDate($this->hourly(), $this->tz); + $noon = $byDate['2026-09-11'][6]; // index 6 = 12:00 (0,2,4,6,8,10,12,...) + + self::assertSame(['at', 'temp', 'precip_mm', 'wind_kmh', 'wind_dir'], array_keys($noon)); + self::assertSame('2026-09-11T12:00:00+02:00', $noon['at']); + self::assertIsFloat($noon['temp']); + self::assertIsFloat($noon['precip_mm']); + self::assertIsFloat($noon['wind_kmh']); + self::assertIsInt($noon['wind_dir']); + } + + public function testRainOnlyOnTheHoursThatHaveIt(): void + { + $byDate = HourlyForecastMapper::mapByDate($this->hourly(), $this->tz); + // 2026-09-14 heeft 8,2 mm in de dagfixture, verdeeld over 14-16u; de bemonsterde uren zijn 0,2,4,..22. + $rainy = array_filter($byDate['2026-09-14'], fn(array $p) => $p['precip_mm'] > 0); + + self::assertNotEmpty($rainy); + foreach ($rainy as $p) { + self::assertGreaterThanOrEqual(14, (int) (new \DateTimeImmutable($p['at']))->format('H')); + } + } + + public function testDaylightSavingDayWithTwentyFiveHoursSamplesWithoutError(): void + { + $hourly = json_decode((string) file_get_contents(__DIR__ . '/fixtures/hourly-dst.json'), true); + + $byDate = HourlyForecastMapper::mapByDate($hourly, $this->tz); + + self::assertCount(13, $byDate['2026-10-25'], '25 punten, om de 2: index 0,2,...,24 = 13 stuks'); + } + + public function testNullValuesStayNull(): void + { + $hourly = $this->hourly(); + $hourly['temperature_2m'][0] = null; + $hourly['wind_direction_10m'][0] = 'kapot'; + + $byDate = HourlyForecastMapper::mapByDate($hourly, $this->tz); + + self::assertNull($byDate['2026-09-11'][0]['temp']); + self::assertNull($byDate['2026-09-11'][0]['wind_dir']); + } + + public function testMalformedTimestampsAreSkipped(): void + { + $byDate = HourlyForecastMapper::mapByDate([ + 'time' => ['niet-een-datum', '2026-09-11T05:00'], + 'temperature_2m' => [1.0, 2.0], + 'precipitation' => [0.0, 0.0], + 'wind_speed_10m' => [1.0, 1.0], + 'wind_direction_10m' => [1, 1], + ], $this->tz); + + self::assertSame(['2026-09-11'], array_keys($byDate)); + self::assertCount(1, $byDate['2026-09-11']); + } + + public function testMissingTimeArrayGivesEmptyResult(): void + { + self::assertSame([], HourlyForecastMapper::mapByDate([], $this->tz)); + } +} diff --git a/tests/OpenMeteoClientTest.php b/tests/OpenMeteoClientTest.php index 7b9c6d0..74fa1ab 100644 --- a/tests/OpenMeteoClientTest.php +++ b/tests/OpenMeteoClientTest.php @@ -10,19 +10,21 @@ use Weerscherm\UpstreamException; final class OpenMeteoClientTest extends TestCase { - public function testRequestsSixDaysWithAllDailyVariables(): void + public function testRequestsSixDaysWithDailyAndHourlyVariables(): void { $http = new FakeHttp(); $http->queue(200, json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true)); - $daily = (new OpenMeteoClient($http))->dailyForecast(51.82, 6.55, 'Europe/Berlin'); + $result = (new OpenMeteoClient($http))->forecast(51.82, 6.55, 'Europe/Berlin'); - self::assertCount(6, $daily['time']); + self::assertCount(6, $result['daily']['time']); + self::assertCount(144, $result['hourly']['time']); self::assertSame('https://api.open-meteo.com/v1/forecast', $http->requests[0]['url']); self::assertSame([ 'latitude' => 51.82, 'longitude' => 6.55, 'daily' => 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max', + 'hourly' => 'temperature_2m,precipitation,wind_speed_10m,wind_direction_10m', 'timezone' => 'Europe/Berlin', 'forecast_days' => 6, ], $http->requests[0]['params']); @@ -32,13 +34,22 @@ final class OpenMeteoClientTest extends TestCase { $http = (new FakeHttp())->queue(429, ['reason' => 'rate limit']); $this->expectException(UpstreamException::class); - (new OpenMeteoClient($http))->dailyForecast(1, 2, 'UTC'); + (new OpenMeteoClient($http))->forecast(1, 2, 'UTC'); } public function testMissingDailyIsUpstreamException(): void { $http = (new FakeHttp())->queue(200, ['hourly' => []]); $this->expectException(UpstreamException::class); - (new OpenMeteoClient($http))->dailyForecast(1, 2, 'UTC'); + (new OpenMeteoClient($http))->forecast(1, 2, 'UTC'); + } + + public function testMissingHourlyGivesEmptyHourlyBlockInsteadOfError(): void + { + $http = (new FakeHttp())->queue(200, ['daily' => ['time' => ['2026-09-11']]]); + + $result = (new OpenMeteoClient($http))->forecast(1, 2, 'UTC'); + + self::assertSame([], $result['hourly']); } } diff --git a/tests/fixtures/forecast.json b/tests/fixtures/forecast.json index 1b5d86f..18ee1fe 100644 --- a/tests/fixtures/forecast.json +++ b/tests/fixtures/forecast.json @@ -1,25 +1,806 @@ { - "latitude": 51.82, - "longitude": 6.56, - "generationtime_ms": 0.2, - "utc_offset_seconds": 7200, - "timezone": "Europe/Berlin", - "timezone_abbreviation": "CEST", - "elevation": 20.0, - "daily_units": { - "time": "iso8601", - "weather_code": "wmo code", - "temperature_2m_max": "°C", - "temperature_2m_min": "°C", - "precipitation_sum": "mm", - "precipitation_probability_max": "%" - }, - "daily": { - "time": ["2026-09-11", "2026-09-12", "2026-09-13", "2026-09-14", "2026-09-15", "2026-09-16"], - "weather_code": [3, 2, 0, 61, 80, 2], - "temperature_2m_max": [17.9, 18.3, 20.1, 17.4, 15.2, 16.0], - "temperature_2m_min": [11.0, 10.1, 11.3, 12.0, 9.4, 8.1], - "precipitation_sum": [0.0, 0.4, 0.0, 8.2, 3.1, 0.2], - "precipitation_probability_max": [10, 20, 5, 80, 60, 15] - } + "latitude": 51.82, + "longitude": 6.56, + "generationtime_ms": 0.2, + "utc_offset_seconds": 7200, + "timezone": "Europe/Berlin", + "timezone_abbreviation": "CEST", + "elevation": 20, + "daily_units": { + "time": "iso8601", + "weather_code": "wmo code", + "temperature_2m_max": "\u00b0C", + "temperature_2m_min": "\u00b0C", + "precipitation_sum": "mm", + "precipitation_probability_max": "%" + }, + "daily": { + "time": [ + "2026-09-11", + "2026-09-12", + "2026-09-13", + "2026-09-14", + "2026-09-15", + "2026-09-16" + ], + "weather_code": [ + 3, + 2, + 0, + 61, + 80, + 2 + ], + "temperature_2m_max": [ + 17.9, + 18.3, + 20.1, + 17.4, + 15.2, + 16 + ], + "temperature_2m_min": [ + 11, + 10.1, + 11.3, + 12, + 9.4, + 8.1 + ], + "precipitation_sum": [ + 0, + 0.4, + 0, + 8.2, + 3.1, + 0.2 + ], + "precipitation_probability_max": [ + 10, + 20, + 5, + 80, + 60, + 15 + ] + }, + "hourly_units": { + "time": "iso8601", + "temperature_2m": "\u00b0C", + "precipitation": "mm", + "wind_speed_10m": "km/h", + "wind_direction_10m": "\u00b0" + }, + "hourly": { + "time": [ + "2026-09-11T00:00", + "2026-09-11T01:00", + "2026-09-11T02:00", + "2026-09-11T03:00", + "2026-09-11T04:00", + "2026-09-11T05:00", + "2026-09-11T06:00", + "2026-09-11T07:00", + "2026-09-11T08:00", + "2026-09-11T09:00", + "2026-09-11T10:00", + "2026-09-11T11:00", + "2026-09-11T12:00", + "2026-09-11T13:00", + "2026-09-11T14:00", + "2026-09-11T15:00", + "2026-09-11T16:00", + "2026-09-11T17:00", + "2026-09-11T18:00", + "2026-09-11T19:00", + "2026-09-11T20:00", + "2026-09-11T21:00", + "2026-09-11T22:00", + "2026-09-11T23:00", + "2026-09-12T00:00", + "2026-09-12T01:00", + "2026-09-12T02:00", + "2026-09-12T03:00", + "2026-09-12T04:00", + "2026-09-12T05:00", + "2026-09-12T06:00", + "2026-09-12T07:00", + "2026-09-12T08:00", + "2026-09-12T09:00", + "2026-09-12T10:00", + "2026-09-12T11:00", + "2026-09-12T12:00", + "2026-09-12T13:00", + "2026-09-12T14:00", + "2026-09-12T15:00", + "2026-09-12T16:00", + "2026-09-12T17:00", + "2026-09-12T18:00", + "2026-09-12T19:00", + "2026-09-12T20:00", + "2026-09-12T21:00", + "2026-09-12T22:00", + "2026-09-12T23:00", + "2026-09-13T00:00", + "2026-09-13T01:00", + "2026-09-13T02:00", + "2026-09-13T03:00", + "2026-09-13T04:00", + "2026-09-13T05:00", + "2026-09-13T06:00", + "2026-09-13T07:00", + "2026-09-13T08:00", + "2026-09-13T09:00", + "2026-09-13T10:00", + "2026-09-13T11:00", + "2026-09-13T12:00", + "2026-09-13T13:00", + "2026-09-13T14:00", + "2026-09-13T15:00", + "2026-09-13T16:00", + "2026-09-13T17:00", + "2026-09-13T18:00", + "2026-09-13T19:00", + "2026-09-13T20:00", + "2026-09-13T21:00", + "2026-09-13T22:00", + "2026-09-13T23:00", + "2026-09-14T00:00", + "2026-09-14T01:00", + "2026-09-14T02:00", + "2026-09-14T03:00", + "2026-09-14T04:00", + "2026-09-14T05:00", + "2026-09-14T06:00", + "2026-09-14T07:00", + "2026-09-14T08:00", + "2026-09-14T09:00", + "2026-09-14T10:00", + "2026-09-14T11:00", + "2026-09-14T12:00", + "2026-09-14T13:00", + "2026-09-14T14:00", + "2026-09-14T15:00", + "2026-09-14T16:00", + "2026-09-14T17:00", + "2026-09-14T18:00", + "2026-09-14T19:00", + "2026-09-14T20:00", + "2026-09-14T21:00", + "2026-09-14T22:00", + "2026-09-14T23:00", + "2026-09-15T00:00", + "2026-09-15T01:00", + "2026-09-15T02:00", + "2026-09-15T03:00", + "2026-09-15T04:00", + "2026-09-15T05:00", + "2026-09-15T06:00", + "2026-09-15T07:00", + "2026-09-15T08:00", + "2026-09-15T09:00", + "2026-09-15T10:00", + "2026-09-15T11:00", + "2026-09-15T12:00", + "2026-09-15T13:00", + "2026-09-15T14:00", + "2026-09-15T15:00", + "2026-09-15T16:00", + "2026-09-15T17:00", + "2026-09-15T18:00", + "2026-09-15T19:00", + "2026-09-15T20:00", + "2026-09-15T21:00", + "2026-09-15T22:00", + "2026-09-15T23:00", + "2026-09-16T00:00", + "2026-09-16T01:00", + "2026-09-16T02:00", + "2026-09-16T03:00", + "2026-09-16T04:00", + "2026-09-16T05:00", + "2026-09-16T06:00", + "2026-09-16T07:00", + "2026-09-16T08:00", + "2026-09-16T09:00", + "2026-09-16T10:00", + "2026-09-16T11:00", + "2026-09-16T12:00", + "2026-09-16T13:00", + "2026-09-16T14:00", + "2026-09-16T15:00", + "2026-09-16T16:00", + "2026-09-16T17:00", + "2026-09-16T18:00", + "2026-09-16T19:00", + "2026-09-16T20:00", + "2026-09-16T21:00", + "2026-09-16T22:00", + "2026-09-16T23:00" + ], + "temperature_2m": [ + 10.3, + 9.7, + 9.3, + 9.2, + 9.3, + 9.7, + 10.3, + 11, + 11.9, + 12.8, + 13.7, + 14.6, + 15.3, + 15.9, + 16.3, + 16.4, + 16.3, + 15.9, + 15.3, + 14.6, + 13.7, + 12.8, + 11.9, + 11, + 11.3, + 10.6, + 10.2, + 10.1, + 10.2, + 10.6, + 11.3, + 12.2, + 13.1, + 14.2, + 15.3, + 16.3, + 17.1, + 17.8, + 18.2, + 18.3, + 18.2, + 17.8, + 17.1, + 16.3, + 15.3, + 14.2, + 13.1, + 12.2, + 12.6, + 11.9, + 11.4, + 11.3, + 11.4, + 11.9, + 12.6, + 13.5, + 14.6, + 15.7, + 16.8, + 17.9, + 18.8, + 19.5, + 20, + 20.1, + 20, + 19.5, + 18.8, + 17.9, + 16.8, + 15.7, + 14.6, + 13.5, + 12.8, + 12.4, + 12.1, + 12, + 12.1, + 12.4, + 12.8, + 13.4, + 14, + 14.7, + 15.4, + 16.1, + 16.6, + 17, + 17.3, + 17.4, + 17.3, + 17, + 16.6, + 16.1, + 15.4, + 14.7, + 14, + 13.4, + 10.2, + 9.8, + 9.5, + 9.4, + 9.5, + 9.8, + 10.2, + 10.9, + 11.5, + 12.3, + 13.1, + 13.8, + 14.4, + 14.8, + 15.1, + 15.2, + 15.1, + 14.8, + 14.4, + 13.8, + 13.1, + 12.3, + 11.5, + 10.9, + 9.3, + 8.6, + 8.2, + 8.1, + 8.2, + 8.6, + 9.3, + 10.1, + 11, + 12.1, + 13.1, + 14, + 14.8, + 15.5, + 15.9, + 16, + 15.9, + 15.5, + 14.8, + 14, + 13.1, + 12.1, + 11, + 10.1 + ], + "precipitation": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0.1, + 0.1, + 0.1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 2.7, + 2.7, + 2.7, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0.1, + 0.1, + 0.1, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "wind_speed_10m": [ + 6, + 6.8, + 7.5, + 8.1, + 8.6, + 8.9, + 9, + 8.9, + 8.6, + 8.1, + 7.5, + 6.8, + 6, + 5.2, + 4.5, + 3.9, + 3.4, + 3.1, + 3, + 3.1, + 3.4, + 3.9, + 4.5, + 5.2, + 8.1, + 8.6, + 8.9, + 9, + 8.9, + 8.6, + 8.1, + 7.5, + 6.8, + 6, + 5.2, + 4.5, + 3.9, + 3.4, + 3.1, + 3, + 3.1, + 3.4, + 3.9, + 4.5, + 5.2, + 6, + 6.8, + 7.5, + 9, + 8.9, + 8.6, + 8.1, + 7.5, + 6.8, + 6, + 5.2, + 4.5, + 3.9, + 3.4, + 3.1, + 3, + 3.1, + 3.4, + 3.9, + 4.5, + 5.2, + 6, + 6.8, + 7.5, + 8.1, + 8.6, + 8.9, + 8.1, + 7.5, + 6.8, + 6, + 5.2, + 4.5, + 3.9, + 3.4, + 3.1, + 3, + 3.1, + 3.4, + 3.9, + 4.5, + 5.2, + 6, + 6.8, + 7.5, + 8.1, + 8.6, + 8.9, + 9, + 8.9, + 8.6, + 6, + 5.2, + 4.5, + 3.9, + 3.4, + 3.1, + 3, + 3.1, + 3.4, + 3.9, + 4.5, + 5.2, + 6, + 6.8, + 7.5, + 8.1, + 8.6, + 8.9, + 9, + 8.9, + 8.6, + 8.1, + 7.5, + 6.8, + 3.9, + 3.4, + 3.1, + 3, + 3.1, + 3.4, + 3.9, + 4.5, + 5.2, + 6, + 6.8, + 7.5, + 8.1, + 8.6, + 8.9, + 9, + 8.9, + 8.6, + 8.1, + 7.5, + 6.8, + 6, + 5.2, + 4.5 + ], + "wind_direction_10m": [ + 0, + 7, + 14, + 21, + 28, + 35, + 42, + 49, + 56, + 63, + 70, + 77, + 84, + 91, + 98, + 105, + 112, + 119, + 126, + 133, + 140, + 147, + 154, + 161, + 40, + 47, + 54, + 61, + 68, + 75, + 82, + 89, + 96, + 103, + 110, + 117, + 124, + 131, + 138, + 145, + 152, + 159, + 166, + 173, + 180, + 187, + 194, + 201, + 80, + 87, + 94, + 101, + 108, + 115, + 122, + 129, + 136, + 143, + 150, + 157, + 164, + 171, + 178, + 185, + 192, + 199, + 206, + 213, + 220, + 227, + 234, + 241, + 120, + 127, + 134, + 141, + 148, + 155, + 162, + 169, + 176, + 183, + 190, + 197, + 204, + 211, + 218, + 225, + 232, + 239, + 246, + 253, + 260, + 267, + 274, + 281, + 160, + 167, + 174, + 181, + 188, + 195, + 202, + 209, + 216, + 223, + 230, + 237, + 244, + 251, + 258, + 265, + 272, + 279, + 286, + 293, + 300, + 307, + 314, + 321, + 200, + 207, + 214, + 221, + 228, + 235, + 242, + 249, + 256, + 263, + 270, + 277, + 284, + 291, + 298, + 305, + 312, + 319, + 326, + 333, + 340, + 347, + 354, + 1 + ] + } } diff --git a/tests/fixtures/hourly-dst.json b/tests/fixtures/hourly-dst.json new file mode 100644 index 0000000..612de22 --- /dev/null +++ b/tests/fixtures/hourly-dst.json @@ -0,0 +1,137 @@ +{ + "time": [ + "2026-10-25T00:00", + "2026-10-25T01:00", + "2026-10-25T02:00", + "2026-10-25T03:00", + "2026-10-25T04:00", + "2026-10-25T05:00", + "2026-10-25T06:00", + "2026-10-25T07:00", + "2026-10-25T08:00", + "2026-10-25T09:00", + "2026-10-25T10:00", + "2026-10-25T11:00", + "2026-10-25T12:00", + "2026-10-25T13:00", + "2026-10-25T14:00", + "2026-10-25T15:00", + "2026-10-25T16:00", + "2026-10-25T17:00", + "2026-10-25T18:00", + "2026-10-25T19:00", + "2026-10-25T20:00", + "2026-10-25T21:00", + "2026-10-25T22:00", + "2026-10-25T23:00", + "2026-10-25T02:00" + ], + "temperature_2m": [ + 10, + 10.1, + 10.2, + 10.3, + 10.4, + 10.5, + 10.6, + 10.7, + 10.8, + 10.9, + 11, + 11.1, + 11.2, + 11.3, + 11.4, + 11.5, + 11.6, + 11.7, + 11.8, + 11.9, + 12, + 12.1, + 12.2, + 12.3, + 12.4 + ], + "precipitation": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "wind_speed_10m": [ + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5 + ], + "wind_direction_10m": [ + 180, + 180, + 180, + 180, + 180, + 180, + 180, + 180, + 180, + 180, + 180, + 180, + 180, + 180, + 180, + 180, + 180, + 180, + 180, + 180, + 180, + 180, + 180, + 180, + 180 + ] +} diff --git a/tests/js/format.test.js b/tests/js/format.test.js index 6228163..ed81746 100644 --- a/tests/js/format.test.js +++ b/tests/js/format.test.js @@ -119,7 +119,10 @@ test('msUntil computes the wait until the next local time', () => { 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 } from '../../public/format.js'; +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' }); @@ -151,3 +154,36 @@ test('sparklinePath scales points into the box', () => { 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), '–'); +});