Author SHA1 Message Date
theoandClaude Opus 5 c0715b7cc9 feat: assen bij de luchtdrukgrafiek
De lijn had geen schaal, dus je kon niet zien hoeveel hPa de uitslag was
of wanneer een dip viel. Nu links de hoogste en laagste waarde van het
venster in hele hPa, uitgelijnd op boven- en onderkant van de lijn, en
eronder vijf tijdlabels (0/25/50/75/100% van het venster) met "nu"
rechts, ondersteund door drie lichte verticale hulplijnen. Posities
komen uit de pure functie axisMarks(), met tests.

De losse tekst "1009 – 1015 hPa in 24 u" in de kopregel vervalt: de
y-as zegt hetzelfde en de x-as maakt het venster duidelijk. De extra
labelrij past door de marge onder de grote temperatuur 8px te krappen;
er blijft 5px speling boven en onder de hero. Servicewerker naar v8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 11:30:11 +02:00
theoandClaude Opus 5 c95d198341 Merge feature/buienradar: regenradar-overlay bij een tik op het regenblok
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 11:13:32 +02:00
theoandClaude Opus 5 c542601d4e feat: tik op het regenblok voor de Buienradar-animatie
Het regenblok linksonder opent een overlay met de geanimeerde regenradar
van Buienradar (550x512, tijd in het beeld), naast de regencijfers die we
al hebben: vandaag, de 3 vorige dagen en het maandtotaal. Sluit na 20
seconden of bij een tik, net als het uuroverzicht.

Het beeld loopt via onze eigen /api/radar.php in plaats van een <img>
rechtstreeks naar Buienradar: de spec wil alles lokaal geserveerd, de
iPad praat zo nooit met een derde partij, we cachen 4 minuten (de radar
ververst elke 5) en de servicewerker kan het laatste beeld offline
bewaren. Bij een storing komt het laatst opgehaalde beeld terug; pas als
er niets is, een nette melding in plaats van een leeg vlak.
Bronvermelding staat in de overlay, zoals Buienradar vraagt.

Verder: de overlay-machinerie (20s-timer, balk, sluiten) staat nu een
keer in plaats van per overlay, Http kan optioneel redirects volgen (de
beeld-API leidt door naar een CDN), en alle /api/-paden zijn in de
servicewerker network-first met een cachesleutel zonder querystring.
Servicewerker naar v7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 11:13:28 +02:00
theoandClaude Sonnet 5 2200a849f9 Merge feature/module-pinnen: tik op een module om bovenaan te zetten met groter font
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 10:27:14 +02:00
theoandClaude Sonnet 5 350dafe591 feat: tik op een module om die bovenaan te zetten met een groter temperatuurgetal
Tik op een van de drie binnenmodules zet die 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 zet 'm weer los. Puur client-side, geen nieuwe
API-velden: state.pinnedRoomId + de pure functie pinRoom() (met tests)
in format.js. De keuze overleeft de 5-minuten-verversing, reset bij een
volledige herlaad. Servicewerker naar v6.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 10:27:09 +02:00
theoandClaude Sonnet 5 48d68b86fa fix: treat a stale/legacy-shaped forecast cache as unavailable, not silently empty
Na de deploy van het uuroverzicht (commit 8e90eb1) bleef de nog geldige
cache op de server een tijdlang in het oude platte 'daily'-blok staan
i.p.v. het nieuwe {daily, hourly}. DashboardService::forecast() las
daaruit stil een lege verwachting zonder errors-melding. Nu wordt een
cache- of bronresultaat zonder geldig 'daily'-blok behandeld als een
storing (forecast_unavailable), zichtbaar in plaats van stil.

