From 0bf35271151f13d1a36f983fef0235913e85ccb0 Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Fri, 11 Sep 2026 14:33:34 +0200 Subject: [PATCH] docs: add spec, mockup and implementation plan Co-Authored-By: Claude Fable 5.1 --- .gitignore | 5 + docs/design/weerscherm-layouts.html | 416 ++++++++++++++++++ .../plans/2026-09-11-weerscherm.md | 382 ++++++++++++++++ docs/weerscherm-spec.md | 363 +++++++++++++++ 4 files changed, 1166 insertions(+) create mode 100644 .gitignore create mode 100644 docs/design/weerscherm-layouts.html create mode 100644 docs/superpowers/plans/2026-09-11-weerscherm.md create mode 100644 docs/weerscherm-spec.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd58d51 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +config.php +/storage/ +/vendor/ +/node_modules/ +.DS_Store diff --git a/docs/design/weerscherm-layouts.html b/docs/design/weerscherm-layouts.html new file mode 100644 index 0000000..2dcea86 --- /dev/null +++ b/docs/design/weerscherm-layouts.html @@ -0,0 +1,416 @@ + + + + + +Weerscherm: drie layoutvoorstellen + + + +
+
+

Weerscherm voor de iPad mini: drie layouts

+

Liggend formaat, op ware grootte van een iPad mini (1133 × 744 punten). Alle waarden zijn voorbeelddata; de namen van de binnenruimtes zijn placeholders.

+
+
+ + + +
+
+

+
+ + + + diff --git a/docs/superpowers/plans/2026-09-11-weerscherm.md b/docs/superpowers/plans/2026-09-11-weerscherm.md new file mode 100644 index 0000000..5c588ab --- /dev/null +++ b/docs/superpowers/plans/2026-09-11-weerscherm.md @@ -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 $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), ``, `