docs: add spec, mockup and implementation plan

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 14:33:34 +02:00
co-authored by Claude Fable 5.1
commit 0bf3527115
4 changed files with 1166 additions and 0 deletions
@@ -0,0 +1,382 @@
# 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
Exceptions.php # UpstreamException, NetatmoAuthException
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.**