Files
2026-09-11 14:34:39 +02:00

383 lines
25 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<string,string|int|float> $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 <file>.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<array>, 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<array{date:string,mm:?float}>, month_total:?float} */
public static function compute(array $body, \DateTimeImmutable $now, ?float $sumRain24): array;
}
final class ForecastMapper { /** @return list<array> */ 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 <unix>, max 19.8, max_at <unix>`.
- `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": {"<unix>": [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 `<dir>/<key>.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)` → `<svg class="ic" viewBox="0 0 48 48" aria-hidden="true">…</svg>`.
- `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`
- `<html lang="nl">`, viewport `width=device-width, initial-scale=1, viewport-fit=cover`, iOS-meta (§7), `<link rel="manifest">`, `<script type="module" src="app.js">`.
- Structuur: `<div id="stage">` (1133×744, `transform-origin: top left`) met daarin dezelfde secties als de mockup: `header.a-top`, `section.a-hero`, `section.a-inside`, `section.a-bottom` (regen + `.a-fc`). Alle dynamische velden hebben `id`'s of `data-*`.
- Extra elementen: `#status` (klein, in `.a-right`, voor "geen verbinding sinds HH:MM"), `#banner` (over de bovenzone, verborgen; voor `netatmo_auth` en de eerste-load-fout), `#rotate` (portret-melding, alleen zichtbaar via `@media (orientation: portrait)`), `#loading` ("Gegevens laden…").
- CSS: de `.da`-tokens en alle `.a-*`-klassen uit de mockup overgenomen; `body` heeft `background: var(--bg)` en `overflow: hidden`; `#stage` gecentreerd via JS-schaal; `padding` houdt rekening met `env(safe-area-inset-*)`.
- `.a-upd.stale { color: var(--warm); }`.
### Task 12: app.js
**Files:** `public/app.js`
Functies:
- `fit()`: `scale = Math.min(innerWidth/1133, innerHeight/744)`; stage krijgt `transform: translate(x,y) scale(s)`. Aan `resize` en `orientationchange`.
- `render(data)`: vult alle velden met `format.js`; regenstaven via `barHeights`; forecast via `icon(wmoIcon(code))`; indoor onbereikbaar → "–" in `--muted`; `station.stale` of meting ouder dan 30 min → `.a-upd` warm + " verouderd"; `errors` bevat `netatmo_auth` → banner met herstelinstructie.
- `refresh()`: `fetch('api/data.php', {cache: 'no-store'})`; bij succes `lastData` bewaren, `lastOk = now`, status leeg; bij fout: laatste data blijft, status "geen verbinding sinds HH:MM" (tijd van laatste succes); eerste load zonder data → banner met foutmelding.
- Timers: `setInterval(refresh, 5 min)`; `visibilitychange` → refresh als zichtbaar; klok elke minuut; `setTimeout` tot middernacht → refresh + datum; `setTimeout` tot 04:00 → `location.reload()`.
- `navigator.wakeLock?.request('screen')` in try/catch, opnieuw bij `visibilitychange`.
- SW-registratie en `controllerchange` → `location.reload()` (fase 5; hier alvast met `if ('serviceWorker' in navigator)`).
- Visuele check: `php -S` + browser op 1133×744 naast de mockup (tab A). Commit. **Fase 2 klaar.**
---
## Fase 3: Netatmo live
### Task 13: Http + HttpResponse
**Files:** `src/Http.php`
- `Http implements HttpClient`, curl met `CURLOPT_CONNECTTIMEOUT 5`, `CURLOPT_TIMEOUT 8`, `RETURNTRANSFER`, `FOLLOWLOCATION false`. Curl-fout → `UpstreamException`.
- `HttpResponse::json()` → `json_decode(..., true, 512, JSON_THROW_ON_ERROR)`, gooit `UpstreamException` bij ongeldige JSON.
### Task 14: TokenStore
**Files:** `src/TokenStore.php`, `tests/TokenStoreTest.php`
- `write`: `json_encode` naar `<file>.tmp.<uniqid>`, `chmod 0600`, `rename`.
- `read`: null als bestand ontbreekt of ongeldig.
- `withLock(fn)`: opent `<file>.lock`, `flock(LOCK_EX)`, voert fn uit, `LOCK_UN`, geeft resultaat terug; ook bij exception ontgrendelen (`finally`).
- Tests: write/read round-trip; bestand heeft mode 0600; `withLock` geeft de returnwaarde terug en laat de lock los na een exception; twee processen (`proc_open` van een klein PHP-script dat 300 ms lockt) serialiseren: het tweede wacht.
### Task 15: NetatmoClient + tokentests
**Files:** `src/NetatmoClient.php`, `tests/NetatmoClientTest.php`, `tests/FakeHttp.php`
- `__construct(HttpClient $http, TokenStore $store, string $clientId, string $clientSecret, ?callable $now = null)`.
- `accessToken()`: onder `withLock`: lees token; als `expires_at - now < 300` → `refresh()`. `refresh()` POST `https://api.netatmo.com/oauth2/token` met `grant_type=refresh_token, refresh_token, client_id, client_secret`; response `access_token, refresh_token, expires_in` → `write(['access_token', 'refresh_token' => nieuwe, 'expires_at' => now + expires_in])`. Status 400 met body `error: invalid_grant` → `NetatmoAuthException`. Ontbrekend tokenbestand → `NetatmoAuthException`.
- `apiGet(path, query)`: GET met Bearer; bij 401/403 eenmalig `refresh()` + retry; daarna `NetatmoAuthException`. Andere status ≠ 200 → `UpstreamException`. Response `body` teruggeven.
- `getStationsData()`: `GET /api/getstationsdata`; `getMeasure($deviceId, $moduleId, $begin, $end)`: `GET /api/getmeasure` met `scale=1day, type=sum_rain, optimize=false, real_time=false`.
- Tests met `FakeHttp` (script van responses, registreert requests):
- verlopen token → eerst POST refresh, daarna GET met nieuwe Bearer; store bevat nieuwe refresh token.
- geldig token → geen refresh.
- GET 401 → refresh + retry; tweede 401 → `NetatmoAuthException`.
- refresh met `invalid_grant` → `NetatmoAuthException`.
- gelijktijdigheid: twee clients op dezelfde store, de refresh van de eerste schrijft; de tweede leest binnen de lock het verse token en refresht niet (FakeHttp telt 1 POST).
### Task 16: LiveSource, bin/netatmo-init.php, data.php live
**Files:** `src/LiveSource.php`, `bin/netatmo-init.php`, `public/api/data.php`
- `LiveSource(NetatmoClient, OpenMeteoClient)` (OpenMeteoClient komt in Task 17; hier alvast als constructorparameter met `forecast()` doorgifte).
- `bin/netatmo-init.php <refresh_token> [access_token]`: schrijft `storage/netatmo-token.json` met `expires_at 0` (dwingt refresh bij eerste call), meldt pad en mode.
- `data.php`: `data_source === 'live'` → `LiveSource` met `Http`, `TokenStore(storage/netatmo-token.json)`.
- `config.php` aanmaken met de echte gegevens, `storage/` aanmaken, init draaien, `curl` op de live API: echte waarden zichtbaar. Commit (zonder config.php/storage). **Fase 3 klaar.**
---
## Fase 4: Open-Meteo live
### Task 17: OpenMeteoClient
**Files:** `src/OpenMeteoClient.php`, `tests/OpenMeteoClientTest.php`
- `dailyForecast(float $lat, float $lon, string $tz): array` → GET `https://api.open-meteo.com/v1/forecast` met `latitude, longitude, daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max, timezone=$tz, forecast_days=6`; geeft `['daily']` terug; status ≠ 200 of ontbrekende `daily` → `UpstreamException`.
- Test met FakeHttp: query bevat alle parameters; foutstatus → exception.
- Live check via `curl` op de API: `forecast` heeft 5 dagen vanaf morgen. Commit. **Fase 4 klaar.**
---
## Fase 5: PWA
### Task 18: manifest, iconen, iOS-meta
**Files:** `public/manifest.webmanifest`, `public/icons/icon.svg`, `public/icons/icon-{180,192,512}.png`, `public/index.html`
- Manifest volgens §7 (`name Weerscherm, short_name Weer, display standalone, orientation landscape, start_url "./", scope "./", background_color/theme_color #E8EDF1, icons 192 + 512`).
- `icon.svg`: zon/wolk in de iconstijl op `--bg`, 512×512. PNG's renderen met `rsvg-convert` of `sips`/`qlmanage` (macOS); fallback: een klein Node-script met `canvas` is NIET toegestaan (geen deps) → gebruik `qlmanage -t -s 512 -o . icon.svg` en `sips -z` voor 192/180.
- `index.html`: `apple-touch-icon` 180, `apple-mobile-web-app-capable`, `apple-mobile-web-app-status-bar-style default`, `apple-mobile-web-app-title Weer`.
### Task 19: service worker
**Files:** `public/sw.js`, `public/app.js`
- `CACHE = 'weerscherm-v1'`; `SHELL = ['./', './index.html', './styles.css', './app.js', './format.js', './icons.js', './manifest.webmanifest', './icons/icon-192.png', './icons/icon-512.png', './icons/icon-180.png']`.
- `install`: `cache.addAll(SHELL)` + `skipWaiting()`. `activate`: oude caches weg + `clients.claim()`.
- `fetch`: URL bevat `/api/data.php` → network-first met `AbortController` timeout 8000 ms; succes → response klonen in cache; fout → cached response met extra header `X-From-Cache: 1` (via `new Response(body, {headers})`), of `503` JSON `{"errors":["offline"]}`. Overige GET → cache-first, netwerk als fallback.
- `app.js`: registreert `./sw.js`; `controllerchange` → `location.reload()`; response met `X-From-Cache` → behandelen als "geen verbinding" (status tonen, data wel renderen).
- Check: in Chrome DevTools offline zetten → laatste data blijft, status verschijnt. Commit. **Fase 5 klaar.**
---
## Fase 6: deploy en README
### Task 20: OrbStack-vhost, README, deploy
**Files:** `README.md`, `orbstack/…` (buiten deze repo, in `/Volumes/development/sites/orbstack/divers/weerscherm/`), `deploy/` notities in README
- OrbStack: `Dockerfile` op `php:8.3-fpm-alpine` (curl-ext zit erin), `docker-compose.yml` in `shared-network`, vhost `weerscherm.test` met docroot `/var/www/html/weerscherm/public`, mount in shared compose, `/etc/hosts`-regel. Alleen uitvoeren als OrbStack draait; anders de bestanden klaarzetten en in de README beschrijven.
- README: installatie, `config.php`, Netatmo-koppeling (§3.1), `bin/netatmo-init.php`, tests (`composer test`, `npm test`), lokaal draaien (`php -S` en OrbStack), deploy (Gitea + DeployPad, `storage/` als gedeelde map buiten de release), productie-URL `https://homedisplay.bahne-drei.de/`, iPad-instellingen (beginscherm, Automatisch vergrendelen → Nooit, Begeleide toegang), acceptatiecriteria uit §11 als checklist.
- Commit. **Fase 6 klaar.**