Handmatig verholpen op productie door de verouderde cachefile te
wissen; deze fix voorkomt herhaling bij een volgende formaatwijziging.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 10:20:00 +02:00
theoandClaude Sonnet 5 b6f4836b12 Merge feature/uuroverzicht: uuroverzicht per dag met temperatuur, regen en wind
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 10:18:17 +02:00
19 changed files with 673 additions and 54 deletions
+1
View File
@@ -32,6 +32,7 @@ return [
'measure' => 900,
'pressure' => 900,
'forecast' => 3600,
'radar' => 240,
],
// Map voor tokens en cache. Moet schrijfbaar zijn voor de webserver en buiten de docroot liggen.
+16
View File
@@ -251,11 +251,26 @@ 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".
- 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 vijf tijdlabels op 0/25/50/75/100% met "nu" rechts, plus drie lichte verticale hulplijnen. De losse
tekst "1009 – 1015 hPa in 24 u" in de kopregel is daarmee vervallen. Zie `axisMarks()` in
`public/format.js`._
- **Binnen (rechts):** 3 rijen met naam, temperatuur (1 decimaal) en "↓ min° ↑ max°". Een onbereikbare module toont "–" in `--muted`.
_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".
_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.
@@ -351,6 +366,7 @@ Hangt af van de iPadOS-versie in §0:
- [ ] Alle waarden komen overeen met de Netatmo-app: buiten, 3× binnen, min/max met tijdstippen, regen vandaag en de afgelopen 3 dagen, en het maandtotaal.
- [ ] De verwachting toont 6 dagen (vandaag t/m 5 dagen verder) met het juiste icoon per WMO-code.
- [ ] Tikken op een dag opent het uuroverzicht (temperatuur, regen, wind) en sluit na 20 seconden vanzelf of bij een tik erop.
- [ ] 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.
+48
View File
@@ -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";
}
+100 -19
View File
@@ -2,7 +2,7 @@ import {
f1, fInt, fmm, markerPos, barHeights, wmoIcon, trendLabel,
formatDate, formatTime, dayAbbr, monthName, isOlderThan, msUntil,
co2Level, fPct, fHpa, seriesRange, sparklinePath,
windArrowDeg, fKmh, formatHour, formatFullDate,
windArrowDeg, fKmh, formatHour, formatFullDate, pinRoom, axisMarks,
} from './format.js';
import { icon, windArrow } from './icons.js';
@@ -14,7 +14,8 @@ const FETCH_TIMEOUT_MS = 10000;
const STALE_MINUTES = 30;
const RELOAD_HOUR = 4;
const FALLBACK_TZ = 'Europe/Berlin';
const HOURLY_OVERLAY_MS = 20000;
const 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 $ = (id) => document.getElementById(id);
@@ -22,10 +23,11 @@ const stage = $('stage');
const viewport = $('viewport');
const state = {
data: null, // laatste payload, ook als die uit de SW-cache komt
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
lastOk: null, // Date van de laatste geslaagde live-fetch
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 ---------- */
@@ -71,11 +73,13 @@ function renderOutdoor(o) {
}
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;
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>
<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>
@@ -100,10 +104,21 @@ 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));
// Tijdlabels op dezelfde posities als de punten waar ze bij horen; de laatste is "nu".
const marks = axisMarks(p.history.length);
$('press-hours').innerHTML = marks.map((m, i) => {
const last = i === marks.length - 1;
const label = last ? 'nu' : formatTime(p.history[m.index].at, state.tz);
const style = i === 0 ? 'left:0' : last ? 'right:0' : `left:${m.percent}%;transform:translateX(-50%)`;
return `<span style="${style}">${label}</span>`;
}).join('');
}
function renderForecast(days) {
@@ -117,7 +132,8 @@ function renderForecast(days) {
/* ---------- uuroverzicht per dag ---------- */
let hourlyOverlayTimer = null;
let overlayTimer = null;
let openOverlayId = null;
function renderHourlyCharts(hourly) {
const temps = hourly.map((h) => h.temp);
@@ -144,25 +160,81 @@ function openHourOverlay(date) {
const day = (state.data?.forecast || []).find((d) => d.date === date);
if (!day) return;
clearTimeout(hourlyOverlayTimer);
setText('ho-date', formatFullDate(day.date, state.tz));
renderHourlyCharts(day.hourly || []);
showOverlay('hour-overlay', 'ho-bar-fill');
}
const fill = $('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 ${HOURLY_OVERLAY_MS}ms linear`;
fill.style.transition = `transform ${OVERLAY_MS}ms linear`;
fill.style.transform = 'scaleX(0)';
$('hour-overlay').hidden = false;
hourlyOverlayTimer = setTimeout(closeHourOverlay, HOURLY_OVERLAY_MS);
$(id).hidden = false;
openOverlayId = id;
overlayTimer = setTimeout(closeOverlay, OVERLAY_MS);
}
function closeHourOverlay() {
clearTimeout(hourlyOverlayTimer);
hourlyOverlayTimer = null;
$('hour-overlay').hidden = true;
function closeOverlay() {
clearTimeout(overlayTimer);
overlayTimer = null;
if (openOverlayId !== null) {
$(openOverlayId).hidden = true;
openOverlayId = null;
}
}
function renderBanner() {
@@ -316,7 +388,16 @@ $('forecast').addEventListener('click', (e) => {
const day = e.target.closest('.a-day');
if (day) openHourOverlay(day.dataset.date);
});
$('hour-overlay').addEventListener('click', closeHourOverlay);
$('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));
+24
View File
@@ -205,3 +205,27 @@ export function formatFullDate(dateStr, tz) {
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;
}
/**
* Posities voor labels langs een as. Geeft per fractie de bijbehorende index in een reeks van
* `length` punten, plus het percentage waarop dat label hoort te staan. Onder de 2 punten valt er
* niets zinnigs te labelen.
*/
export function axisMarks(length, fractions = [0, 0.25, 0.5, 0.75, 1]) {
if (length < 2) return [];
return fractions.map((f) => ({ index: Math.round(f * (length - 1)), percent: f * 100 }));
}
+36 -7
View File
@@ -36,18 +36,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>
<svg class="a-spark" viewBox="0 0 560 56" preserveAspectRatio="none" aria-hidden="true">
<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>
<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-bottom">
<div>
<div id="rain-block">
<p class="a-sub">Regen</p>
<div class="a-rainrow">
<div>
@@ -63,7 +71,7 @@
<div class="banner" id="banner">Gegevens laden…</div>
<div class="hour-overlay" id="hour-overlay" hidden>
<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>
@@ -79,6 +87,27 @@
<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>
+30 -9
View File
@@ -80,7 +80,7 @@ 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-temp { font-size: 200px; font-weight: 200; letter-spacing: -.045em; line-height: .95; margin: 4px 0 10px -8px; }
.a-hum { font-size: 17px; font-weight: 400; }
.a-hum:not(:empty)::before { content: "·"; margin-right: 14px; }
.a-range { width: 560px; }
@@ -89,13 +89,17 @@ 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 */
@@ -108,8 +112,9 @@ 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-rtemp { font-size: 50px; font-weight: 300; line-height: 1.05; transition: font-size .25s ease; }
.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); }
@@ -162,8 +167,9 @@ html, body {
}
.banner small { display: block; margin-top: 16px; font-size: 20px; color: var(--muted); }
/* Uuroverzicht per dag: tik op een dagkolom om te openen, tik erop of wacht 20s om te sluiten. */
.hour-overlay {
/* 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;
@@ -179,7 +185,7 @@ html, body {
box-shadow: 0 12px 32px rgba(27, 42, 53, .12);
z-index: 5;
}
.hour-overlay[hidden] { display: none; }
.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; }
@@ -206,6 +212,21 @@ html, body {
.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;
+17 -9
View File
@@ -1,6 +1,6 @@
/* Service worker: app-shell cache-first, API network-first met timeout en fallback op de laatste response. */
const VERSION = 'weerscherm-v5';
const VERSION = 'weerscherm-v8';
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 });
}
return new Response(JSON.stringify({ errors: ['offline'] }), {
status: 503,
headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' },
});
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);
}
+35
View File
@@ -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;
}
}
+1
View File
@@ -20,6 +20,7 @@ final class Config
'measure' => 900,
'pressure' => 900,
'forecast' => 3600,
'radar' => 240,
],
'storage_path' => null,
];
+10 -3
View File
@@ -123,11 +123,18 @@ final class DashboardService
return ['days' => [], 'stale' => true];
}
$result = $this->fetch('forecast', fn() => $this->source->forecast($lat, $lon, $tz->getName()), self::ERR_FORECAST);
if ($result['data'] === null) {
return ['days' => [], 'stale' => $result['stale']];
$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($result['data']['daily'] ?? []);
$days = ForecastMapper::map($daily);
$hourlyByDate = HourlyForecastMapper::mapByDate($result['data']['hourly'] ?? [], $tz);
foreach ($days as &$day) {
$day['hourly'] = $hourlyByDate[$day['date']] ?? [];
+12 -3
View File
@@ -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),
+75
View File
@@ -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}");
}
}
}
+44
View File
@@ -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();
}
}
+2 -2
View File
@@ -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, '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, 'forecast' => 3600, 'radar' => 240], $config->get('cache_ttl'));
}
public function testUnknownKeyReturnsGivenDefault(): void
+16
View File
@@ -182,6 +182,22 @@ 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();
+118
View File
@@ -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');
}
}
+47 -1
View File
@@ -121,7 +121,7 @@ test('msUntil computes the wait until the next local time', () => {
import {
co2Level, fPct, fHpa, seriesRange, sparklinePath, sparklinePoints,
windArrowDeg, fKmh, formatHour, formatFullDate,
windArrowDeg, fKmh, formatHour, formatFullDate, pinRoom, axisMarks,
} from '../../public/format.js';
test('co2Level uses the Netatmo thresholds', () => {
@@ -187,3 +187,49 @@ 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('axisMarks maps fractions to indices and percentages', () => {
// 49 punten = 24 uur per half uur
assert.deepEqual(axisMarks(49), [
{ index: 0, percent: 0 },
{ index: 12, percent: 25 },
{ index: 24, percent: 50 },
{ index: 36, percent: 75 },
{ index: 48, percent: 100 },
]);
});
test('axisMarks honours custom fractions and rounds to the nearest point', () => {
assert.deepEqual(axisMarks(10, [0, 0.5, 1]), [
{ index: 0, percent: 0 },
{ index: 5, percent: 50 },
{ index: 9, percent: 100 },
]);
assert.deepEqual(axisMarks(4, [0.33]), [{ index: 1, percent: 33 }]);
});
test('axisMarks gives nothing to label below two points', () => {
assert.deepEqual(axisMarks(1), []);
assert.deepEqual(axisMarks(0), []);
});
+41 -1
View File
@@ -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');
});