Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3bbf5639d | ||
|
|
ca2fc76552 | ||
|
|
75111a5571 | ||
|
|
bfc96acb5a | ||
|
|
4c4881fd11 | ||
|
|
107978b265 | ||
|
|
5058bb850d | ||
|
|
5d61b6ec90 | ||
|
|
e2db301aef | ||
|
|
c0715b7cc9 | ||
|
|
c95d198341 | ||
|
|
c542601d4e | ||
|
|
2200a849f9 | ||
|
|
350dafe591 | ||
|
|
48d68b86fa | ||
|
|
b6f4836b12 | ||
|
|
8e90eb1129 | ||
|
|
90e979f562 | ||
|
|
8bbfd62d16 | ||
|
|
281eb1406f | ||
|
|
ef37c69076 |
@@ -31,7 +31,9 @@ return [
|
||||
'stations' => 300,
|
||||
'measure' => 900,
|
||||
'pressure' => 900,
|
||||
'temperature' => 900,
|
||||
'forecast' => 3600,
|
||||
'radar' => 240,
|
||||
],
|
||||
|
||||
// Map voor tokens en cache. Moet schrijfbaar zijn voor de webserver en buiten de docroot liggen.
|
||||
|
||||
+51
-5
@@ -1,6 +1,6 @@
|
||||
# Weerscherm: bouwspecificatie v1
|
||||
|
||||
Opdracht voor Claude Code: bouw een PWA die fullscreen draait op een iPad mini (liggend) in de woonkamer. Het scherm toont actuele gegevens van een Netatmo-weerstation en een weersverwachting voor 5 dagen.
|
||||
Opdracht voor Claude Code: bouw een PWA die fullscreen draait op een iPad mini (liggend) in de woonkamer. Het scherm toont actuele gegevens van een Netatmo-weerstation en een weersverwachting voor 6 dagen.
|
||||
|
||||
**Ontwerp A** in `docs/design/weerscherm-layouts.html` is leidend voor de layout. Dat bestand is een statische mockup met voorbeelddata. De CSS van ontwerp A (klasse `.da` en alle `.a-*`-klassen) is getest op 1133 × 744 punten en dient als uitgangspunt. De tabbladen B en C zijn niet in scope.
|
||||
|
||||
@@ -26,7 +26,8 @@ Werkwijze: lees eerst dit document en de mockup. Stel daarna een plan voor per f
|
||||
- Buitentemperatuur, met de laagste en hoogste temperatuur van vandaag (inclusief tijdstippen) en de trend.
|
||||
- De 3 binnensensoren, elk met huidige, laagste en hoogste temperatuur van vandaag.
|
||||
- Regen: vandaag, de afgelopen 3 dagen per dag, en het totaal van de huidige maand.
|
||||
- Verwachting voor de komende 5 dagen (vanaf morgen): max/min-temperatuur, neerslag in mm, neerslagkans en een weericoon.
|
||||
- Verwachting voor 6 dagen (vandaag t/m 5 dagen verder): max/min-temperatuur, neerslag in mm, neerslagkans en een weericoon.
|
||||
_Wijziging 2026-09-13: was oorspronkelijk 5 dagen vanaf morgen; op verzoek van Theo toegevoegd: vandaag als eerste kolom._
|
||||
- Installeerbaar als PWA op het beginscherm, blijft werken met de laatst bekende data als de verbinding wegvalt.
|
||||
|
||||
**Niet in v1:** nachtmodus (ontwerp C), andere meetwaarden (CO₂, luchtvochtigheid, wind), meerdere stations, een instellingenscherm, pushmeldingen.
|
||||
@@ -250,12 +251,55 @@ De iconen `sun`, `partly`, `rain` en `showers` staan in de mockup. Maak `cloud`,
|
||||
- De temperatuur staat groot, met 1 decimaal ("16,4°"). Het label heeft een trend-aanduiding: `up` → "↗ stijgend", `down` → "↘ dalend", `stable` → "→ stabiel".
|
||||
- Daaronder de gradiëntbalk van `--cold` naar `--warm`. De marker staat op `(temp − min) / (max − min)`, begrensd op 0–100%. Bij `max == min` staat hij op 50%.
|
||||
- Onder de balk: "9,2° laagst om 06:10" en "19,8° hoogst om 13:05".
|
||||
- **Binnen (rechts):** 3 rijen met naam, temperatuur (1 decimaal) en "↓ min° ↑ max°". Een onbereikbare module toont "–" in `--muted`.
|
||||
- Rechts van het grote getal een klein grafiekje van het temperatuurverloop van vandaag.
|
||||
_Toegevoegd 2026-09-13: onderschrift "Vandaag", de lijn, en tijdlabels op hele uren met "nu" rechts
|
||||
(zelfde `hourTicks` als de luchtdruk). Geen y-as: de laagste en hoogste waarde van vandaag staan al
|
||||
onder de balk en dat is precies de schaal van de lijn. Data uit een extra `getmeasure`
|
||||
(`scale=30min`, `type=Temperature`, buitenmodule) vanaf middernacht lokale tijd, in de payload als
|
||||
`outdoor_history: {points, stale}`. Vlak na middernacht zijn er nog geen twee punten; dan blijft het
|
||||
blokje verborgen in plaats van een leeg kader te tonen._
|
||||
- Daaronder de luchtdruk: "Luchtdruk 1014 hPa ↗ stijgend" met een grafiek van de afgelopen 24 uur.
|
||||
_Assen toegevoegd 2026-09-13: links de hoogste en laagste waarde van het venster in hele hPa, onder de
|
||||
lijn tijdlabels op hele uren die op de klok zijn uitgelijnd (elke 6 uur vanaf middernacht, dus 00:00,
|
||||
06:00, 12:00, 18:00) op hun echte tijdspositie, met "nu" op de rechterrand, plus drie lichte verticale
|
||||
hulplijnen. Een markering te dicht bij het einde valt weg omdat "nu" daar staat. De losse tekst
|
||||
"1009 – 1015 hPa in 24 u" in de kopregel is daarmee vervallen. Zie `hourTicks()` in `public/format.js`;
|
||||
die loopt uur voor uur door het venster en raadpleegt telkens de lokale klok, zodat de zomertijdwissel
|
||||
vanzelf goed gaat._
|
||||
- **Binnen (rechts):** een rij per module met naam, temperatuur (1 decimaal) en "↓ min° ↑ max°". Een onbereikbare module toont "–" in `--muted`.
|
||||
_Gewijzigd 2026-09-14: het aantal modules ligt niet vast op 3. De kolom heeft een vaste hoogte van 412px
|
||||
(744 − 2×30 padding − 40 kopregel − 232 onderstrook) en `fitRooms()` verkleint de inhoud met een schaal
|
||||
zodra die niet past. Zonder dat duwde een vierde module de onderste strook 39px van het scherm af. Bij
|
||||
drie modules verandert er niets (schaal 1); vier geeft ongeveer 0,86, en met een vastgezette module 0,78._
|
||||
_Toegevoegd 2026-09-13: rechts van elk temperatuurgetal staat het verloop van vandaag als klein lijntje
|
||||
(200x40, zonder labels: de schaal staat op de regel eronder en het venster is hetzelfde "vandaag" als bij
|
||||
buiten). Data per module uit een eigen `getmeasure`, apart gecachet onder `temperature-<module>` maar met
|
||||
één gedeelde TTL; het basisstation meet zelf en wordt zonder `module_id` opgevraagd. In de payload als
|
||||
`indoor[].history`. Mislukt er één, dan blijft die lijst leeg, verdwijnt alleen dat lijntje en staat de
|
||||
foutcode in `errors`; een stale-vlag per kamer zou het contract nodeloos zwaar maken. De hoogte is overal
|
||||
gelijk, ook bij een vastgezette module, zodat de kamers vergelijkbaar blijven._
|
||||
|
||||
_Toegevoegd 2026-09-13: een module is tikbaar. Een tik zet die module bovenaan de lijst (de overige twee
|
||||
behouden hun onderlinge volgorde) en vergroot alleen het temperatuurgetal van 50 naar 92px. Nogmaals tikken
|
||||
op de bovenste (vastgezette) module maakt hem weer los. De keuze blijft staan bij de 5-minuten-verversing en
|
||||
reset bij een volledige herlaad. Zie `pinRoom()` in `public/format.js`._
|
||||
- **Regen (linksonder):**
|
||||
- "Vandaag" groot, in mm met 1 decimaal.
|
||||
- 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):** 5 kolommen met dagafkorting ("Za"), icoon (58px), "max° min°" als hele getallen (min in `--muted`) en "x,x mm" plus neerslagkans in %. Geen neerslag toont "0 mm".
|
||||
_Toegevoegd 2026-09-13: het regenblok is tikbaar en opent de regenradar van Buienradar (geanimeerde GIF,
|
||||
550x512, met de tijd in het beeld), naast de regencijfers van vandaag, de 3 vorige dagen en het maandtotaal.
|
||||
Sluit na 20 seconden of bij een tik. Het beeld loopt via `/api/radar.php` op onze eigen server (cache 4 min,
|
||||
bij storing het laatst opgehaalde beeld), zodat de iPad niet zelf met een derde partij praat en het beeld
|
||||
ook offline blijft staan. Bronvermelding "bron: Buienradar.nl" staat in de overlay; dat vraagt Buienradar
|
||||
bij vrij gebruik. Zie `src/BuienradarClient.php` en `src/RadarService.php`._
|
||||
- **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
|
||||
|
||||
@@ -342,7 +386,9 @@ 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 5 dagen vanaf morgen met het juiste icoon per WMO-code.
|
||||
- [ ] 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.
|
||||
- [ ] Tikken op het regenblok opent de Buienradar-animatie en sluit op dezelfde manier.
|
||||
- [ ] 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.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Weerscherm\BuienradarClient;
|
||||
use Weerscherm\Config;
|
||||
use Weerscherm\Http;
|
||||
use Weerscherm\RadarService;
|
||||
|
||||
$root = dirname(__DIR__, 2);
|
||||
|
||||
require $root . '/src/autoload.php';
|
||||
|
||||
header('X-Robots-Tag: noindex');
|
||||
|
||||
$configFile = $root . '/config.php';
|
||||
if (!is_file($configFile)) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo "config.php ontbreekt\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$config = Config::load($configFile);
|
||||
$ttl = $config->get('cache_ttl')['radar'] ?? 240;
|
||||
|
||||
$service = new RadarService(
|
||||
// Buienradar leidt door naar een CDN, dus deze client volgt redirects.
|
||||
new BuienradarClient(new Http(followRedirects: true)),
|
||||
rtrim((string) $config->get('storage_path'), '/') . '/cache/radar.gif',
|
||||
(int) $ttl,
|
||||
);
|
||||
$radar = $service->image();
|
||||
|
||||
header('Content-Type: image/gif');
|
||||
header('Content-Length: ' . strlen($radar['bytes']));
|
||||
// De browser mag het beeld hergebruiken tot de radar zelf ververst is.
|
||||
header('Cache-Control: public, max-age=' . max(30, (int) $ttl - $radar['age']));
|
||||
header('X-Radar-Stale: ' . ($radar['stale'] ? '1' : '0'));
|
||||
echo $radar['bytes'];
|
||||
} catch (Throwable $e) {
|
||||
error_log('weerscherm: radar: ' . $e->getMessage());
|
||||
http_response_code(503);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
echo "Radarbeeld niet beschikbaar\n";
|
||||
}
|
||||
+209
-9
@@ -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, pinRoom, hourTicks, scaleToFit,
|
||||
} 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,10 @@ const FETCH_TIMEOUT_MS = 10000;
|
||||
const STALE_MINUTES = 30;
|
||||
const RELOAD_HOUR = 4;
|
||||
const FALLBACK_TZ = 'Europe/Berlin';
|
||||
const OVERLAY_MS = 20000; // hoe lang een overlay blijft staan zonder tik
|
||||
const RADAR_BUCKET_MS = 240000; // hergebruikvenster voor het radarbeeld, gelijk aan de servercache
|
||||
const HOURLY_WIDTH = 1045; // = stage-breedte 1133 min 2x 44px padding, zelfde marge als de rest van het scherm
|
||||
const INSIDE_H = 412; // hoogte van de middelste rij: 744 - 2x30 padding - 40 kopregel - 232 onderstrook
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const stage = $('stage');
|
||||
@@ -22,7 +27,8 @@ const state = {
|
||||
data: null, // laatste payload, ook als die uit de SW-cache komt
|
||||
tz: FALLBACK_TZ,
|
||||
lastOk: null, // Date van de laatste geslaagde live-fetch
|
||||
offlineSince: null // Date vanaf wanneer er geen verbinding is
|
||||
offlineSince: null, // Date vanaf wanneer er geen verbinding is
|
||||
pinnedRoomId: null, // id van de module die vastgezet is bovenaan; blijft staan tot opnieuw getikt of een volledige herlaad
|
||||
};
|
||||
|
||||
/* ---------- schaal ---------- */
|
||||
@@ -67,18 +73,59 @@ function renderOutdoor(o) {
|
||||
setText('max-at', `hoogst om ${formatTime(o.max_at, state.tz)}`);
|
||||
}
|
||||
|
||||
/** Verloop van vandaag naast het grote getal. Met minder dan twee punten valt er niets te tekenen. */
|
||||
function renderTodayChart(history) {
|
||||
const block = $('today-chart');
|
||||
const points = history?.points || [];
|
||||
if (points.length < 2) {
|
||||
block.hidden = true;
|
||||
return;
|
||||
}
|
||||
block.hidden = false;
|
||||
$('today-path').setAttribute('d', sparklinePath(points.map((p) => p.temp), 200, 96, 4));
|
||||
|
||||
const ticks = hourTicks(points[0].at, points[points.length - 1].at, state.tz);
|
||||
$('today-hours').innerHTML = ticks
|
||||
.map((t) => {
|
||||
const style = t.percent < 6 ? 'left:0' : `left:${t.percent.toFixed(1)}%;transform:translateX(-50%)`;
|
||||
return `<span style="${style}">${formatTime(t.at, state.tz)}</span>`;
|
||||
})
|
||||
.concat(['<span style="right:0">nu</span>'])
|
||||
.join('');
|
||||
}
|
||||
|
||||
function renderRooms(rooms) {
|
||||
const html = rooms.map((r) => {
|
||||
const ordered = pinRoom(rooms, state.pinnedRoomId);
|
||||
const html = ordered.map((r) => {
|
||||
const off = !r.reachable || r.temp === null;
|
||||
const co2 = co2Level(off ? null : r.co2);
|
||||
const pill = `<span class="pill ${co2.level} num" title="${co2.label}">${co2.level === 'unknown' ? '–' : r.co2 + ' ppm'}</span>`;
|
||||
return `<div class="a-room${off ? ' off' : ''}">
|
||||
const pinned = r.id === state.pinnedRoomId;
|
||||
const points = r.history || [];
|
||||
// Zonder twee punten valt er niets te tekenen; dan blijft de plek naast het getal gewoon leeg.
|
||||
const spark = points.length < 2 ? '' : `<svg class="a-rspark" viewBox="0 0 200 40" preserveAspectRatio="none" aria-hidden="true">
|
||||
<path class="a-spark-line" d="${sparklinePath(points.map((p) => p.temp), 200, 40, 3)}"/>
|
||||
</svg>`;
|
||||
return `<div class="a-room${off ? ' off' : ''}${pinned ? ' pinned' : ''}" data-id="${esc(r.id)}">
|
||||
<p class="a-rname"><span>${esc(r.name)}</span>${pill}</p>
|
||||
<p class="a-rtemp num">${off ? '–' : f1(r.temp) + '°'}</p>
|
||||
<div class="a-rrow"><p class="a-rtemp num">${off ? '–' : f1(r.temp) + '°'}</p>${spark}</div>
|
||||
<p class="a-rmm num"><span class="cold">↓ ${off ? '–' : f1(r.min) + '°'}</span><span class="warm">↑ ${off ? '–' : f1(r.max) + '°'}</span><span class="rv">RV ${off ? '–' : fPct(r.humidity)}</span></p>
|
||||
</div>`;
|
||||
});
|
||||
$('rooms').innerHTML = html.join('');
|
||||
fitRooms();
|
||||
}
|
||||
|
||||
/**
|
||||
* Houdt de modulekolom binnen zijn vaste hoogte. Bij drie modules past alles op ware grootte; komt er een
|
||||
* module bij (of groeit er een doordat je hem vastzet), dan wordt het geheel net genoeg verkleind. Zonder
|
||||
* dit duwt een vierde kaart de onderste strook van het scherm af.
|
||||
*/
|
||||
function fitRooms() {
|
||||
const el = $('rooms');
|
||||
el.style.transform = 'none';
|
||||
const scale = scaleToFit(el.scrollHeight, INSIDE_H);
|
||||
el.style.transform = scale < 1 ? `scale(${scale.toFixed(4)})` : '';
|
||||
}
|
||||
|
||||
function renderRain(rain, now) {
|
||||
@@ -97,14 +144,29 @@ function renderRain(rain, now) {
|
||||
function renderPressure(p) {
|
||||
setText('press', fHpa(p.current));
|
||||
setText('press-trend', trendLabel(p.trend));
|
||||
|
||||
const values = p.history.map((h) => h.hpa);
|
||||
const range = seriesRange(values);
|
||||
setText('press-range', range ? `${Math.round(range.min)} – ${Math.round(range.max)} hPa in 24 u` : '');
|
||||
setText('press-max', range ? String(Math.round(range.max)) : '–');
|
||||
setText('press-min', range ? String(Math.round(range.min)) : '–');
|
||||
$('press-path').setAttribute('d', sparklinePath(values, 560, 56));
|
||||
|
||||
// Hele uren op hun echte tijdspositie, plus "nu" op de rechterrand.
|
||||
const first = p.history[0];
|
||||
const last = p.history[p.history.length - 1];
|
||||
const ticks = first && last ? hourTicks(first.at, last.at, state.tz) : [];
|
||||
$('press-hours').innerHTML = ticks
|
||||
.map((t) => {
|
||||
// Een markering vlak op de linkerrand niet centreren, anders steekt het label buiten de grafiek.
|
||||
const style = t.percent < 3 ? 'left:0' : `left:${t.percent.toFixed(1)}%;transform:translateX(-50%)`;
|
||||
return `<span style="${style}">${formatTime(t.at, state.tz)}</span>`;
|
||||
})
|
||||
.concat(last ? ['<span style="right:0">nu</span>'] : [])
|
||||
.join('');
|
||||
}
|
||||
|
||||
function renderForecast(days) {
|
||||
$('forecast').innerHTML = days.map((d) => `<div class="a-day">
|
||||
$('forecast').innerHTML = days.map((d) => `<div class="a-day" data-date="${d.date}">
|
||||
<p class="a-dname">${dayAbbr(d.date, state.tz)}</p>
|
||||
<div class="a-icon">${icon(wmoIcon(d.weather_code))}</div>
|
||||
<p class="a-hl num">${fInt(d.temp_max)}°<span>${fInt(d.temp_min)}°</span></p>
|
||||
@@ -112,6 +174,113 @@ function renderForecast(days) {
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
/* ---------- uuroverzicht per dag ---------- */
|
||||
|
||||
let overlayTimer = null;
|
||||
let openOverlayId = 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) => `<span class="num">${fInt(h.temp)}°</span>`).join('');
|
||||
|
||||
const heights = barHeights(hourly.map((h) => h.precip_mm), 40, 2);
|
||||
$('ho-rain').innerHTML = hourly.map((h, i) => `<div class="ho-rbar">
|
||||
<i style="height:${heights[i].toFixed(1)}px"></i><span class="num">${fmm(h.precip_mm)}</span>
|
||||
</div>`).join('');
|
||||
|
||||
$('ho-wind').innerHTML = hourly.map((h) => {
|
||||
const deg = windArrowDeg(h.wind_dir);
|
||||
const arrow = deg === null
|
||||
? '<span class="ho-warrow off">–</span>'
|
||||
: `<span class="ho-warrow" style="transform:rotate(${deg}deg)">${windArrow()}</span>`;
|
||||
return `<div class="ho-wcell">${arrow}<span class="num">${fKmh(h.wind_kmh)}</span></div>`;
|
||||
}).join('');
|
||||
|
||||
$('ho-hours').innerHTML = hourly.map((h) => `<span class="num">${formatHour(h.at, state.tz)}</span>`).join('');
|
||||
}
|
||||
|
||||
function openHourOverlay(date) {
|
||||
const day = (state.data?.forecast || []).find((d) => d.date === date);
|
||||
if (!day) return;
|
||||
|
||||
setText('ho-date', formatFullDate(day.date, state.tz));
|
||||
renderHourlyCharts(day.hourly || []);
|
||||
showOverlay('hour-overlay', 'ho-bar-fill');
|
||||
}
|
||||
|
||||
/* ---------- regenradar ---------- */
|
||||
|
||||
function renderRainDetails(rain) {
|
||||
setText('ro-today', f1(rain.today));
|
||||
$('ro-days').innerHTML = rain.previous_days
|
||||
.map((p) => `<p><span>${dayAbbr(p.date, state.tz)}</span><b class="num">${fmm(p.mm)}</b></p>`)
|
||||
.join('');
|
||||
setText('ro-month', `${monthName(new Date(), state.tz)} ${f1(rain.month_total)} mm`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zet het radarbeeld klaar. De bucket in de URL verandert elke RADAR_BUCKET_MS, zodat de browser
|
||||
* binnen dat venster het al geladen beeld hergebruikt (tik = meteen zichtbaar) en daarna vanzelf
|
||||
* een verser beeld ophaalt. Er wordt niets op de achtergrond geladen zolang niemand kijkt.
|
||||
*/
|
||||
function loadRadarImage() {
|
||||
const img = $('ro-img');
|
||||
const status = $('ro-status');
|
||||
const src = `api/radar.php?t=${Math.floor(Date.now() / RADAR_BUCKET_MS)}`;
|
||||
if (img.getAttribute('src') === src && img.complete && img.naturalWidth > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
status.textContent = 'Radar laden…';
|
||||
status.hidden = false;
|
||||
img.hidden = true;
|
||||
img.onload = () => {
|
||||
status.hidden = true;
|
||||
img.hidden = false;
|
||||
};
|
||||
img.onerror = () => {
|
||||
status.textContent = 'Radar niet beschikbaar';
|
||||
status.hidden = false;
|
||||
img.hidden = true;
|
||||
};
|
||||
img.setAttribute('src', src);
|
||||
}
|
||||
|
||||
function openRadarOverlay() {
|
||||
if (!state.data) return;
|
||||
renderRainDetails(state.data.rain);
|
||||
loadRadarImage();
|
||||
showOverlay('radar-overlay', 'ro-bar-fill');
|
||||
}
|
||||
|
||||
/* ---------- gedeelde overlay-machinerie ---------- */
|
||||
|
||||
/** Toont een overlay, start de 20s-balk opnieuw en zet de sluittimer. */
|
||||
function showOverlay(id, barFillId) {
|
||||
closeOverlay();
|
||||
|
||||
const fill = $(barFillId);
|
||||
fill.style.transition = 'none';
|
||||
fill.style.transform = 'scaleX(1)';
|
||||
fill.getBoundingClientRect(); // forceer reflow zodat de volgende transitie opnieuw start
|
||||
fill.style.transition = `transform ${OVERLAY_MS}ms linear`;
|
||||
fill.style.transform = 'scaleX(0)';
|
||||
|
||||
$(id).hidden = false;
|
||||
openOverlayId = id;
|
||||
overlayTimer = setTimeout(closeOverlay, OVERLAY_MS);
|
||||
}
|
||||
|
||||
function closeOverlay() {
|
||||
clearTimeout(overlayTimer);
|
||||
overlayTimer = null;
|
||||
if (openOverlayId !== null) {
|
||||
$(openOverlayId).hidden = true;
|
||||
openOverlayId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderBanner() {
|
||||
const banner = $('banner');
|
||||
const errors = state.data ? state.data.errors : [];
|
||||
@@ -142,6 +311,7 @@ function render() {
|
||||
if (d) {
|
||||
renderMeasurement(now);
|
||||
renderOutdoor(d.outdoor);
|
||||
renderTodayChart(d.outdoor_history);
|
||||
renderRooms(d.indoor);
|
||||
renderRain(d.rain, now);
|
||||
renderPressure(d.pressure || { current: null, trend: null, history: [] });
|
||||
@@ -232,9 +402,13 @@ async function requestWakeLock() {
|
||||
|
||||
/* ---------- service worker ---------- */
|
||||
|
||||
let swRegistration = null;
|
||||
|
||||
function registerServiceWorker() {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
navigator.serviceWorker.register('./sw.js').catch((err) => console.warn('weerscherm: service worker', err));
|
||||
navigator.serviceWorker.register('./sw.js')
|
||||
.then((reg) => { swRegistration = reg; })
|
||||
.catch((err) => console.warn('weerscherm: service worker', err));
|
||||
let reloading = false;
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
if (reloading) return;
|
||||
@@ -243,8 +417,33 @@ function registerServiceWorker() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Vraagt de browser expliciet een nieuwe sw.js te controleren. Op een kiosk-scherm dat nooit navigeert
|
||||
* (geen tabwissel, geen echte reload) gebeurt dat anders alleen impliciet en onvoorspelbaar laat — soms
|
||||
* pas bij de volgende dagelijkse 04:00-reload. Met skipWaiting + clients.claim in sw.js activeert een
|
||||
* gevonden update zich meteen, en de controllerchange-listener hierboven herlaadt de pagina dan vanzelf.
|
||||
*/
|
||||
function checkForUpdate() {
|
||||
swRegistration?.update().catch(() => {});
|
||||
}
|
||||
|
||||
/* ---------- start ---------- */
|
||||
|
||||
$('forecast').addEventListener('click', (e) => {
|
||||
const day = e.target.closest('.a-day');
|
||||
if (day) openHourOverlay(day.dataset.date);
|
||||
});
|
||||
$('hour-overlay').addEventListener('click', closeOverlay);
|
||||
$('radar-overlay').addEventListener('click', closeOverlay);
|
||||
$('rain-block').addEventListener('click', openRadarOverlay);
|
||||
|
||||
$('rooms').addEventListener('click', (e) => {
|
||||
const room = e.target.closest('.a-room');
|
||||
if (!room || !state.data) return;
|
||||
state.pinnedRoomId = state.pinnedRoomId === room.dataset.id ? null : room.dataset.id;
|
||||
renderRooms(state.data.indoor);
|
||||
});
|
||||
|
||||
window.addEventListener('resize', fit);
|
||||
window.addEventListener('orientationchange', () => setTimeout(fit, 50));
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
@@ -252,7 +451,7 @@ document.addEventListener('visibilitychange', () => {
|
||||
fit();
|
||||
refresh();
|
||||
requestWakeLock();
|
||||
registerServiceWorker();
|
||||
checkForUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -260,6 +459,7 @@ fit();
|
||||
render();
|
||||
refresh();
|
||||
setInterval(refresh, REFRESH_MS);
|
||||
setInterval(checkForUpdate, REFRESH_MS);
|
||||
scheduleClock();
|
||||
scheduleMidnight();
|
||||
scheduleReload();
|
||||
|
||||
+94
-10
@@ -148,23 +148,107 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Herschikt kamers zodat de kamer met `pinnedId` vooraan staat; de rest behoudt de onderlinge volgorde.
|
||||
* Zonder pinnedId, of als die niet voorkomt of al vooraan staat, blijft de oorspronkelijke array (referentie) terug.
|
||||
*/
|
||||
export function pinRoom(rooms, pinnedId) {
|
||||
if (!pinnedId) return rooms;
|
||||
const index = rooms.findIndex((r) => r.id === pinnedId);
|
||||
if (index <= 0) return rooms;
|
||||
const copy = rooms.slice();
|
||||
const [pinned] = copy.splice(index, 1);
|
||||
copy.unshift(pinned);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markeringen op hele uren binnen een tijdvenster, uitgelijnd op de klok: elke `stepHours` uur
|
||||
* gerekend vanaf middernacht in de gegeven tijdzone, dus 00:00, 06:00, 12:00, 18:00 bij een stap van 6.
|
||||
* Per markering het tijdstip en de positie in procenten van het venster.
|
||||
*
|
||||
* Er wordt uur voor uur door het venster gelopen en telkens de lokale klok geraadpleegd, zodat de
|
||||
* zomertijdwissel vanzelf goed gaat: een dag van 23 of 25 uur levert gewoon de juiste hele uren op.
|
||||
* Markeringen binnen `minGapPercent` van het einde vallen weg; daar staat het label "nu".
|
||||
*/
|
||||
export function hourTicks(startIso, endIso, tz, stepHours = 6, minGapPercent = 9) {
|
||||
const t0 = new Date(startIso).getTime();
|
||||
const t1 = new Date(endIso).getTime();
|
||||
if (Number.isNaN(t0) || Number.isNaN(t1) || t1 <= t0) return [];
|
||||
|
||||
const hourMs = 3600000;
|
||||
const span = t1 - t0;
|
||||
const ticks = [];
|
||||
for (let t = Math.ceil(t0 / hourMs) * hourMs; t <= t1; t += hourMs) {
|
||||
const at = new Date(t).toISOString();
|
||||
if (Number(formatHour(at, tz)) % stepHours !== 0) continue;
|
||||
const percent = ((t - t0) / span) * 100;
|
||||
if (percent > 100 - minGapPercent) break;
|
||||
ticks.push({ at, percent });
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
/** Schaal om `natural` px binnen `available` px te passen; nooit vergroten, nooit negatief. */
|
||||
export function scaleToFit(natural, available) {
|
||||
if (!(natural > 0) || !(available > 0)) return 1;
|
||||
return Math.min(1, available / natural);
|
||||
}
|
||||
|
||||
@@ -30,3 +30,8 @@ export function icon(name) {
|
||||
const inner = ICONS[name] || ICONS.cloud;
|
||||
return `<svg class="ic" viewBox="0 0 48 48" aria-hidden="true">${inner}</svg>`;
|
||||
}
|
||||
|
||||
/** Compacte windpijl, wijst standaard naar boven. Draai 'm met een inline transform naar de juiste hoek. */
|
||||
export function windArrow() {
|
||||
return '<svg class="wind-ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 19V5M6.5 10.5 12 5l5.5 5.5"/></svg>';
|
||||
}
|
||||
|
||||
+59
-3
@@ -27,7 +27,17 @@
|
||||
|
||||
<section class="a-hero">
|
||||
<p class="a-label">Buiten <span class="a-trend" id="trend"></span><span class="a-hum" id="out-hum"></span></p>
|
||||
<div class="a-temprow">
|
||||
<p class="a-temp num" id="temp">–</p>
|
||||
<div class="a-today" id="today-chart" hidden>
|
||||
<p class="a-tcap">Vandaag</p>
|
||||
<svg class="a-tspark" viewBox="0 0 200 96" preserveAspectRatio="none" aria-hidden="true">
|
||||
<line class="a-spark-base" x1="0" y1="95.5" x2="200" y2="95.5"/>
|
||||
<path class="a-spark-line" id="today-path" d=""/>
|
||||
</svg>
|
||||
<div class="a-px num" id="today-hours"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="a-range">
|
||||
<div class="a-track"><span class="a-mark" id="mark" style="left:50%"></span></div>
|
||||
<div class="a-ends num">
|
||||
@@ -36,18 +46,26 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="a-pressure">
|
||||
<p class="a-plabel">Luchtdruk <b class="num" id="press">–</b><span class="a-trend" id="press-trend"></span><span class="a-prange num" id="press-range"></span></p>
|
||||
<p class="a-plabel">Luchtdruk <b class="num" id="press">–</b><span class="a-trend" id="press-trend"></span></p>
|
||||
<div class="a-pgrid">
|
||||
<div class="a-py num"><span id="press-max">–</span><span id="press-min">–</span></div>
|
||||
<svg class="a-spark" viewBox="0 0 560 56" preserveAspectRatio="none" aria-hidden="true">
|
||||
<line class="a-spark-grid" x1="140" y1="0" x2="140" y2="56"/>
|
||||
<line class="a-spark-grid" x1="280" y1="0" x2="280" y2="56"/>
|
||||
<line class="a-spark-grid" x1="420" y1="0" x2="420" y2="56"/>
|
||||
<line class="a-spark-base" x1="0" y1="55.5" x2="560" y2="55.5"/>
|
||||
<path class="a-spark-line" id="press-path" d=""/>
|
||||
</svg>
|
||||
<div></div>
|
||||
<div class="a-px num" id="press-hours"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="a-inside" id="rooms"></section>
|
||||
<section class="a-inside"><div id="rooms"></div></section>
|
||||
|
||||
<section class="a-bottom">
|
||||
<div>
|
||||
<div id="rain-block">
|
||||
<p class="a-sub">Regen</p>
|
||||
<div class="a-rainrow">
|
||||
<div>
|
||||
@@ -62,6 +80,44 @@
|
||||
</section>
|
||||
|
||||
<div class="banner" id="banner">Gegevens laden…</div>
|
||||
|
||||
<div class="overlay" id="hour-overlay" hidden>
|
||||
<div class="ho-bar"><i id="ho-bar-fill"></i></div>
|
||||
<header class="ho-head">
|
||||
<span class="ho-date" id="ho-date"></span>
|
||||
<span class="ho-hint">tik om te sluiten</span>
|
||||
</header>
|
||||
<div class="ho-temp">
|
||||
<svg class="ho-temp-svg" viewBox="0 0 1045 64" preserveAspectRatio="none" aria-hidden="true">
|
||||
<path class="ho-temp-line" id="ho-temp-path" d=""/>
|
||||
</svg>
|
||||
<div class="ho-grid" id="ho-temp-nums"></div>
|
||||
</div>
|
||||
<div class="ho-grid ho-rain" id="ho-rain"></div>
|
||||
<div class="ho-grid ho-wind" id="ho-wind"></div>
|
||||
<div class="ho-grid ho-hours" id="ho-hours"></div>
|
||||
</div>
|
||||
|
||||
<div class="overlay" id="radar-overlay" hidden>
|
||||
<div class="ho-bar"><i id="ro-bar-fill"></i></div>
|
||||
<header class="ho-head">
|
||||
<span class="ho-date">Regen</span>
|
||||
<span class="ho-hint">tik om te sluiten</span>
|
||||
</header>
|
||||
<div class="ro-body">
|
||||
<div class="ro-map">
|
||||
<p class="ro-status" id="ro-status">Radar laden…</p>
|
||||
<img class="ro-img" id="ro-img" alt="Regenradar" hidden>
|
||||
</div>
|
||||
<div class="ro-side">
|
||||
<p class="ro-big"><span class="num" id="ro-today">–</span> mm</p>
|
||||
<p class="ro-sub">vandaag</p>
|
||||
<div class="ro-days" id="ro-days"></div>
|
||||
<p class="ro-month num" id="ro-month">–</p>
|
||||
<p class="ro-source">bron: Buienradar.nl</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<div id="rotate">Draai de iPad een kwartslag</div>
|
||||
|
||||
+89
-11
@@ -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); }
|
||||
@@ -80,7 +80,14 @@ html, body {
|
||||
.a-hero { align-self: center; }
|
||||
.a-label { font-size: 22px; color: var(--muted); font-weight: 500; display: flex; gap: 14px; align-items: baseline; }
|
||||
.a-trend { font-size: 17px; font-weight: 400; }
|
||||
.a-temp { font-size: 200px; font-weight: 200; letter-spacing: -.045em; line-height: .95; margin: 4px 0 18px -8px; }
|
||||
.a-temprow { display: flex; align-items: center; gap: 28px; margin: 4px 0 10px 0; }
|
||||
.a-temp { font-size: 200px; font-weight: 200; letter-spacing: -.045em; line-height: .95; margin-left: -8px; }
|
||||
|
||||
/* Temperatuurverloop van vandaag, in de ruimte rechts van het grote getal */
|
||||
.a-today { flex: 1; min-width: 0; }
|
||||
.a-today[hidden] { display: none; }
|
||||
.a-tcap { font-size: 15px; color: var(--muted); margin-bottom: 4px; }
|
||||
.a-tspark { display: block; width: 100%; height: 96px; overflow: visible; }
|
||||
.a-hum { font-size: 17px; font-weight: 400; }
|
||||
.a-hum:not(:empty)::before { content: "·"; margin-right: 14px; }
|
||||
.a-range { width: 560px; }
|
||||
@@ -89,17 +96,23 @@ html, body {
|
||||
.a-ends { display: flex; justify-content: space-between; margin-top: 14px; font-size: 17px; color: var(--muted); }
|
||||
.a-ends b { font-size: 24px; font-weight: 600; margin-right: 6px; }
|
||||
|
||||
/* Luchtdruk, 24 uur */
|
||||
.a-pressure { width: 560px; margin-top: 18px; }
|
||||
/* Luchtdruk, 24 uur. Grafiek met een y-as (hoogste/laagste waarde) en tijdlabels op de x-as. */
|
||||
.a-pressure { width: 560px; margin-top: 14px; }
|
||||
.a-plabel { font-size: 17px; color: var(--muted); display: flex; gap: 12px; align-items: baseline; margin-bottom: 6px; }
|
||||
.a-plabel b { font-size: 20px; font-weight: 600; color: var(--ink); }
|
||||
.a-prange { margin-left: auto; font-size: 14px; }
|
||||
.a-spark { display: block; width: 560px; height: 56px; overflow: visible; }
|
||||
.a-pgrid { display: grid; grid-template-columns: 44px 1fr; column-gap: 8px; row-gap: 3px; }
|
||||
.a-py { display: flex; flex-direction: column; justify-content: space-between; text-align: right; font-size: 13px; color: var(--muted); line-height: 1; padding: 1px 0; }
|
||||
.a-px { position: relative; height: 17px; font-size: 13px; color: var(--muted); }
|
||||
.a-px span { position: absolute; top: 0; white-space: nowrap; }
|
||||
.a-spark { display: block; width: 100%; height: 56px; overflow: visible; }
|
||||
.a-spark-base { stroke: var(--line); stroke-width: 1; }
|
||||
.a-spark-grid { stroke: var(--line); stroke-width: 1; vector-effect: non-scaling-stroke; opacity: .6; }
|
||||
.a-spark-line { fill: none; stroke: var(--rain); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
|
||||
|
||||
/* Binnen */
|
||||
.a-inside { align-self: center; }
|
||||
/* Binnen. Vaste hoogte met overflow verborgen: hoeveel modules er ook zijn, de kolom duwt nooit de
|
||||
onderste strook van het scherm af. Past de inhoud niet, dan verkleint fitRooms() hem met een schaal. */
|
||||
.a-inside { align-self: center; height: 412px; display: flex; align-items: center; overflow: hidden; }
|
||||
#rooms { width: 100%; transform-origin: center center; }
|
||||
.a-room { padding: 12px 0; border-top: 1px solid var(--line); }
|
||||
.a-room:first-child { border-top: 0; }
|
||||
.a-rname { font-size: 19px; color: var(--muted); display: flex; justify-content: space-between; align-items: center; }
|
||||
@@ -108,8 +121,13 @@ html, body {
|
||||
.pill.medium { background: var(--warm); }
|
||||
.pill.bad { background: var(--bad); }
|
||||
.pill.unknown { background: var(--line); color: var(--muted); }
|
||||
.a-rtemp { font-size: 50px; font-weight: 300; line-height: 1.05; }
|
||||
.a-rrow { display: flex; align-items: center; gap: 16px; }
|
||||
.a-rtemp { font-size: 50px; font-weight: 300; line-height: 1.05; transition: font-size .25s ease; flex: none; }
|
||||
/* Overal even hoog: zo zijn de kamers onderling vergelijkbaar, en groeit een vastgezette kaart niet
|
||||
zo ver door dat de speling onder de kolom opraakt. */
|
||||
.a-rspark { flex: 1; min-width: 0; height: 40px; overflow: visible; }
|
||||
.a-room.off .a-rtemp { color: var(--muted); }
|
||||
.a-room.pinned .a-rtemp { font-size: 92px; }
|
||||
.a-rmm { font-size: 17px; display: flex; gap: 18px; }
|
||||
.a-rmm .rv { color: var(--muted); }
|
||||
|
||||
@@ -129,11 +147,11 @@ html, body {
|
||||
.a-bar.now span { color: var(--ink); font-weight: 600; }
|
||||
|
||||
/* Verwachting */
|
||||
.a-fc { display: grid; grid-template-columns: repeat(5, 1fr); text-align: center; }
|
||||
.a-fc { display: grid; grid-template-columns: repeat(6, 1fr); text-align: center; }
|
||||
.a-day { display: flex; flex-direction: column; align-items: center; gap: 6px; border-left: 1px solid var(--line); }
|
||||
.a-day:first-child { border-left: 0; }
|
||||
.a-dname { font-size: 19px; font-weight: 600; }
|
||||
.a-icon { width: 58px; height: 58px; }
|
||||
.a-icon { width: 50px; height: 50px; }
|
||||
.a-hl { font-size: 28px; font-weight: 500; }
|
||||
.a-hl span { font-size: 22px; color: var(--muted); font-weight: 400; margin-left: 6px; }
|
||||
.a-fr { font-size: 16px; color: var(--rain); }
|
||||
@@ -162,6 +180,66 @@ html, body {
|
||||
}
|
||||
.banner small { display: block; margin-top: 16px; font-size: 20px; color: var(--muted); }
|
||||
|
||||
/* Overlays (uuroverzicht, regenradar): tik om te openen, tik erop of wacht 20s om te sluiten.
|
||||
De .ho-*-chrome (balk, kop) wordt door beide overlays gedeeld. */
|
||||
.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;
|
||||
}
|
||||
.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); }
|
||||
|
||||
/* Regenradar */
|
||||
.ro-body { display: flex; gap: 56px; margin: 0 30px; min-height: 0; }
|
||||
.ro-map { display: flex; align-items: center; justify-content: center; width: 560px; flex: none; }
|
||||
.ro-img { height: 512px; width: auto; display: block; border-radius: 10px; }
|
||||
.ro-status { font-size: 22px; color: var(--muted); }
|
||||
|
||||
.ro-side { display: flex; flex-direction: column; justify-content: center; }
|
||||
.ro-big { font-size: 26px; color: var(--rain); }
|
||||
.ro-big span { font-size: 76px; font-weight: 300; line-height: 1; }
|
||||
.ro-sub { font-size: 19px; color: var(--muted); margin-bottom: 26px; }
|
||||
.ro-days p { display: flex; justify-content: space-between; width: 260px; font-size: 21px; color: var(--muted); padding: 7px 0; border-top: 1px solid var(--line); }
|
||||
.ro-days b { color: var(--ink); font-weight: 600; }
|
||||
.ro-month { width: 260px; font-size: 19px; color: var(--muted); margin-top: 18px; }
|
||||
.ro-source { font-size: 15px; color: var(--muted); margin-top: auto; padding-top: 22px; }
|
||||
|
||||
#rotate {
|
||||
display: none;
|
||||
position: fixed;
|
||||
|
||||
+13
-5
@@ -1,6 +1,6 @@
|
||||
/* Service worker: app-shell cache-first, API network-first met timeout en fallback op de laatste response. */
|
||||
|
||||
const VERSION = 'weerscherm-v2';
|
||||
const VERSION = 'weerscherm-v12';
|
||||
const SHELL = [
|
||||
'./',
|
||||
'./index.html',
|
||||
@@ -14,7 +14,8 @@ const SHELL = [
|
||||
'./icons/icon-192.png',
|
||||
'./icons/icon-512.png',
|
||||
];
|
||||
const API_PATH = '/api/data.php';
|
||||
const API_PREFIX = '/api/';
|
||||
const DATA_PATH = '/api/data.php';
|
||||
const API_TIMEOUT_MS = 8000;
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
@@ -37,7 +38,7 @@ self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
if (url.pathname.endsWith(API_PATH)) {
|
||||
if (url.pathname.includes(API_PREFIX)) {
|
||||
event.respondWith(networkFirst(req));
|
||||
} else {
|
||||
event.respondWith(cacheFirst(req));
|
||||
@@ -46,25 +47,32 @@ self.addEventListener('fetch', (event) => {
|
||||
|
||||
async function networkFirst(req) {
|
||||
const cache = await caches.open(VERSION);
|
||||
const url = new URL(req.url);
|
||||
// Sleutel zonder querystring: het radarbeeld krijgt elke paar minuten een andere ?t=, en zonder
|
||||
// normaliseren zou de cache vollopen met varianten en zou offline nooit een treffer geven.
|
||||
const key = url.origin + url.pathname;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(req, { signal: controller.signal, cache: 'no-store' });
|
||||
if (res.ok) {
|
||||
cache.put(req, res.clone());
|
||||
cache.put(key, res.clone());
|
||||
}
|
||||
return res;
|
||||
} catch (err) {
|
||||
const cached = await cache.match(req);
|
||||
const cached = await cache.match(key);
|
||||
if (cached) {
|
||||
const headers = new Headers(cached.headers);
|
||||
headers.set('X-From-Cache', '1');
|
||||
return new Response(await cached.arrayBuffer(), { status: 200, statusText: 'OK', headers });
|
||||
}
|
||||
if (url.pathname.endsWith(DATA_PATH)) {
|
||||
return new Response(JSON.stringify({ errors: ['offline'] }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' },
|
||||
});
|
||||
}
|
||||
return new Response('offline', { status: 503, headers: { 'Cache-Control': 'no-store' } });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm;
|
||||
|
||||
/**
|
||||
* Haalt de geanimeerde regenradar op bij Buienradar. Vrij te gebruiken met bronvermelding;
|
||||
* de frontend toont daarom "bron: Buienradar.nl" bij het beeld.
|
||||
*
|
||||
* De endpoint leidt door naar een CDN, dus de meegegeven HttpClient moet redirects volgen.
|
||||
* Het beeld is 550 x 512 met de tijd erin gebrand; opgegeven afmetingen worden genegeerd.
|
||||
*/
|
||||
final class BuienradarClient
|
||||
{
|
||||
private const URL = 'https://image.buienradar.nl/2.0/image/animation/RadarmapRainNL';
|
||||
|
||||
public function __construct(private readonly HttpClient $http)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return string de ruwe bytes van een geanimeerde GIF */
|
||||
public function rainRadar(): string
|
||||
{
|
||||
$res = $this->http->get(self::URL, ['w' => 550, 'h' => 512], ['Accept: image/gif,image/*']);
|
||||
|
||||
if ($res->status !== 200) {
|
||||
throw new UpstreamException("Buienradar: HTTP {$res->status}");
|
||||
}
|
||||
if (!str_starts_with($res->body, 'GIF8')) {
|
||||
throw new UpstreamException('Buienradar: antwoord is geen GIF');
|
||||
}
|
||||
return $res->body;
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,9 @@ final class Config
|
||||
'stations' => 300,
|
||||
'measure' => 900,
|
||||
'pressure' => 900,
|
||||
'temperature' => 900,
|
||||
'forecast' => 3600,
|
||||
'radar' => 240,
|
||||
],
|
||||
'storage_path' => null,
|
||||
];
|
||||
|
||||
+85
-10
@@ -10,6 +10,8 @@ namespace Weerscherm;
|
||||
*/
|
||||
final class DashboardService
|
||||
{
|
||||
private const PRESSURE_HOURS = 24;
|
||||
|
||||
private const ERR_NETATMO_AUTH = 'netatmo_auth';
|
||||
private const ERR_NETATMO = 'netatmo_unavailable';
|
||||
private const ERR_FORECAST = 'forecast_unavailable';
|
||||
@@ -48,6 +50,7 @@ final class DashboardService
|
||||
|
||||
$rain = $this->rain($station, $now);
|
||||
$pressure = $this->pressure($station, $now);
|
||||
$outdoorHistory = $this->outdoorHistory($station, $now);
|
||||
$forecast = $this->forecast($station, $tz);
|
||||
|
||||
return [
|
||||
@@ -66,7 +69,8 @@ final class DashboardService
|
||||
'max_at' => $this->iso($station['outdoor']['max_at'], $tz),
|
||||
'humidity' => $station['outdoor']['humidity'],
|
||||
],
|
||||
'indoor' => $station['indoor'],
|
||||
'outdoor_history' => $outdoorHistory,
|
||||
'indoor' => $this->indoorWithHistory($station['indoor'], $station['device_id'], $now),
|
||||
'rain' => $rain,
|
||||
'pressure' => $pressure,
|
||||
'forecast' => $forecast['days'],
|
||||
@@ -101,18 +105,73 @@ final class DashboardService
|
||||
if ($station['device_id'] === null) {
|
||||
return $base + ['history' => [], 'stale' => false];
|
||||
}
|
||||
$begin = $now->getTimestamp() - PressureHistory::HOURS * 3600;
|
||||
$begin = $now->getTimestamp() - self::PRESSURE_HOURS * 3600;
|
||||
$measure = $this->fetch(
|
||||
'pressure',
|
||||
fn() => $this->source->pressureHistory($station['device_id'], $begin, $now->getTimestamp()),
|
||||
self::ERR_NETATMO,
|
||||
);
|
||||
return $base + [
|
||||
'history' => $measure['data'] === null ? [] : PressureHistory::map($measure['data'], $now->getTimezone()),
|
||||
'history' => $measure['data'] === null ? [] : MeasureSeries::map($measure['data'], $now->getTimezone(), 'hpa'),
|
||||
'stale' => $measure['stale'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Temperatuurverloop van vandaag: vanaf middernacht lokale tijd tot nu.
|
||||
*
|
||||
* @return array{points: list<array{at: string, temp: float}>, stale: bool}
|
||||
*/
|
||||
private function outdoorHistory(array $station, \DateTimeImmutable $now): array
|
||||
{
|
||||
$moduleId = $station['outdoor_module_id'];
|
||||
if ($station['device_id'] === null || $moduleId === null) {
|
||||
return ['points' => [], 'stale' => false];
|
||||
}
|
||||
return $this->temperatureHistory($station['device_id'], $moduleId, $now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Temperatuurverloop van vandaag voor één module, apart gecachet per module.
|
||||
*
|
||||
* @return array{points: list<array{at: string, temp: float}>, stale: bool}
|
||||
*/
|
||||
private function temperatureHistory(string $deviceId, ?string $moduleId, \DateTimeImmutable $now): array
|
||||
{
|
||||
$begin = $now->setTime(0, 0, 0)->getTimestamp();
|
||||
$measure = $this->fetch(
|
||||
'temperature-' . ($moduleId ?? $deviceId),
|
||||
fn() => $this->source->temperatureHistory($deviceId, $moduleId, $begin, $now->getTimestamp()),
|
||||
self::ERR_NETATMO,
|
||||
'temperature',
|
||||
);
|
||||
return [
|
||||
'points' => $measure['data'] === null ? [] : MeasureSeries::map($measure['data'], $now->getTimezone(), 'temp'),
|
||||
'stale' => $measure['stale'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Zet het temperatuurverloop van vandaag bij elke binnenmodule. Mislukt er één, dan blijft die lijst
|
||||
* leeg (het grafiekje verdwijnt) en staat de foutcode al in errors; een eigen stale-vlag per kamer
|
||||
* zou het contract nodeloos zwaar maken.
|
||||
*
|
||||
* @param list<array<string, mixed>> $rooms
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function indoorWithHistory(array $rooms, ?string $deviceId, \DateTimeImmutable $now): array
|
||||
{
|
||||
if ($deviceId === null) {
|
||||
return $rooms;
|
||||
}
|
||||
foreach ($rooms as &$room) {
|
||||
$moduleId = $room['id'] === $deviceId ? null : $room['id'];
|
||||
$room['history'] = $this->temperatureHistory($deviceId, $moduleId, $now)['points'];
|
||||
}
|
||||
unset($room);
|
||||
return $rooms;
|
||||
}
|
||||
|
||||
/** @return array{days: list<array>, stale: bool} */
|
||||
private function forecast(array $station, \DateTimeZone $tz): array
|
||||
{
|
||||
@@ -122,23 +181,39 @@ 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);
|
||||
$daily = is_array($result['data']['daily'] ?? null) ? $result['data']['daily'] : null;
|
||||
if ($daily === null) {
|
||||
// Data ontbreekt (echte storing, al gemeld door fetch()), of de cache staat nog in een ouder
|
||||
// formaat na een deploy dat de opgeslagen vorm wijzigde. In beide gevallen liever zichtbaar
|
||||
// niets tonen dan stil een lege verwachting zonder foutmelding.
|
||||
if ($result['data'] !== null) {
|
||||
$this->addError(self::ERR_FORECAST);
|
||||
}
|
||||
return ['days' => [], 'stale' => true];
|
||||
}
|
||||
|
||||
$days = ForecastMapper::map($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']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache-first per bron. Bij een fout: stale cache (ongeacht leeftijd) en een foutcode, anders null.
|
||||
*
|
||||
* @param callable(): array $fetcher
|
||||
* @param ?string $ttlKey losse TTL-sleutel wanneer de cachesleutel per module verschilt
|
||||
* @return array{data: ?array, stale: bool}
|
||||
*/
|
||||
private function fetch(string $key, callable $fetcher, string $errorCode): array
|
||||
private function fetch(string $key, callable $fetcher, string $errorCode, ?string $ttlKey = null): array
|
||||
{
|
||||
$now = ($this->now ?? new \DateTimeImmutable('now'))->getTimestamp();
|
||||
$ttl = $this->ttl[$key] ?? 0;
|
||||
$ttl = $this->ttl[$ttlKey ?? $key] ?? 0;
|
||||
if ($ttl > 0 && $this->cache->isFresh($key, $ttl, $now)) {
|
||||
return ['data' => $this->cache->read($key)['data'], 'stale' => false];
|
||||
}
|
||||
|
||||
+64
-6
@@ -38,6 +38,8 @@ final class FixtureSource implements WeatherSource
|
||||
'dashboard_data' => ['time_utc' => $at('-4 minutes'), 'Temperature' => 18.9, 'CO2' => 1180, 'Humidity' => 52, 'min_temp' => 18.1, 'max_temp' => 19.6, 'temp_trend' => 'up']],
|
||||
['_id' => '03:00:00:00:00:03', 'type' => 'NAModule4', 'module_name' => 'Kantoor', 'reachable' => true,
|
||||
'dashboard_data' => ['time_utc' => $at('-4 minutes'), 'Temperature' => 20.2, 'CO2' => 1640, 'Humidity' => 44, 'min_temp' => 19.0, 'max_temp' => 21.0, 'temp_trend' => 'stable']],
|
||||
['_id' => '03:00:00:00:00:05', 'type' => 'NAModule4', 'module_name' => 'Zolder', 'reachable' => true,
|
||||
'dashboard_data' => ['time_utc' => $at('-5 minutes'), 'Temperature' => 17.4, 'CO2' => 780, 'Humidity' => 55, 'min_temp' => 16.2, 'max_temp' => 18.0, 'temp_trend' => 'up']],
|
||||
['_id' => '05:00:00:00:00:04', 'type' => 'NAModule3', 'module_name' => 'Regenmeter', 'reachable' => true,
|
||||
'dashboard_data' => ['time_utc' => $at('-3 minutes'), 'Rain' => 0, 'sum_rain_1' => 0, 'sum_rain_24' => 2.4]],
|
||||
],
|
||||
@@ -61,25 +63,81 @@ final class FixtureSource implements WeatherSource
|
||||
{
|
||||
$body = [];
|
||||
for ($ts = $begin - ($begin % 1800); $ts <= $end; $ts += 1800) {
|
||||
$i = ($end - $ts) / 1800;
|
||||
// Hele halfuren sinds het einde; intdiv houdt $i een int, zodat de modulo hieronder geen
|
||||
// deprecation geeft (en de waarschuwing niet vóór de JSON in de response belandt).
|
||||
$i = intdiv($end - $ts, 1800);
|
||||
$body[(string) $ts] = [round(1012 + 3 * sin($i / 8) + ($i % 5) * 0.1, 1)];
|
||||
}
|
||||
return $body;
|
||||
}
|
||||
|
||||
public function temperatureHistory(string $deviceId, ?string $moduleId, int $begin, int $end): array
|
||||
{
|
||||
// Dagcurve tussen de min en max die stationsData() voor deze module meldt: koudst rond 06:00,
|
||||
// warmst rond 15:00. Binnen beweegt minder en later dan buiten, zodat de grafiekjes verschillen.
|
||||
[$min, $max, $shift] = match ($moduleId) {
|
||||
'02:00:00:00:00:01' => [9.2, 19.8, 10], // buiten
|
||||
'03:00:00:00:00:02' => [18.1, 19.6, 13], // slaapkamer
|
||||
'03:00:00:00:00:03' => [19.0, 21.0, 14], // kantoor
|
||||
'03:00:00:00:00:05' => [16.2, 18.0, 15], // zolder
|
||||
default => [20.4, 22.1, 12], // basisstation
|
||||
};
|
||||
|
||||
$body = [];
|
||||
for ($ts = $begin - ($begin % 1800); $ts <= $end; $ts += 1800) {
|
||||
if ($ts < $begin) {
|
||||
continue;
|
||||
}
|
||||
$at = (new \DateTimeImmutable('@' . $ts))->setTimezone($this->now->getTimezone());
|
||||
$hour = (int) $at->format('G') + (int) $at->format('i') / 60;
|
||||
$t = $min + ($max - $min) * (0.5 + 0.5 * sin((($hour - $shift) / 24) * 2 * M_PI));
|
||||
$body[(string) $ts] = [round($t, 1)];
|
||||
}
|
||||
return $body;
|
||||
}
|
||||
|
||||
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 [
|
||||
'daily' => [
|
||||
'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],
|
||||
'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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm;
|
||||
|
||||
/** Zet het "daily"-blok van Open-Meteo om naar 5 dagen vanaf morgen (index 0 is vandaag). */
|
||||
/** Zet het "daily"-blok van Open-Meteo om naar 6 dagen, inclusief vandaag (index 0). */
|
||||
final class ForecastMapper
|
||||
{
|
||||
private const DAYS = 5;
|
||||
private const DAYS = 6;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $daily
|
||||
@@ -17,7 +17,7 @@ final class ForecastMapper
|
||||
{
|
||||
$dates = is_array($daily['time'] ?? null) ? $daily['time'] : [];
|
||||
$out = [];
|
||||
for ($i = 1; $i <= self::DAYS && isset($dates[$i]); $i++) {
|
||||
for ($i = 0; $i < self::DAYS && isset($dates[$i]); $i++) {
|
||||
if (!is_string($dates[$i])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm;
|
||||
|
||||
/**
|
||||
* Zet het "hourly"-blok van Open-Meteo om naar uurpunten per kalenderdatum, om het andere uur.
|
||||
*
|
||||
* Groepeert eerst chronologisch per datum in de gegeven tijdzone, en neemt daarna om het andere
|
||||
* punt (index 0, 2, 4, …) binnen die dag. Dat is robuust voor de zomertijddag: een dag met 23 of 25
|
||||
* uren levert vanzelf iets minder of meer dan 12 punten op, zonder speciale wissel-logica.
|
||||
*/
|
||||
final class HourlyForecastMapper
|
||||
{
|
||||
private const STEP = 2;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $hourly
|
||||
* @return array<string, list<array{at: string, temp: ?float, precip_mm: ?float, wind_kmh: ?float, wind_dir: ?int}>>
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
+12
-3
@@ -7,8 +7,16 @@ namespace Weerscherm;
|
||||
/** Dunne curl-wrapper met korte timeouts. Netwerkfouten worden UpstreamException. */
|
||||
final class Http implements HttpClient
|
||||
{
|
||||
public function __construct(private readonly int $timeout = 8, private readonly int $connectTimeout = 5)
|
||||
{
|
||||
/**
|
||||
* @param bool $followRedirects standaard uit: de API's die we aanroepen horen direct te antwoorden, en een
|
||||
* stille omleiding naar een andere host wil je niet. De beeld-API van Buienradar
|
||||
* leidt bewust door naar een CDN, dus die client zet dit expliciet aan.
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly int $timeout = 8,
|
||||
private readonly int $connectTimeout = 5,
|
||||
private readonly bool $followRedirects = false,
|
||||
) {
|
||||
}
|
||||
|
||||
public function get(string $url, array $query = [], array $headers = []): HttpResponse
|
||||
@@ -33,7 +41,8 @@ final class Http implements HttpClient
|
||||
}
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_FOLLOWLOCATION => $this->followRedirects,
|
||||
CURLOPT_MAXREDIRS => 3,
|
||||
CURLOPT_CONNECTTIMEOUT => $this->connectTimeout,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_HTTPHEADER => array_merge(['Accept: application/json', 'User-Agent: weerscherm/1.0'], $headers),
|
||||
|
||||
+6
-1
@@ -39,8 +39,13 @@ final class LiveSource implements WeatherSource
|
||||
return $this->netatmo->getPressureHistory($deviceId, $begin, $end);
|
||||
}
|
||||
|
||||
public function temperatureHistory(string $deviceId, ?string $moduleId, int $begin, int $end): array
|
||||
{
|
||||
return $this->netatmo->getTemperatureHistory($deviceId, $moduleId, $begin, $end);
|
||||
}
|
||||
|
||||
public function forecast(float $lat, float $lon, string $timezone): array
|
||||
{
|
||||
return $this->openMeteo->dailyForecast($lat, $lon, $timezone);
|
||||
return $this->openMeteo->forecast($lat, $lon, $timezone);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm;
|
||||
|
||||
/**
|
||||
* Zet een getmeasure-body (één waarde per tijdstip, bv. scale=30min met type=Pressure of Temperature)
|
||||
* om naar een chronologisch gesorteerde reeks punten. De naam van het waardeveld verschilt per meting,
|
||||
* zodat de payload "hpa" of "temp" kan heten.
|
||||
*/
|
||||
final class MeasureSeries
|
||||
{
|
||||
/**
|
||||
* @param array<string|int, mixed> $body
|
||||
* @return list<array{at: string, ...}> elk punt heeft "at" en het veld $valueKey
|
||||
*/
|
||||
public static function map(array $body, \DateTimeZone $tz, string $valueKey): array
|
||||
{
|
||||
$points = [];
|
||||
foreach ($body as $timestamp => $values) {
|
||||
if (!is_numeric($timestamp) || !is_array($values) || !isset($values[0]) || !is_numeric($values[0])) {
|
||||
continue;
|
||||
}
|
||||
$points[(int) $timestamp] = (float) $values[0];
|
||||
}
|
||||
ksort($points);
|
||||
|
||||
$out = [];
|
||||
foreach ($points as $ts => $value) {
|
||||
$out[] = [
|
||||
'at' => (new \DateTimeImmutable('@' . $ts))->setTimezone($tz)->format(\DATE_ATOM),
|
||||
$valueKey => $value,
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
+27
-4
@@ -60,17 +60,40 @@ final class NetatmoClient
|
||||
/** @return array<string|int, mixed> timestamp => [hPa], per 30 minuten, van het basisstation */
|
||||
public function getPressureHistory(string $deviceId, int $begin, int $end): array
|
||||
{
|
||||
$json = $this->apiGet('getmeasure', [
|
||||
'device_id' => $deviceId,
|
||||
return $this->measureSeries('Pressure', ['device_id' => $deviceId], $begin, $end);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ?string $moduleId null meet het basisstation zelf; Netatmo verwacht dan geen module_id
|
||||
* @return array<string|int, mixed> timestamp => [°C], per 30 minuten
|
||||
*/
|
||||
public function getTemperatureHistory(string $deviceId, ?string $moduleId, int $begin, int $end): array
|
||||
{
|
||||
$target = ['device_id' => $deviceId];
|
||||
if ($moduleId !== null) {
|
||||
$target['module_id'] = $moduleId;
|
||||
}
|
||||
return $this->measureSeries('Temperature', $target, $begin, $end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eén meetreeks per 30 minuten ophalen. $target bevat device_id en eventueel module_id.
|
||||
*
|
||||
* @param array<string, string> $target
|
||||
* @return array<string|int, mixed>
|
||||
*/
|
||||
private function measureSeries(string $type, array $target, int $begin, int $end): array
|
||||
{
|
||||
$json = $this->apiGet('getmeasure', $target + [
|
||||
'scale' => '30min',
|
||||
'type' => 'Pressure',
|
||||
'type' => $type,
|
||||
'date_begin' => $begin,
|
||||
'date_end' => $end,
|
||||
'optimize' => 'false',
|
||||
'real_time' => 'false',
|
||||
]);
|
||||
if (!is_array($json['body'] ?? null)) {
|
||||
throw new UpstreamException('getmeasure (Pressure): geen body in antwoord');
|
||||
throw new UpstreamException("getmeasure ($type): geen body in antwoord");
|
||||
}
|
||||
return $json['body'];
|
||||
}
|
||||
|
||||
@@ -13,13 +13,14 @@ final class OpenMeteoClient
|
||||
{
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> het "daily"-blok */
|
||||
public function dailyForecast(float $lat, float $lon, string $timezone): array
|
||||
/** @return array{daily: array<string, mixed>, hourly: array<string, mixed>} */
|
||||
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'] : [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm;
|
||||
|
||||
/** Zet een getmeasure-body (scale=30min, type=Pressure) om naar een gesorteerde reeks punten. */
|
||||
final class PressureHistory
|
||||
{
|
||||
public const HOURS = 24;
|
||||
|
||||
/**
|
||||
* @param array<string|int, mixed> $body
|
||||
* @return list<array{at: string, hpa: float}>
|
||||
*/
|
||||
public static function map(array $body, \DateTimeZone $tz): array
|
||||
{
|
||||
$points = [];
|
||||
foreach ($body as $timestamp => $values) {
|
||||
if (!is_numeric($timestamp) || !is_array($values) || !isset($values[0]) || !is_numeric($values[0])) {
|
||||
continue;
|
||||
}
|
||||
$points[(int) $timestamp] = (float) $values[0];
|
||||
}
|
||||
ksort($points);
|
||||
|
||||
$out = [];
|
||||
foreach ($points as $ts => $hpa) {
|
||||
$out[] = ['at' => (new \DateTimeImmutable('@' . $ts))->setTimezone($tz)->format(\DATE_ATOM), 'hpa' => $hpa];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm;
|
||||
|
||||
/**
|
||||
* Serveert het radarbeeld met een eigen bestandscache. De radar ververst ongeveer elke 5 minuten,
|
||||
* dus een TTL van 4 minuten houdt het beeld actueel zonder het CDN van Buienradar onnodig te belasten.
|
||||
*
|
||||
* Bij een storing komt het laatst opgehaalde beeld terug, gemarkeerd als verouderd. Alleen als er
|
||||
* helemaal niets in de cache staat, faalt de aanroep. Zo blijft het scherm ook zonder verbinding iets tonen.
|
||||
*/
|
||||
final class RadarService
|
||||
{
|
||||
/** @var \Closure(): int */
|
||||
private readonly \Closure $now;
|
||||
|
||||
public function __construct(
|
||||
private readonly BuienradarClient $client,
|
||||
private readonly string $file,
|
||||
private readonly int $ttl = 240,
|
||||
?\Closure $now = null,
|
||||
) {
|
||||
$this->now = $now ?? static fn(): int => time();
|
||||
}
|
||||
|
||||
/** @return array{bytes: string, stale: bool, age: int} */
|
||||
public function image(): array
|
||||
{
|
||||
$cached = $this->readCache();
|
||||
if ($cached !== null && $cached['age'] < $this->ttl) {
|
||||
return ['bytes' => $cached['bytes'], 'stale' => false, 'age' => $cached['age']];
|
||||
}
|
||||
|
||||
try {
|
||||
$bytes = $this->client->rainRadar();
|
||||
$this->writeCache($bytes);
|
||||
return ['bytes' => $bytes, 'stale' => false, 'age' => 0];
|
||||
} catch (\Throwable $e) {
|
||||
error_log('weerscherm: radar: ' . $e->getMessage());
|
||||
if ($cached !== null) {
|
||||
return ['bytes' => $cached['bytes'], 'stale' => true, 'age' => $cached['age']];
|
||||
}
|
||||
throw new UpstreamException('Radarbeeld niet beschikbaar en niets in de cache', 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{bytes: string, age: int}|null */
|
||||
private function readCache(): ?array
|
||||
{
|
||||
if (!is_file($this->file)) {
|
||||
return null;
|
||||
}
|
||||
$bytes = file_get_contents($this->file);
|
||||
$mtime = filemtime($this->file);
|
||||
if ($bytes === false || $bytes === '' || $mtime === false) {
|
||||
return null;
|
||||
}
|
||||
return ['bytes' => $bytes, 'age' => max(0, ($this->now)() - $mtime)];
|
||||
}
|
||||
|
||||
private function writeCache(string $bytes): void
|
||||
{
|
||||
$dir = dirname($this->file);
|
||||
if (!is_dir($dir) && !mkdir($dir, 0750, true) && !is_dir($dir)) {
|
||||
throw new \RuntimeException("Cachemap kan niet worden aangemaakt: $dir");
|
||||
}
|
||||
$tmp = $this->file . '.tmp.' . uniqid('', true);
|
||||
if (file_put_contents($tmp, $bytes, LOCK_EX) === false || !rename($tmp, $this->file)) {
|
||||
@unlink($tmp);
|
||||
throw new \RuntimeException("Radarbeeld kan niet worden opgeslagen: {$this->file}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ final class StationMapper
|
||||
* outdoor: array{temp: ?float, trend: ?string, min: ?float, min_at: ?int, max: ?float, max_at: ?int, humidity: ?float},
|
||||
* indoor: list<array{id: string, name: string, temp: ?float, min: ?float, max: ?float, humidity: ?float, co2: ?int, reachable: bool}>,
|
||||
* pressure: array{current: ?float, trend: ?string},
|
||||
* rain_module_id: ?string, sum_rain_24: ?float}
|
||||
* outdoor_module_id: ?string, rain_module_id: ?string, sum_rain_24: ?float}
|
||||
*/
|
||||
public static function map(array $body, ?string $deviceId, array $indoorModules): array
|
||||
{
|
||||
@@ -29,7 +29,7 @@ final class StationMapper
|
||||
return [
|
||||
'timezone' => null, 'lat' => null, 'lon' => null, 'device_id' => null, 'measured_at' => null,
|
||||
'outdoor' => self::EMPTY_OUTDOOR, 'indoor' => [], 'pressure' => self::EMPTY_PRESSURE,
|
||||
'rain_module_id' => null, 'sum_rain_24' => null,
|
||||
'outdoor_module_id' => null, 'rain_module_id' => null, 'sum_rain_24' => null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ final class StationMapper
|
||||
'current' => self::floatOrNull(self::dashboard($device)['Pressure'] ?? null),
|
||||
'trend' => self::stringOrNull(self::dashboard($device)['pressure_trend'] ?? null),
|
||||
],
|
||||
'outdoor_module_id' => $outdoor === null ? null : self::stringOrNull($outdoor['_id'] ?? null),
|
||||
'rain_module_id' => $rain === null ? null : self::stringOrNull($rain['_id'] ?? null),
|
||||
'sum_rain_24' => self::floatOrNull(self::dashboard($rain)['sum_rain_24'] ?? null),
|
||||
];
|
||||
|
||||
@@ -16,6 +16,12 @@ interface WeatherSource
|
||||
/** @return array<string|int, mixed> de "body" van Netatmo getmeasure (timestamp => [hPa]) voor het basisstation */
|
||||
public function pressureHistory(string $deviceId, int $begin, int $end): array;
|
||||
|
||||
/** @return array<string, mixed> het "daily"-blok van Open-Meteo */
|
||||
/**
|
||||
* @param ?string $moduleId null meet het basisstation zelf
|
||||
* @return array<string|int, mixed> de "body" van Netatmo getmeasure (timestamp => [°C])
|
||||
*/
|
||||
public function temperatureHistory(string $deviceId, ?string $moduleId, int $begin, int $end): array;
|
||||
|
||||
/** @return array{daily: array<string, mixed>, hourly: array<string, mixed>} de "daily"- en "hourly"-blokken van Open-Meteo */
|
||||
public function forecast(float $lat, float $lon, string $timezone): array;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Weerscherm\BuienradarClient;
|
||||
use Weerscherm\UpstreamException;
|
||||
|
||||
final class BuienradarClientTest extends TestCase
|
||||
{
|
||||
private const GIF = "GIF89a\x01\x00\x01\x00fake-animatie";
|
||||
|
||||
public function testFetchesTheAnimatedRadarImage(): void
|
||||
{
|
||||
$http = (new FakeHttp())->queue(200, self::GIF);
|
||||
|
||||
$bytes = (new BuienradarClient($http))->rainRadar();
|
||||
|
||||
self::assertSame(self::GIF, $bytes);
|
||||
self::assertSame('https://image.buienradar.nl/2.0/image/animation/RadarmapRainNL', $http->requests[0]['url']);
|
||||
self::assertSame(['w' => 550, 'h' => 512], $http->requests[0]['params']);
|
||||
}
|
||||
|
||||
public function testErrorStatusIsUpstreamException(): void
|
||||
{
|
||||
$http = (new FakeHttp())->queue(503, '');
|
||||
|
||||
$this->expectException(UpstreamException::class);
|
||||
$this->expectExceptionMessage('HTTP 503');
|
||||
(new BuienradarClient($http))->rainRadar();
|
||||
}
|
||||
|
||||
public function testNonGifResponseIsUpstreamException(): void
|
||||
{
|
||||
// Bijvoorbeeld een foutpagina of een redirect die als HTML binnenkomt.
|
||||
$http = (new FakeHttp())->queue(200, '<!doctype html><title>Oeps</title>');
|
||||
|
||||
$this->expectException(UpstreamException::class);
|
||||
$this->expectExceptionMessage('geen GIF');
|
||||
(new BuienradarClient($http))->rainRadar();
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ final class ConfigTest extends TestCase
|
||||
|
||||
self::assertSame('Europe/Berlin', $config->get('timezone'));
|
||||
self::assertSame('fixture', $config->get('data_source'));
|
||||
self::assertSame(['stations' => 300, 'measure' => 900, 'pressure' => 900, 'forecast' => 3600], $config->get('cache_ttl'));
|
||||
self::assertSame(['stations' => 300, 'measure' => 900, 'pressure' => 900, 'temperature' => 900, 'forecast' => 3600, 'radar' => 240], $config->get('cache_ttl'));
|
||||
self::assertSame([], $config->get('indoor_modules'));
|
||||
self::assertNull($config->get('netatmo_device_id'));
|
||||
}
|
||||
@@ -39,7 +39,7 @@ final class ConfigTest extends TestCase
|
||||
$config = Config::load($this->file);
|
||||
|
||||
self::assertSame('Europe/Amsterdam', $config->get('timezone'));
|
||||
self::assertSame(['stations' => 60, 'measure' => 900, 'pressure' => 900, 'forecast' => 3600], $config->get('cache_ttl'));
|
||||
self::assertSame(['stations' => 60, 'measure' => 900, 'pressure' => 900, 'temperature' => 900, 'forecast' => 3600, 'radar' => 240], $config->get('cache_ttl'));
|
||||
}
|
||||
|
||||
public function testUnknownKeyReturnsGivenDefault(): void
|
||||
|
||||
@@ -32,7 +32,7 @@ final class DashboardServiceTest extends TestCase
|
||||
return new DashboardService(
|
||||
$this->source,
|
||||
new Cache($this->dir),
|
||||
['stations' => 300, 'measure' => 900, 'pressure' => 900, 'forecast' => 3600],
|
||||
['stations' => 300, 'measure' => 900, 'pressure' => 900, 'temperature' => 900, 'forecast' => 3600],
|
||||
'Europe/Amsterdam',
|
||||
$lat,
|
||||
$lon,
|
||||
@@ -46,7 +46,7 @@ final class DashboardServiceTest extends TestCase
|
||||
{
|
||||
$p = $this->service()->build();
|
||||
|
||||
self::assertSame(['generated_at', 'timezone', 'station', 'outdoor', 'indoor', 'rain', 'pressure', 'forecast', 'forecast_stale', 'errors'], array_keys($p));
|
||||
self::assertSame(['generated_at', 'timezone', 'station', 'outdoor', 'outdoor_history', 'indoor', 'rain', 'pressure', 'forecast', 'forecast_stale', 'errors'], array_keys($p));
|
||||
self::assertSame('2026-09-11T14:32:05+02:00', $p['generated_at']);
|
||||
self::assertSame('Europe/Berlin', $p['timezone']);
|
||||
self::assertSame(['measured_at' => '2026-09-11T14:30:12+02:00', 'stale' => false], $p['station']);
|
||||
@@ -63,8 +63,11 @@ final class DashboardServiceTest extends TestCase
|
||||
self::assertSame(['2026-09-08', '2026-09-09', '2026-09-10'], array_column($p['rain']['previous_days'], 'date'));
|
||||
self::assertEqualsWithDelta(38.5, $p['rain']['month_total'], 0.0001);
|
||||
self::assertFalse($p['rain']['stale']);
|
||||
self::assertCount(5, $p['forecast']);
|
||||
self::assertSame('2026-09-12', $p['forecast'][0]['date']);
|
||||
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']);
|
||||
}
|
||||
@@ -80,6 +83,63 @@ final class DashboardServiceTest extends TestCase
|
||||
self::assertSame((new \DateTimeImmutable('2026-09-11 12:32:05 UTC'))->getTimestamp(), $end);
|
||||
}
|
||||
|
||||
public function testOutdoorHistoryCoversTodayFromLocalMidnight(): void
|
||||
{
|
||||
$p = $this->service()->build();
|
||||
|
||||
[$deviceId, $moduleId, $begin, $end] = $this->source->allArgs['temperature'][0];
|
||||
self::assertSame('70:ee:50:00:00:01', $deviceId);
|
||||
self::assertSame('02:00:00:00:00:01', $moduleId, 'de buitenmodule, niet het basisstation');
|
||||
self::assertSame((new \DateTimeImmutable('2026-09-11 00:00', new \DateTimeZone('Europe/Berlin')))->getTimestamp(), $begin);
|
||||
self::assertSame((new \DateTimeImmutable('2026-09-11 12:32:05 UTC'))->getTimestamp(), $end);
|
||||
|
||||
self::assertNotEmpty($p['outdoor_history']['points']);
|
||||
self::assertSame(['at', 'temp'], array_keys($p['outdoor_history']['points'][0]));
|
||||
self::assertFalse($p['outdoor_history']['stale']);
|
||||
}
|
||||
|
||||
public function testEveryIndoorModuleGetsItsOwnHistory(): void
|
||||
{
|
||||
$p = $this->service()->build();
|
||||
|
||||
foreach ($p['indoor'] as $room) {
|
||||
self::assertNotEmpty($room['history'], "kamer {$room['name']} heeft een verloop");
|
||||
self::assertSame(['at', 'temp'], array_keys($room['history'][0]));
|
||||
}
|
||||
|
||||
// Het basisstation meet zelf; dat vraag je op zonder module_id.
|
||||
$modules = array_map(fn(array $args) => $args[1], $this->source->allArgs['temperature']);
|
||||
self::assertSame(
|
||||
['02:00:00:00:00:01', null, '03:00:00:00:00:02', '03:00:00:00:00:03'],
|
||||
$modules,
|
||||
'buiten, dan het basisstation zonder module_id, dan de twee losse modules',
|
||||
);
|
||||
}
|
||||
|
||||
public function testIndoorHistoryFailureOnlyEmptiesThatList(): void
|
||||
{
|
||||
$this->source->responses['temperature'] = new UpstreamException('500');
|
||||
|
||||
$p = $this->service()->build();
|
||||
|
||||
foreach ($p['indoor'] as $room) {
|
||||
self::assertSame([], $room['history']);
|
||||
}
|
||||
self::assertSame(21.3, $p['indoor'][0]['temp'], 'de actuele waarden komen uit stationsdata en blijven staan');
|
||||
self::assertSame(['netatmo_unavailable'], $p['errors']);
|
||||
}
|
||||
|
||||
public function testOutdoorHistoryFailureLeavesTheRestIntact(): void
|
||||
{
|
||||
$this->source->responses['temperature'] = new UpstreamException('500');
|
||||
|
||||
$p = $this->service()->build();
|
||||
|
||||
self::assertSame(['points' => [], 'stale' => true], $p['outdoor_history']);
|
||||
self::assertSame(16.4, $p['outdoor']['temp'], 'de actuele waarden komen uit stationsdata en blijven staan');
|
||||
self::assertSame(['netatmo_unavailable'], $p['errors']);
|
||||
}
|
||||
|
||||
public function testPressureHistoryIsRequestedForLast24Hours(): void
|
||||
{
|
||||
$this->service()->build();
|
||||
@@ -127,6 +187,8 @@ final class DashboardServiceTest extends TestCase
|
||||
self::assertNull($p['rain']['today']);
|
||||
self::assertSame(0, $this->source->calls['measure'], 'zonder regenmeter-id geen getmeasure-call');
|
||||
self::assertSame(0, $this->source->calls['pressure'], 'zonder device-id geen luchtdruk-call');
|
||||
self::assertSame(0, $this->source->calls['temperature'], 'zonder buitenmodule geen temperatuur-call');
|
||||
self::assertSame(['points' => [], 'stale' => false], $p['outdoor_history']);
|
||||
self::assertSame(['current' => null, 'trend' => null, 'history' => [], 'stale' => false], $p['pressure']);
|
||||
self::assertSame('Europe/Amsterdam', $p['timezone'], 'fallback-tijdzone uit config');
|
||||
self::assertSame(['netatmo_unavailable', 'forecast_unavailable'], $p['errors']);
|
||||
@@ -179,15 +241,32 @@ final class DashboardServiceTest extends TestCase
|
||||
self::assertSame(16.4, $p['outdoor']['temp']);
|
||||
}
|
||||
|
||||
public function testLegacyCachedForecastShapeIsTreatedAsUnavailableNotSilentlyEmpty(): void
|
||||
{
|
||||
// Vóór 2026-09-13 cachete DashboardService het platte "daily"-blok direct onder de sleutel
|
||||
// "forecast". Na de uitbreiding met uurdata verwacht de code {daily, hourly}. Een nog niet
|
||||
// verlopen cache in het oude formaat mag niet stil een lege verwachting zonder foutmelding geven.
|
||||
$legacyDailyOnly = json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true)['daily'];
|
||||
(new Cache($this->dir))->write('forecast', $legacyDailyOnly, (new \DateTimeImmutable('2026-09-11 12:30:00 UTC'))->getTimestamp());
|
||||
|
||||
$p = $this->service()->build();
|
||||
|
||||
self::assertSame([], $p['forecast']);
|
||||
self::assertTrue($p['forecast_stale']);
|
||||
self::assertSame(['forecast_unavailable'], $p['errors']);
|
||||
self::assertSame(0, $this->source->calls['forecast'], 'de cache was nog vers, dus geen live call verwacht');
|
||||
}
|
||||
|
||||
public function testSecondBuildWithinTtlUsesCache(): void
|
||||
{
|
||||
$this->service()->build();
|
||||
$this->service('2026-09-11 12:36:05 UTC')->build();
|
||||
|
||||
self::assertSame(['stations' => 1, 'measure' => 1, 'pressure' => 1, 'forecast' => 1], $this->source->calls);
|
||||
self::assertSame(['stations' => 1, 'measure' => 1, 'pressure' => 1, 'temperature' => 4, 'forecast' => 1], $this->source->calls);
|
||||
|
||||
$this->service('2026-09-11 12:38:05 UTC')->build();
|
||||
self::assertSame(2, $this->source->calls['stations'], 'stations na 5 min opnieuw');
|
||||
self::assertSame(4, $this->source->calls['temperature'], 'verloop per module nog binnen 15 min');
|
||||
self::assertSame(1, $this->source->calls['measure'], 'measure nog binnen 15 min');
|
||||
}
|
||||
}
|
||||
|
||||
+15
-2
@@ -13,11 +13,14 @@ final class FakeSource implements WeatherSource
|
||||
public array $responses = [];
|
||||
|
||||
/** @var array<string, int> */
|
||||
public array $calls = ['stations' => 0, 'measure' => 0, 'pressure' => 0, 'forecast' => 0];
|
||||
public array $calls = ['stations' => 0, 'measure' => 0, 'pressure' => 0, 'temperature' => 0, 'forecast' => 0];
|
||||
|
||||
/** @var array<string, array> */
|
||||
public array $lastArgs = [];
|
||||
|
||||
/** @var array<string, list<array>> alle aanroepen per soort, in volgorde */
|
||||
public array $allArgs = [];
|
||||
|
||||
public static function fromFixtures(): self
|
||||
{
|
||||
$s = new self();
|
||||
@@ -25,7 +28,11 @@ 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'],
|
||||
'temperature' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure-temperature.json'), true)['body'],
|
||||
'forecast' => array_intersect_key(
|
||||
json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true),
|
||||
['daily' => null, 'hourly' => null],
|
||||
),
|
||||
];
|
||||
return $s;
|
||||
}
|
||||
@@ -45,6 +52,11 @@ final class FakeSource implements WeatherSource
|
||||
return $this->answer('pressure', func_get_args());
|
||||
}
|
||||
|
||||
public function temperatureHistory(string $deviceId, ?string $moduleId, int $begin, int $end): array
|
||||
{
|
||||
return $this->answer('temperature', func_get_args());
|
||||
}
|
||||
|
||||
public function forecast(float $lat, float $lon, string $timezone): array
|
||||
{
|
||||
return $this->answer('forecast', func_get_args());
|
||||
@@ -54,6 +66,7 @@ final class FakeSource implements WeatherSource
|
||||
{
|
||||
$this->calls[$key]++;
|
||||
$this->lastArgs[$key] = $args;
|
||||
$this->allArgs[$key][] = $args;
|
||||
$r = $this->responses[$key] ?? new \RuntimeException("geen response voor $key");
|
||||
if ($r instanceof \Throwable) {
|
||||
throw $r;
|
||||
|
||||
@@ -15,16 +15,16 @@ final class ForecastMapperTest extends TestCase
|
||||
return json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true)['daily'];
|
||||
}
|
||||
|
||||
public function testSkipsTodayAndReturnsFiveDays(): void
|
||||
public function testIncludesTodayAndReturnsSixDays(): void
|
||||
{
|
||||
$f = ForecastMapper::map($this->daily());
|
||||
|
||||
self::assertCount(5, $f);
|
||||
self::assertCount(6, $f);
|
||||
self::assertSame([
|
||||
'date' => '2026-09-12', 'weather_code' => 2, 'temp_max' => 18.3, 'temp_min' => 10.1,
|
||||
'precip_mm' => 0.4, 'precip_prob' => 20,
|
||||
'date' => '2026-09-11', 'weather_code' => 3, 'temp_max' => 17.9, 'temp_min' => 11.0,
|
||||
'precip_mm' => 0.0, 'precip_prob' => 10,
|
||||
], $f[0]);
|
||||
self::assertSame('2026-09-16', $f[4]['date']);
|
||||
self::assertSame('2026-09-16', $f[5]['date']);
|
||||
}
|
||||
|
||||
public function testNullValuesStayNull(): void
|
||||
@@ -35,9 +35,9 @@ final class ForecastMapperTest extends TestCase
|
||||
|
||||
$f = ForecastMapper::map($daily);
|
||||
|
||||
self::assertNull($f[2]['weather_code']);
|
||||
self::assertNull($f[2]['precip_prob']);
|
||||
self::assertSame(17.4, $f[2]['temp_max']);
|
||||
self::assertNull($f[3]['weather_code']);
|
||||
self::assertNull($f[3]['precip_prob']);
|
||||
self::assertSame(17.4, $f[3]['temp_max']);
|
||||
}
|
||||
|
||||
public function testShorterResponseGivesFewerDays(): void
|
||||
@@ -47,7 +47,7 @@ final class ForecastMapperTest extends TestCase
|
||||
$daily[$key] = array_slice($values, 0, 3);
|
||||
}
|
||||
|
||||
self::assertCount(2, ForecastMapper::map($daily));
|
||||
self::assertCount(3, ForecastMapper::map($daily));
|
||||
}
|
||||
|
||||
public function testMissingTimeArrayGivesEmptyList(): void
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Weerscherm\HourlyForecastMapper;
|
||||
|
||||
final class HourlyForecastMapperTest extends TestCase
|
||||
{
|
||||
private \DateTimeZone $tz;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->tz = new \DateTimeZone('Europe/Berlin');
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,16 @@ declare(strict_types=1);
|
||||
namespace Weerscherm\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Weerscherm\PressureHistory;
|
||||
use Weerscherm\MeasureSeries;
|
||||
|
||||
final class PressureHistoryTest extends TestCase
|
||||
final class MeasureSeriesTest extends TestCase
|
||||
{
|
||||
public function testMapsAndSortsPoints(): void
|
||||
{
|
||||
$body = json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure-pressure.json'), true)['body'];
|
||||
$shuffled = array_reverse($body, true);
|
||||
|
||||
$points = PressureHistory::map($shuffled, new \DateTimeZone('Europe/Berlin'));
|
||||
$points = MeasureSeries::map($shuffled, new \DateTimeZone('Europe/Berlin'), 'hpa');
|
||||
|
||||
self::assertCount(49, $points);
|
||||
self::assertSame('2026-09-10T14:30:00+02:00', $points[0]['at']);
|
||||
@@ -25,8 +25,19 @@ final class PressureHistoryTest extends TestCase
|
||||
|
||||
public function testSkipsGarbage(): void
|
||||
{
|
||||
$points = PressureHistory::map(['abc' => [1000.0], '1789129800' => ['x'], '1789133400' => [1013.5]], new \DateTimeZone('UTC'));
|
||||
$points = MeasureSeries::map(['abc' => [1000.0], '1789129800' => ['x'], '1789133400' => [1013.5]], new \DateTimeZone('UTC'), 'hpa');
|
||||
|
||||
self::assertSame([['at' => '2026-09-11T13:30:00+00:00', 'hpa' => 1013.5]], $points);
|
||||
}
|
||||
|
||||
public function testTheValueFieldIsNamedPerMeasurement(): void
|
||||
{
|
||||
$body = ['1789129800' => [16.4], '1789131600' => [16.9]];
|
||||
|
||||
$temps = MeasureSeries::map($body, new \DateTimeZone('Europe/Berlin'), 'temp');
|
||||
|
||||
self::assertSame(['at', 'temp'], array_keys($temps[0]));
|
||||
self::assertSame(16.4, $temps[0]['temp']);
|
||||
self::assertSame(16.9, $temps[1]['temp']);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Weerscherm\BuienradarClient;
|
||||
use Weerscherm\RadarService;
|
||||
use Weerscherm\UpstreamException;
|
||||
|
||||
final class RadarServiceTest extends TestCase
|
||||
{
|
||||
private const NOW = 1_800_000_000;
|
||||
private const GIF = "GIF89a\x01\x00\x01\x00vers";
|
||||
private const OLD_GIF = "GIF89a\x01\x00\x01\x00oud";
|
||||
|
||||
private string $dir;
|
||||
private string $file;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->dir = sys_get_temp_dir() . '/weerscherm-radar-' . uniqid();
|
||||
$this->file = $this->dir . '/radar.gif';
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
array_map('unlink', glob($this->dir . '/*') ?: []);
|
||||
@rmdir($this->dir);
|
||||
}
|
||||
|
||||
private function service(FakeHttp $http, int $ttl = 240): RadarService
|
||||
{
|
||||
return new RadarService(
|
||||
new BuienradarClient($http),
|
||||
$this->file,
|
||||
$ttl,
|
||||
static fn(): int => self::NOW,
|
||||
);
|
||||
}
|
||||
|
||||
/** Legt een cachebestand neer met een gegeven leeftijd in seconden. */
|
||||
private function seedCache(string $bytes, int $ageSeconds): void
|
||||
{
|
||||
mkdir($this->dir, 0700, true);
|
||||
file_put_contents($this->file, $bytes);
|
||||
touch($this->file, self::NOW - $ageSeconds);
|
||||
}
|
||||
|
||||
public function testFetchesAndCachesWhenNothingIsStored(): void
|
||||
{
|
||||
$http = (new FakeHttp())->queue(200, self::GIF);
|
||||
|
||||
$result = $this->service($http)->image();
|
||||
|
||||
self::assertSame(['bytes' => self::GIF, 'stale' => false, 'age' => 0], $result);
|
||||
self::assertStringEqualsFile($this->file, self::GIF);
|
||||
self::assertSame([], glob($this->dir . '/*.tmp.*') ?: [], 'geen tijdelijke bestanden achtergelaten');
|
||||
}
|
||||
|
||||
public function testFreshCacheIsServedWithoutFetching(): void
|
||||
{
|
||||
$this->seedCache(self::OLD_GIF, 100);
|
||||
$http = new FakeHttp(); // geen response gepland: een fetch zou de test laten falen
|
||||
|
||||
$result = $this->service($http)->image();
|
||||
|
||||
self::assertSame(self::OLD_GIF, $result['bytes']);
|
||||
self::assertFalse($result['stale']);
|
||||
self::assertSame(100, $result['age']);
|
||||
self::assertSame([], $http->requests);
|
||||
}
|
||||
|
||||
public function testExpiredCacheIsRefetched(): void
|
||||
{
|
||||
$this->seedCache(self::OLD_GIF, 300);
|
||||
$http = (new FakeHttp())->queue(200, self::GIF);
|
||||
|
||||
$result = $this->service($http)->image();
|
||||
|
||||
self::assertSame(self::GIF, $result['bytes']);
|
||||
self::assertFalse($result['stale']);
|
||||
self::assertCount(1, $http->requests);
|
||||
self::assertStringEqualsFile($this->file, self::GIF);
|
||||
}
|
||||
|
||||
public function testUpstreamFailureFallsBackToTheCachedImageMarkedStale(): void
|
||||
{
|
||||
$this->seedCache(self::OLD_GIF, 3600);
|
||||
$http = (new FakeHttp())->queue(500, '');
|
||||
|
||||
$result = $this->service($http)->image();
|
||||
|
||||
self::assertSame(self::OLD_GIF, $result['bytes'], 'ongeacht de leeftijd: liever een oud beeld dan niets');
|
||||
self::assertTrue($result['stale']);
|
||||
self::assertSame(3600, $result['age']);
|
||||
}
|
||||
|
||||
public function testUpstreamFailureWithoutCacheThrows(): void
|
||||
{
|
||||
$http = (new FakeHttp())->queue(500, '');
|
||||
|
||||
$this->expectException(UpstreamException::class);
|
||||
$this->service($http)->image();
|
||||
}
|
||||
|
||||
public function testEmptyCacheFileIsIgnored(): void
|
||||
{
|
||||
$this->seedCache('', 10);
|
||||
$http = (new FakeHttp())->queue(200, self::GIF);
|
||||
|
||||
$result = $this->service($http)->image();
|
||||
|
||||
self::assertSame(self::GIF, $result['bytes']);
|
||||
self::assertCount(1, $http->requests, 'een leeg cachebestand telt niet als geldige cache');
|
||||
}
|
||||
}
|
||||
Vendored
+790
-9
@@ -5,21 +5,802 @@
|
||||
"utc_offset_seconds": 7200,
|
||||
"timezone": "Europe/Berlin",
|
||||
"timezone_abbreviation": "CEST",
|
||||
"elevation": 20.0,
|
||||
"elevation": 20,
|
||||
"daily_units": {
|
||||
"time": "iso8601",
|
||||
"weather_code": "wmo code",
|
||||
"temperature_2m_max": "°C",
|
||||
"temperature_2m_min": "°C",
|
||||
"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.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]
|
||||
"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
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"body": {
|
||||
"1789077600": [
|
||||
11.9
|
||||
],
|
||||
"1789079400": [
|
||||
11.9
|
||||
],
|
||||
"1789081200": [
|
||||
10.8
|
||||
],
|
||||
"1789083000": [
|
||||
10.8
|
||||
],
|
||||
"1789084800": [
|
||||
9.9
|
||||
],
|
||||
"1789086600": [
|
||||
9.9
|
||||
],
|
||||
"1789088400": [
|
||||
9.4
|
||||
],
|
||||
"1789090200": [
|
||||
9.4
|
||||
],
|
||||
"1789092000": [
|
||||
9.2
|
||||
],
|
||||
"1789093800": [
|
||||
9.2
|
||||
],
|
||||
"1789095600": [
|
||||
9.4
|
||||
],
|
||||
"1789097400": [
|
||||
9.4
|
||||
],
|
||||
"1789099200": [
|
||||
9.9
|
||||
],
|
||||
"1789101000": [
|
||||
9.9
|
||||
],
|
||||
"1789102800": [
|
||||
10.8
|
||||
],
|
||||
"1789104600": [
|
||||
10.8
|
||||
],
|
||||
"1789106400": [
|
||||
11.9
|
||||
],
|
||||
"1789108200": [
|
||||
11.9
|
||||
],
|
||||
"1789110000": [
|
||||
13.1
|
||||
],
|
||||
"1789111800": [
|
||||
13.1
|
||||
],
|
||||
"1789113600": [
|
||||
14.5
|
||||
],
|
||||
"1789115400": [
|
||||
14.5
|
||||
],
|
||||
"1789117200": [
|
||||
15.9
|
||||
],
|
||||
"1789119000": [
|
||||
15.9
|
||||
],
|
||||
"1789120800": [
|
||||
17.2
|
||||
],
|
||||
"1789122600": [
|
||||
17.2
|
||||
],
|
||||
"1789124400": [
|
||||
18.2
|
||||
],
|
||||
"1789126200": [
|
||||
18.2
|
||||
],
|
||||
"1789128000": [
|
||||
19.1
|
||||
],
|
||||
"1789129800": [
|
||||
19.1
|
||||
]
|
||||
},
|
||||
"status": "ok"
|
||||
}
|
||||
Vendored
+137
@@ -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
|
||||
]
|
||||
}
|
||||
+107
-1
@@ -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, pinRoom, hourTicks, scaleToFit,
|
||||
} from '../../public/format.js';
|
||||
|
||||
test('co2Level uses the Netatmo thresholds', () => {
|
||||
assert.deepEqual(co2Level(612), { level: 'good', label: 'goed' });
|
||||
@@ -151,3 +154,106 @@ 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), '–');
|
||||
});
|
||||
|
||||
test('pinRoom moves the matching room to the front, keeping the others in order', () => {
|
||||
const rooms = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
|
||||
assert.deepEqual(pinRoom(rooms, 'b').map((r) => r.id), ['b', 'a', 'c']);
|
||||
assert.deepEqual(pinRoom(rooms, 'c').map((r) => r.id), ['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
test('pinRoom returns the original array unchanged without a match, id, or when already first', () => {
|
||||
const rooms = [{ id: 'a' }, { id: 'b' }];
|
||||
assert.equal(pinRoom(rooms, null), rooms);
|
||||
assert.equal(pinRoom(rooms, undefined), rooms);
|
||||
assert.equal(pinRoom(rooms, 'x'), rooms, 'onbekend id: geen match, ongewijzigd terug');
|
||||
assert.equal(pinRoom(rooms, 'a'), rooms, 'al vooraan: geen kopie nodig');
|
||||
});
|
||||
|
||||
test('pinRoom does not mutate the input array', () => {
|
||||
const rooms = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
|
||||
const original = rooms.slice();
|
||||
pinRoom(rooms, 'c');
|
||||
assert.deepEqual(rooms, original);
|
||||
});
|
||||
|
||||
|
||||
test('hourTicks puts marks on clock-aligned whole hours inside the window', () => {
|
||||
const ticks = hourTicks('2026-09-12T11:41:00+02:00', '2026-09-13T11:41:00+02:00', TZ);
|
||||
|
||||
assert.deepEqual(ticks.map((t) => formatTime(t.at, TZ)), ['12:00', '18:00', '00:00', '06:00']);
|
||||
assert.deepEqual(ticks.map((t) => Number(t.percent.toFixed(2))), [1.32, 26.32, 51.32, 76.32]);
|
||||
});
|
||||
|
||||
test('hourTicks drops a mark that would collide with the "nu" label', () => {
|
||||
// Venster eindigt om 12:05, dus de markering van 12:00 zit op 99,7% en valt weg.
|
||||
const ticks = hourTicks('2026-09-12T12:05:00+02:00', '2026-09-13T12:05:00+02:00', TZ);
|
||||
|
||||
assert.deepEqual(ticks.map((t) => formatTime(t.at, TZ)), ['18:00', '00:00', '06:00']);
|
||||
});
|
||||
|
||||
test('hourTicks stays on local whole hours across the daylight saving change', () => {
|
||||
// 25 oktober 2026: de klok gaat om 03:00 terug naar 02:00, dus dat venster duurt 25 uur.
|
||||
// Zonder de lokale klok te raadplegen zou 00:00 hier op 01:00 uitkomen.
|
||||
const ticks = hourTicks('2026-10-24T12:00:00+02:00', '2026-10-25T12:00:00+01:00', TZ);
|
||||
|
||||
assert.deepEqual(ticks.map((t) => formatTime(t.at, TZ)), ['12:00', '18:00', '00:00', '06:00']);
|
||||
assert.equal(ticks[0].percent, 0, 'een venster dat precies op een heel uur begint krijgt daar een markering');
|
||||
});
|
||||
|
||||
test('hourTicks honours a different step', () => {
|
||||
const ticks = hourTicks('2026-09-13T09:30:00+02:00', '2026-09-13T21:30:00+02:00', TZ, 3);
|
||||
|
||||
assert.deepEqual(ticks.map((t) => formatTime(t.at, TZ)), ['12:00', '15:00', '18:00']);
|
||||
});
|
||||
|
||||
test('hourTicks gives nothing for an empty or invalid window', () => {
|
||||
assert.deepEqual(hourTicks('2026-09-13T10:00:00+02:00', '2026-09-13T10:00:00+02:00', TZ), []);
|
||||
assert.deepEqual(hourTicks('kapot', '2026-09-13T10:00:00+02:00', TZ), []);
|
||||
assert.deepEqual(hourTicks(undefined, undefined, TZ), []);
|
||||
});
|
||||
|
||||
test('scaleToFit shrinks to fit but never enlarges', () => {
|
||||
assert.equal(scaleToFit(481, 412), 412 / 481);
|
||||
assert.equal(scaleToFit(361, 412), 1, 'past al: niet vergroten');
|
||||
assert.equal(scaleToFit(412, 412), 1);
|
||||
});
|
||||
|
||||
test('scaleToFit is safe with nonsense input', () => {
|
||||
assert.equal(scaleToFit(0, 412), 1);
|
||||
assert.equal(scaleToFit(-10, 412), 1);
|
||||
assert.equal(scaleToFit(481, 0), 1);
|
||||
assert.equal(scaleToFit(undefined, 412), 1);
|
||||
});
|
||||
|
||||
+41
-1
@@ -9,7 +9,8 @@ function loadServiceWorker({ fetchImpl }) {
|
||||
const store = new Map();
|
||||
const cache = {
|
||||
async addAll(urls) { urls.forEach((u) => store.set(new URL(u, 'http://x/').href, new Response('shell'))); },
|
||||
async put(req, res) { store.set(req.url, res); },
|
||||
// De echte Cache API accepteert zowel een Request als een URL-string als sleutel.
|
||||
async put(req, res) { store.set(typeof req === 'string' ? req : req.url, res); },
|
||||
async match(req) { return store.get(typeof req === 'string' ? req : req.url) || undefined; },
|
||||
async keys() { return [...store.keys()].map((u) => new Request(u)); },
|
||||
};
|
||||
@@ -96,3 +97,42 @@ test('cross-origin and non-GET requests are left alone', () => {
|
||||
listeners.fetch({ request: new Request('http://x/api/data.php', { method: 'POST' }), respondWith: () => { handled = true; } });
|
||||
assert.equal(handled, false);
|
||||
});
|
||||
|
||||
test('the radar image is network-first and cached under a key without querystring', async () => {
|
||||
const { listeners, store } = loadServiceWorker({
|
||||
fetchImpl: async () => new Response('GIF89a', { status: 200, headers: { 'Content-Type': 'image/gif' } }),
|
||||
});
|
||||
|
||||
const res = await dispatchFetch(listeners, 'http://x/api/radar.php?t=123');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(store.has('http://x/api/radar.php'), 'sleutel zonder ?t=, anders loopt de cache vol met varianten');
|
||||
assert.equal(store.has('http://x/api/radar.php?t=123'), false);
|
||||
});
|
||||
|
||||
test('an offline radar request falls back to the cached image, even with a different querystring', async () => {
|
||||
let online = true;
|
||||
const { listeners } = loadServiceWorker({
|
||||
fetchImpl: async () => {
|
||||
if (!online) throw new TypeError('Failed to fetch');
|
||||
return new Response('GIF89a', { status: 200, headers: { 'Content-Type': 'image/gif' } });
|
||||
},
|
||||
});
|
||||
|
||||
await dispatchFetch(listeners, 'http://x/api/radar.php?t=1');
|
||||
online = false;
|
||||
const res = await dispatchFetch(listeners, 'http://x/api/radar.php?t=2');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get('X-From-Cache'), '1');
|
||||
assert.equal(await res.text(), 'GIF89a');
|
||||
});
|
||||
|
||||
test('an offline radar request without any cache gives a plain 503, not JSON', async () => {
|
||||
const { listeners } = loadServiceWorker({ fetchImpl: async () => { throw new TypeError('offline'); } });
|
||||
|
||||
const res = await dispatchFetch(listeners, 'http://x/api/radar.php?t=1');
|
||||
|
||||
assert.equal(res.status, 503);
|
||||
assert.equal(await res.text(), 'offline');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user