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 `
${inner} `;
}
+
+/** 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 @@
Gegevens laden…
+
+
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), '–');
+});