# Weerscherm Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Een PWA voor een iPad mini (liggend) die Netatmo-stationsdata en een 5-daagse Open-Meteo-verwachting toont, gevoed door een PHP-backend met cache en tokenbeheer. **Architecture:** Een PHP 8.3-backend zonder framework serveert `GET /api/data.php` als één JSON-payload. `DashboardService` combineert drie bronnen (stationsdata, regenmetingen, verwachting) via een `WeatherSource`-interface met een `Live`- en een `Fixture`-implementatie, cachet per bron en valt bij storing terug op stale cache. Pure mappers (`StationMapper`, `RainCalculator`, `ForecastMapper`) zijn los testbaar. De frontend is vanilla ES-modules op een vast canvas van 1133×744 dat geschaald wordt; pure formatteerfuncties staan in `format.js` en worden met `node --test` getest. **Tech Stack:** PHP 8.3 (`ext-curl`, `ext-json`), PHPUnit 11 (dev), vanilla HTML/CSS/JS (ES-modules), Node 24 `node:test` (dev), service worker, webmanifest. **Spec:** `docs/weerscherm-spec.md` (mockup: `docs/design/weerscherm-layouts.html`, ontwerp A). ## Global Constraints - PHP 8.3, geen framework, geen runtime-dependencies; alleen `ext-curl` en `ext-json`. PHPUnit als dev-dependency. - Frontend zonder build-stap, externe fonts of CDN. Alles lokaal geserveerd. - Browserdoel iPadOS 15+: moderne JS/CSS en ES-modules zijn toegestaan. - Canvas 1133 × 744, geschaald met `transform: scale(min(innerWidth/1133, innerHeight/744))`, gecentreerd, alleen liggend. - Design tokens: `--bg #E8EDF1`, `--ink #1B2A35`, `--muted #5F707C`, `--line #C6D0D8`, `--cold #3A75A3`, `--warm #B8761C`, `--rain #2E6A99`, `--sun #D39A2C`. - Notatie `nl-NL`, decimale komma, tijden in de stationstijdzone, overal `font-variant-numeric: tabular-nums`. - API: ruwe getallen, ISO 8601 met offset, `null` voor onbekend, `Cache-Control: no-store`, `X-Robots-Tag: noindex`, HTTP 200 zolang er iets te tonen is. - Cache-TTL's: stationsdata 300 s, getmeasure 900 s, forecast 3600 s. - Secrets alleen in `config.php` en `storage/`; beide in `.gitignore`, `storage/` buiten de docroot, tokenbestand `chmod 600`. - Projectroot is deze map (`home-display`), docroot is `public/`. - Commits klein en per taak; `config.php` en `storage/` nooit committen. --- ## File Structure ``` config.example.php # voorbeeldconfig (in git); config.php (niet in git) heeft dezelfde vorm composer.json # autoload psr-4 "Weerscherm\\" => src/, phpunit dev, scripts test phpunit.xml package.json # alleen "test": "node --test tests/js" .gitignore # config.php, storage/, vendor/, node_modules/ public/ index.html # app-shell, iOS-meta, manifest-link, module script styles.css # ontwerp A, tokens, canvas-schaal, portret-melding, foutstaten app.js # ophalen, renderen, timers, foutafhandeling, SW-registratie format.js # pure functies (formatteren, marker, staven, WMO, datums) icons.js # icon(name) => SVG-string, 9 iconen sw.js # app-shell cache-first, API network-first met timeout manifest.webmanifest robots.txt icons/icon-180.png, icon-192.png, icon-512.png, icon.svg api/data.php # bootstrap + JSON src/ Config.php # laadt config.php, defaults Http.php # HttpClient-interface + curl-implementatie + HttpResponse Cache.php # bestandscache per key, met ttl en stale-lezen TokenStore.php # atomisch lezen/schrijven, flock NetatmoClient.php # oauth refresh, getstationsdata, getmeasure OpenMeteoClient.php # daily forecast WeatherSource.php # interface + LiveSource + FixtureSource StationMapper.php # getstationsdata-body -> station/outdoor/indoor RainCalculator.php # getmeasure-body -> today/previous_days/month_total ForecastMapper.php # open-meteo daily -> 5 dagen DashboardService.php # orkestratie, cache, stale-fallback, errors UpstreamException.php, NetatmoAuthException.php bin/netatmo-init.php # slaat refresh token op in storage/netatmo-token.json tests/ bootstrap.php fixtures/getstationsdata.json, getmeasure.json, getmeasure-dst.json, forecast.json StationMapperTest.php, RainCalculatorTest.php, ForecastMapperTest.php TokenStoreTest.php, NetatmoClientTest.php, DashboardServiceTest.php, CacheTest.php js/format.test.js docs/weerscherm-spec.md, docs/design/weerscherm-layouts.html README.md ``` ## Interfaces (gedeeld door alle taken) ```php namespace Weerscherm; interface HttpClient { /** @param array $query */ public function get(string $url, array $query = [], array $headers = []): HttpResponse; public function postForm(string $url, array $fields): HttpResponse; } final class HttpResponse { public function __construct(public readonly int $status, public readonly string $body) {} public function json(): array; } final class Config { public static function load(string $file): self; public function get(string $key, mixed $default = null): mixed; } final class Cache { public function __construct(string $dir); /** @return array{data: array, stored_at: int}|null */ public function read(string $key): ?array; public function isFresh(string $key, int $ttl, int $now): bool; public function write(string $key, array $data, int $now): void; } final class TokenStore { public function __construct(string $file); /** @return array{access_token:string, refresh_token:string, expires_at:int}|null */ public function read(): ?array; public function write(array $token): void; // tmp + rename, chmod 600 public function withLock(callable $fn): mixed; // flock LOCK_EX op .lock } interface WeatherSource { public function stationsData(): array; // body van getstationsdata public function measure(string $deviceId, string $moduleId, int $begin, int $end): array; // body van getmeasure public function forecast(float $lat, float $lon, string $tz): array; // "daily" van open-meteo } final class StationMapper { /** @return array{timezone:string, lat:?float, lon:?float, device_id:?string, measured_at:?int, * outdoor:array, indoor:list, rain_module_id:?string, sum_rain_24:?float} */ public static function map(array $body, ?string $deviceId, array $indoorModules): array; } final class RainCalculator { public static function dateBegin(\DateTimeImmutable $now): \DateTimeImmutable; /** @return array{today:?float, previous_days:list, month_total:?float} */ public static function compute(array $body, \DateTimeImmutable $now, ?float $sumRain24): array; } final class ForecastMapper { /** @return list */ public static function map(array $daily): array; } final class DashboardService { public function __construct(WeatherSource $source, Cache $cache, array $ttl, string $tzFallback, ?float $lat, ?float $lon, array $indoorModules, ?string $deviceId, ?\DateTimeImmutable $now = null); public function build(): array; // payload volgens spec §5 } ``` Payloadvorm: exact spec §5. --- ## Fase 1: backend met fixtures ### Task 1: Projectskelet, composer, PHPUnit **Files:** `composer.json`, `phpunit.xml`, `tests/bootstrap.php`, `.gitignore`, `config.example.php`, `src/Config.php`, `tests/ConfigTest.php` - [ ] `composer.json` met `"autoload": {"psr-4": {"Weerscherm\\": "src/"}}`, `"require": {"php": ">=8.3", "ext-curl": "*", "ext-json": "*"}`, `"require-dev": {"phpunit/phpunit": "^11"}`, script `test`. - [ ] `composer install`. - [ ] Test: `Config::load()` geeft defaults (`timezone` = `Europe/Berlin`, `data_source` = `fixture`, `cache_ttl` = `[stations=>300, measure=>900, forecast=>3600]`) en overschrijft met waarden uit het bestand. - [ ] Implementatie `Config`. - [ ] `composer test` groen. Commit. ### Task 2: StationMapper **Files:** `src/StationMapper.php`, `tests/fixtures/getstationsdata.json`, `tests/StationMapperTest.php` Fixture: 1 NAMain (`_id` `70:ee:50:00:00:01`, `module_name` "Woonkamer", `place.timezone` `Europe/Berlin`, `place.location` `[6.55, 51.82]`), NAModule1 buiten (`Temperature 16.4, min_temp 9.2, max_temp 19.8, date_min_temp, date_max_temp, temp_trend "down", time_utc`), 2× NAModule4 ("Slaapkamer" reachable, "Kantoor" `reachable:false` zonder dashboard_data), NAModule3 regenmeter met `sum_rain_24 2.4`. Tests: - `timezone`, `lat`, `lon` uit `place` (let op: location is `[lon, lat]`). - `outdoor` bevat `temp 16.4, trend "down", min 9.2, min_at , max 19.8, max_at `. - `indoor` heeft 3 items in volgorde NAMain, dan modules; "Kantoor" heeft `temp/min/max null, reachable false`. - `indoor_modules`-config `['70:ee:50:00:00:03' => 'Studeerkamer', '70:ee:50:00:00:01' => 'Living']` bepaalt volgorde en labels; modules die niet in de lijst staan komen daarna in oorspronkelijke volgorde. - Zonder NAModule3: `rain_module_id null, sum_rain_24 null`. - Onbekend `device_id` → eerste station. Leeg `devices` → alle velden null, geen exception. ### Task 3: RainCalculator **Files:** `src/RainCalculator.php`, `tests/fixtures/getmeasure.json`, `tests/fixtures/getmeasure-dst.json`, `tests/RainCalculatorTest.php` Body-vorm van getmeasure met `optimize=false`: `{"body": {"": [mm], ...}}`. Sleutels zijn timestamps van dagwaarden, niet per se middernacht. Regels: - `dateBegin(now)` = 00:00 stationstijd van `min(1e van de maand, vandaag − 3 dagen)`. - Elke timestamp → lokale datum `Y-m-d` in de tijdzone van `$now`. - `today` = waarde van vandaag, anders `$sumRain24`. - `previous_days` = 3 datums vóór vandaag, oudste eerst, ontbrekende dag `mm: null`. - `month_total` = som van alle dagen in de huidige kalendermaand inclusief vandaag; `null` als er geen enkele dag is. Tests (now = `2026-09-11 14:32 Europe/Berlin`): - Normale week: today 2.4, previous [08→1.2, 09→6.8, 10→0.0], month_total = som van 1 t/m 11. - Timestamps om 03:17 lokale tijd mappen op de juiste datum. - Ontbrekende 9 september → `mm null`, month_total telt de rest. - now = 2 oktober: previous_days = 29, 30 sep en 1 okt; month_total = 1 + 2 okt. - Ontbrekende vandaag → `today` = sumRain24 (2.4); zonder sumRain24 → null. - DST: now = `2026-10-25 12:00` (wissel om 03:00). Timestamp `2026-10-24 23:30 UTC` (= 01:30 lokaal 25 okt) telt als 25 okt; `2026-10-25 23:30 UTC` (= 00:30 lokaal 26 okt) niet als 25 okt. - `dateBegin` op 2 okt = 29 sep 00:00; op 15 sep = 1 sep 00:00. ### Task 4: ForecastMapper **Files:** `src/ForecastMapper.php`, `tests/fixtures/forecast.json`, `tests/ForecastMapperTest.php` - Input: Open-Meteo `daily` met arrays `time, weather_code, temperature_2m_max, temperature_2m_min, precipitation_sum, precipitation_probability_max` van 6 elementen. - Output: 5 items (index 1..5): `date, weather_code, temp_max, temp_min, precip_mm, precip_prob`. Ontbrekende (`null`) waarden blijven `null`. Minder dan 6 dagen → zoveel als beschikbaar. ### Task 5: Cache **Files:** `src/Cache.php`, `tests/CacheTest.php` - `write` schrijft `/.json` met `{stored_at, data}` via tmp + rename. - `read` geeft null bij ontbreken of corrupt JSON. - `isFresh(key, ttl, now)`: `now - stored_at < ttl`. - Key wordt gesaneerd tot `[a-z0-9_-]`. ### Task 6: WeatherSource + FixtureSource **Files:** `src/WeatherSource.php`, `src/FixtureSource.php`, `src/Exceptions.php`, `tests/FixtureSourceTest.php` - `FixtureSource` bouwt in PHP realistische data **relatief aan nu** (zodat de UI altijd "vandaag" laat zien): stationsdata met `date_min_temp` = vandaag 06:10, `date_max_temp` = vandaag 13:05, `time_utc` = nu − 2 min; measure met 11+ dagen tot vandaag; forecast met 6 dagen vanaf vandaag, codes `[1, 2, 0, 61, 80, 3]`. - Test: `measure()` bevat een sleutel die op vandaag valt; `forecast()['time'][1]` is morgen. ### Task 7: DashboardService **Files:** `src/DashboardService.php`, `tests/DashboardServiceTest.php` Gedrag (per bron): 1. Cache fresh → gebruik cache, `stale false`. 2. Anders `source->…()`; succes → cache write, `stale false`. 3. Exception → cache stale (ongeacht leeftijd) → `stale true` + errorcode; geen cache → velden null + errorcode. 4. `NetatmoAuthException` → code `netatmo_auth`; andere Netatmo-fouten `netatmo_unavailable`; forecast `forecast_unavailable`. 5. Regen: `date_begin` uit `RainCalculator::dateBegin`, `date_end = now`; zonder `rain_module_id` → `rain` alle null zonder call. 6. Forecast-locatie: config lat/lon, anders station-lat/lon; beide onbekend → `forecast_unavailable`. 7. Tijden als ISO 8601 met offset in de stationstijdzone (`timezone` uit stationsdata, anders fallback). Tests met een `FakeSource` (arrays of exceptions per methode) en een tijdelijke cache-dir: - Happy path: payload heeft alle sleutels uit §5, `errors == []`, `outdoor.min_at` = `2026-09-11T06:10:00+02:00`. - Stations-exception zonder cache → `outdoor.temp null`, `station.stale true`, errors `[netatmo_unavailable]`. - Stations-exception mét cache → oude waarden, `station.stale true`. - Auth-exception → `netatmo_auth`. - Forecast-exception → `forecast_stale true`, `forecast_unavailable`, rest intact. - Tweede `build()` binnen de TTL doet geen source-call (teller in FakeSource). ### Task 8: `public/api/data.php` **Files:** `public/api/data.php`, `public/robots.txt` - Laadt `vendor/autoload.php`, `config.php` (valt terug op `config.example.php` met een 500 en tekst "config.php ontbreekt" als die er niet is), kiest `FixtureSource` of `LiveSource` (LiveSource komt in fase 3; tot dan `throw` bij `live`). - Headers: `Content-Type: application/json; charset=utf-8`, `Cache-Control: no-store`, `X-Robots-Tag: noindex`. - `robots.txt`: `User-agent: *` / `Disallow: /`. - Check: `php -S 127.0.0.1:8080 -t public` en `curl` geeft geldige JSON met `errors: []`. - Commit. **Fase 1 klaar.** --- ## Fase 2: frontend ### Task 9: format.js met tests **Files:** `public/format.js`, `tests/js/format.test.js`, `package.json` Exports en gedrag: - `f1(v)`: `16.4 → "16,4"`, `0 → "0,0"`, `null → "–"`. - `fInt(v)`: `18.3 → "18"`, `null → "–"` (afronden met `Math.round`). - `fmm(v)`: `0 → "0 mm"`, `0.4 → "0,4 mm"`, `null → "–"`. - `markerPos(temp, min, max)`: `(16.4, 9.2, 19.8) ≈ 67.9`; `< min → 0`; `> max → 100`; `max == min → 50`; null → 50. - `barHeights(values, maxPx = 70, minPx = 2)`: `[1.2, 6.8, 0, 2.4] → [12.35…, 70, 2, 24.7…]`; alle 0 → alle 2; null telt als 0. - `wmoIcon(code)`: tabel uit spec §4; onbekend → `cloud`. - `trendLabel(t)`: `up → "↗ stijgend"`, `down → "↘ dalend"`, `stable → "→ stabiel"`, anders `""`. - `formatDate(date, tz)`: `"Vrijdag 11 september"` (eerste letter hoofdletter). - `formatTime(iso, tz)`: `"06:10"`; null → `"–"`. - `dayAbbr(dateStr, tz)`: `"2026-09-12" → "Za"`. - `monthName(date, tz)`: `"September"`. - `isOlderThan(iso, now, minutes)`. - `nextMidnight(now, tz)` en `nextTimeAt(now, tz, hour)` geven ms tot het volgende moment. `package.json`: `{"private": true, "type": "module", "scripts": {"test": "node --test tests/js"}}`. Tests importeren `../../public/format.js`. ### Task 10: icons.js **Files:** `public/icons.js` - `export function icon(name)` → ``. - `sun`, `partly`, `rain`, `showers` letterlijk uit de mockup. - Nieuw in dezelfde stijl (stroke 2.4, ronde uiteinden, `i-sun`/`i-cloud`/`i-drop`): - `cloud`: alleen de wolkvorm van `rain` maar gecentreerd (y +5). - `fog`: wolk plus 2 horizontale lijnen eronder (`M14 36h20 M18 42h14`, klasse `i-drop`). - `drizzle`: wolk plus 3 korte streepjes (`M18 35l-1 2.5 M26 35l-1 2.5 M34 35l-1 2.5`). - `snow`: wolk plus 3 kleine sterretjes (elk 2 kruisende lijnen van 5 px) in `i-drop`. - `thunder`: wolk plus bliksem `M25 32l-4 7h5l-3 6` in `i-sun`. - Onbekende naam → `cloud`. ### Task 11: index.html + styles.css (ontwerp A) **Files:** `public/index.html`, `public/styles.css` - ``, viewport `width=device-width, initial-scale=1, viewport-fit=cover`, iOS-meta (§7), ``, `