Files
weerscherm/docs/weerscherm-spec.md
T
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

376 lines
19 KiB
Markdown
Raw 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: bouwspecificatie v1
Opdracht voor Claude Code: bouw een PWA die fullscreen draait op een iPad mini (liggend) in de woonkamer. Het scherm toont actuele gegevens van een Netatmo-weerstation en een weersverwachting voor 6 dagen.
**Ontwerp A** in `docs/design/weerscherm-layouts.html` is leidend voor de layout. Dat bestand is een statische mockup met voorbeelddata. De CSS van ontwerp A (klasse `.da` en alle `.a-*`-klassen) is getest op 1133 × 744 punten en dient als uitgangspunt. De tabbladen B en C zijn niet in scope.
Werkwijze: lees eerst dit document en de mockup. Stel daarna een plan voor per fase (zie §12) en bouw pas na akkoord.
---
## 0. Vooraf in te vullen (Theo)
| Wat | Waarde |
|---|---|
| iPad mini-generatie en iPadOS-versie | … |
| Productie-hostname en pad | … |
| Netatmo `client_id` / `client_secret` | in `config.php`, nooit in git |
| Volgorde en labels van de 3 binnenruimtes | … (standaard: `module_name` uit Netatmo) |
| Locatie voor de verwachting | standaard de coördinaten van het Netatmo-station |
---
## 1. Scope
**In v1:**
- Buitentemperatuur, met de laagste en hoogste temperatuur van vandaag (inclusief tijdstippen) en de trend.
- De 3 binnensensoren, elk met huidige, laagste en hoogste temperatuur van vandaag.
- Regen: vandaag, de afgelopen 3 dagen per dag, en het totaal van de huidige maand.
- Verwachting voor 6 dagen (vandaag t/m 5 dagen verder): max/min-temperatuur, neerslag in mm, neerslagkans en een weericoon.
_Wijziging 2026-09-13: was oorspronkelijk 5 dagen vanaf morgen; op verzoek van Theo toegevoegd: vandaag als eerste kolom._
- Installeerbaar als PWA op het beginscherm, blijft werken met de laatst bekende data als de verbinding wegvalt.
**Niet in v1:** nachtmodus (ontwerp C), andere meetwaarden (CO₂, luchtvochtigheid, wind), meerdere stations, een instellingenscherm, pushmeldingen.
---
## 2. Architectuur
```
iPad (PWA) ──GET /api/data.php──▶ PHP-backend ──▶ Netatmo API
│ └──▶ Open-Meteo API
└── storage/ (tokens + cache)
```
De backend is nodig om drie redenen:
- Het Netatmo `client_secret` en de roterende refresh token mogen de browser nooit bereiken.
- Een cache houdt het aantal API-calls laag.
- De frontend krijgt één JSON-bestand waarin alle waarden al zijn omgerekend.
**Stack:**
- PHP 8.3 zonder framework en zonder runtime-dependencies. Alleen `ext-curl` en `ext-json`; PHPUnit als dev-dependency.
- Frontend in vanilla HTML/CSS/JS, zonder build-stap, externe fonts of CDN. Alles wordt lokaal geserveerd.
- Uitgangspunt: simpel en onderhoudbaar, ook voor iemand anders die het later overneemt.
**Mappenstructuur (voorstel):**
```
weerscherm/
├── public/ # document root
│ ├── index.html
│ ├── styles.css
│ ├── app.js
│ ├── icons.js # weericonen (inline SVG-strings)
│ ├── sw.js
│ ├── manifest.webmanifest
│ ├── icons/ # app-iconen 180, 192, 512 px
│ └── api/data.php
├── src/
│ ├── Config.php
│ ├── Http.php # dunne curl-wrapper met timeouts
│ ├── TokenStore.php # atomisch lezen/schrijven + flock
│ ├── NetatmoClient.php
│ ├── OpenMeteoClient.php
│ ├── Cache.php
│ └── DashboardService.php # bouwt de JSON-payload
├── bin/netatmo-init.php # eenmalig refresh token opslaan
├── storage/ # NIET in git, niet in docroot, schrijfbaar voor www-data
├── tests/ # PHPUnit + JSON-fixtures
├── docs/design/weerscherm-layouts.html
├── config.example.php # config.php staat in .gitignore
└── README.md
```
---
## 3. Netatmo
### 3.1 App en tokens
1. Maak een app aan op dev.netatmo.com. Dat levert een `client_id` en `client_secret` op.
2. Genereer met de token generator op de app-pagina een token met scope `read_station`.
3. Sla de token op met `php bin/netatmo-init.php <refresh_token>`. Dat schrijft naar `storage/netatmo-token.json`.
**Tokenbeheer:**
- Een access token is ongeveer 3 uur geldig. Ververs hem via `POST https://api.netatmo.com/oauth2/token` met `grant_type=refresh_token`.
- **De refresh token roteert.** Het antwoord bevat een nieuwe refresh token. Sla die direct op en gebruik vanaf dan altijd de nieuwste.
- Schrijf atomisch (tijdelijk bestand + `rename`) en onder een `flock`. Anders kunnen twee gelijktijdige requests dubbel verversen en elkaars token ongeldig maken.
- Ververs proactief als de access token binnen 5 minuten verloopt, en eenmalig opnieuw bij een 401/403.
- Geeft Netatmo `invalid_grant` terug, dan retourneert de API de fout `netatmo_auth`. De frontend toont dan "Netatmo-koppeling verlopen: voer bin/netatmo-init.php opnieuw uit".
### 3.2 Stationsdata
`GET https://api.netatmo.com/api/getstationsdata` met `Authorization: Bearer <token>`. Kies het station via `device_id` uit de config, of standaard het eerste station.
| Nodig | Bron in response |
|---|---|
| Tijdzone, locatie | `body.devices[i].place.timezone`, `place.location` (= `[lon, lat]`) |
| Buiten | module met `type = NAModule1` → `dashboard_data.Temperature`, `min_temp`, `max_temp`, `date_min_temp`, `date_max_temp` (unix), `temp_trend` (`up`/`down`/`stable`), `time_utc` |
| Binnen (3×) | het basisstation zelf (`type = NAMain`, eigen `dashboard_data`) plus modules met `type = NAModule4`; naam uit `module_name`. Volgorde en labels zijn te overschrijven in de config op basis van `_id` |
| Regenmeter | module met `type = NAModule3`: de `_id` is nodig voor §3.3; `dashboard_data.sum_rain_24` dient alleen als fallback |
Is een module onbereikbaar (`reachable: false` of geen `dashboard_data`), zet dan alle waarden van die module op `null`. De backend mag hier niet op crashen.
### 3.3 Regen per dag
Eén call levert vandaag, de vorige 3 dagen en het maandtotaal op:
```
GET https://api.netatmo.com/api/getmeasure
device_id = <_id van NAMain>
module_id = <_id van NAModule3>
scale = 1day
type = sum_rain
date_begin = 00:00 lokale tijd van min(1e van de maand, vandaag − 3 dagen)
date_end = nu
optimize = false
real_time = false
```
**Verwerking:**
- Koppel elke timestamp aan een **lokale datum in de tijdzone van het station**. Bij `real_time=false` liggen de timestamps van dagwaarden niet op middernacht, dus ga daar niet van uit.
- `today`: de waarde voor de datum van vandaag. Ontbreekt die, gebruik dan `sum_rain_24`.
- `previous_days`: de 3 datums vóór vandaag, oudste eerst. Een ontbrekende dag wordt `null` en de frontend toont dan "–".
- `month_total`: de som van alle dagen in de huidige kalendermaand, inclusief vandaag. Op de 1e t/m 3e van de maand vallen de vorige dagen in de vorige maand. Die tellen wél mee in `previous_days`, maar niet in het maandtotaal.
### 3.4 Cache en frequentie
Het station stuurt ongeveer elke 10 minuten nieuwe metingen. Cache `getstationsdata` 5 minuten en `getmeasure` 15 minuten. Dat blijft ruim binnen de rate limits van Netatmo.
---
## 4. Open-Meteo (verwachting)
Gratis voor niet-commercieel gebruik, geen API-key nodig.
```
GET https://api.open-meteo.com/v1/forecast
latitude, longitude = config, anders stationslocatie
daily = weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max
timezone = Europe/Berlin
forecast_days = 6
```
Gebruik index 1 t/m 5 (index 0 is vandaag) en cache 60 minuten.
**WMO-code → icoon:**
| Code | Betekenis | Icoon |
|---|---|---|
| 0 | onbewolkt | `sun` |
| 1, 2 | licht / half bewolkt | `partly` |
| 3 | bewolkt | `cloud` |
| 45, 48 | mist | `fog` |
| 51–57 | motregen | `drizzle` |
| 61–67 | regen | `rain` |
| 80–82 | regenbuien | `showers` |
| 71–77, 85, 86 | sneeuw | `snow` |
| 95–99 | onweer | `thunder` |
De iconen `sun`, `partly`, `rain` en `showers` staan in de mockup. Maak `cloud`, `fog`, `drizzle`, `snow` en `thunder` in dezelfde stijl:
- `viewBox 0 0 48 48`, stroke 2.4, ronde uiteinden.
- Zon-delen krijgen de klasse `i-sun`, wolk-vullingen `i-cloud` en druppels of vlokken `i-drop`.
---
## 5. API-contract: `GET /api/data.php`
```json
{
"generated_at": "2026-09-11T14:32:05+02:00",
"timezone": "Europe/Berlin",
"station": { "measured_at": "2026-09-11T14:30:12+02:00", "stale": false },
"outdoor": {
"temp": 16.4, "trend": "down",
"min": 9.2, "min_at": "2026-09-11T06:10:00+02:00",
"max": 19.8, "max_at": "2026-09-11T13:05:00+02:00"
},
"indoor": [
{ "id": "70:ee:50:…", "name": "Woonkamer", "temp": 21.3, "min": 20.4, "max": 22.1, "reachable": true }
],
"rain": {
"today": 2.4,
"previous_days": [
{ "date": "2026-09-08", "mm": 1.2 },
{ "date": "2026-09-09", "mm": 6.8 },
{ "date": "2026-09-10", "mm": 0.0 }
],
"month_total": 38.5,
"stale": false
},
"forecast": [
{ "date": "2026-09-12", "weather_code": 2, "temp_max": 18.3, "temp_min": 10.1,
"precip_mm": 0.4, "precip_prob": 20 }
],
"forecast_stale": false,
"errors": []
}
```
**Regels:**
- Geef ruwe getallen door. De frontend rondt af en formatteert.
- Tijden zijn ISO 8601 met offset, in de tijdzone van het station. Onbekende waarden zijn `null`.
- Bij een storing bij één bron: serveer de laatst gecachte data voor die bron, zet `stale: true` en voeg een code toe aan `errors` (`netatmo_auth`, `netatmo_unavailable`, `forecast_unavailable`). De HTTP-status blijft 200 zolang er iets te tonen is.
- Stuur `Cache-Control: no-store` mee.
- Met `DATA_SOURCE=fixture` in de config levert de API vaste voorbeelddata. Dan kan de frontend gebouwd worden zonder Netatmo-koppeling.
---
## 6. Frontend
### 6.1 Canvas en schaal
- Ontwerp op een vast canvas van **1133 × 744**, het formaat van de iPad mini 6 in liggende stand.
- Schaal het canvas met `transform: scale()` op `min(innerWidth/1133, innerHeight/744)` en centreer het. Vul de ruimte eromheen op met de achtergrondkleur. Zo klopt de layout ook op een mini 5 (1024 × 768).
- Alleen liggend. In portretstand verschijnt de melding "Draai de iPad een kwartslag".
- Gebruik `viewport-fit=cover` en houd rekening met `env(safe-area-inset-*)`.
### 6.2 Design tokens (uit de mockup)
| Token | Waarde | Gebruik |
|---|---|---|
| `--bg` | `#E8EDF1` | achtergrond, ook de vulling van wolk-iconen |
| `--ink` | `#1B2A35` | hoofdtekst |
| `--muted` | `#5F707C` | labels, secundaire tekst |
| `--line` | `#C6D0D8` | scheidingslijnen |
| `--cold` | `#3A75A3` | minimumtemperaturen |
| `--warm` | `#B8761C` | maximumtemperaturen |
| `--rain` | `#2E6A99` | regen |
| `--sun` | `#D39A2C` | zon in iconen |
- Font: `-apple-system, system-ui, sans-serif` (SF Pro op de iPad), met overal `font-variant-numeric: tabular-nums`.
- Grid: padding `30px 44px`, kolommen `1fr 330px`, rijen `40px 1fr 232px`, `column-gap: 56px`.
- Onderste zone: kolommen `390px 1fr`, `column-gap: 36px`. Deze waarden zijn in de mockup al gecorrigeerd.
### 6.3 Zones en formattering
**Algemene notatie:** `nl-NL`, decimale komma, tijden in de tijdzone van het station via `Intl.DateTimeFormat`.
- **Kopregel:** datum ("Vrijdag 11 september") links. Rechts "meting HH:MM" (uit `station.measured_at`) en de klok (apparaattijd, per minuut bijgewerkt).
- **Buiten:**
- 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".
- **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".
- **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.
De uurdata komt in dezelfde Open-Meteo-call mee (`hourly=temperature_2m,precipitation,wind_speed_10m,
wind_direction_10m`), dus geen extra netwerkverzoek bij het openen. Windpijl wijst waarheen de wind waait
(richting + 180°). Zie `src/HourlyForecastMapper.php` en `renderHourlyCharts()`/`openHourOverlay()` in
`public/app.js`._
### 6.4 Verversen
- Haal `/api/data.php` op bij het laden, daarna elke 5 minuten, en direct bij `visibilitychange` naar zichtbaar.
- Om 00:00 lokale tijd: opnieuw ophalen en de datum bijwerken.
- Dagelijks rond 04:00 een volledige `location.reload()`. Dat voorkomt geheugenlekken en pakt nieuwe deploys op.
- Optioneel: vraag een `navigator.wakeLock` aan als die beschikbaar is. De primaire oplossing is Automatisch vergrendelen op "Nooit" in de iPad-instellingen.
### 6.5 Verouderde data en fouten
- Is `station.measured_at` ouder dan 30 minuten, dan wordt "meting HH:MM" `--warm` met de toevoeging "verouderd".
- Is de API niet bereikbaar, laat dan de laatste data staan en toon klein in de kopregel "geen verbinding sinds HH:MM".
- Bij `netatmo_auth`: toon een duidelijke melding over de hele bovenzone met de herstelinstructie uit §3.1.
- Nooit een leeg of wit scherm. Bij de allereerste load zonder data: toon "Gegevens laden…" en daarna een concrete foutmelding.
### 6.6 Browserdoel
Hangt af van de iPadOS-versie in §0:
- **iPadOS 15 of nieuwer:** moderne JS/CSS is prima.
- **Ouder:** schrijf ES2017 (geen optional chaining of `??`). In CSS geen `gap` op flexbox (gebruik margins), geen `inset` en geen `aspect-ratio`. Grid-`gap` mag wel.
---
## 7. PWA
- **`manifest.webmanifest`:**
- `name: "Weerscherm"`, `short_name: "Weer"`, `display: "standalone"`, `orientation: "landscape"`.
- `start_url` en `scope` relatief (`"./"`).
- `background_color` en `theme_color`: `#E8EDF1`.
- Iconen van 192 en 512 px.
- **iOS-meta:** `apple-mobile-web-app-capable`, `apple-mobile-web-app-status-bar-style` (`default`), een `apple-touch-icon` van 180 px, `apple-mobile-web-app-title`.
- **Service worker:**
- De app-shell (html, css, js, iconen) is cache-first met een geversioneerde cachenaam.
- `/api/data.php` is network-first met een timeout van 8 s. Val terug op de laatst gecachte response en markeer die in de UI als verouderd.
- Gebruik `skipWaiting` + `clients.claim`. De pagina herlaadt zichzelf bij `controllerchange`.
- **App-icoon:** een eenvoudig zon/wolk-icoon in de stijl van de weericonen, op `--bg`.
---
## 8. Configuratie en beveiliging
`config.example.php` staat in git, `config.php` niet. Sleutels:
- `netatmo_client_id`, `netatmo_client_secret`
- `netatmo_device_id` (optioneel)
- `timezone` (standaard `Europe/Berlin`)
- `forecast_lat` / `forecast_lon` (optioneel)
- `indoor_modules`: optioneel, een lijst van `_id` → label in de gewenste volgorde
- `data_source` (`live` | `fixture`)
- `cache_ttl` per bron
**Beveiliging:**
- `storage/` staat buiten de document root en wordt nooit geserveerd. Tokenbestanden krijgen `chmod 600`.
- Er komen geen secrets of tokens in responses, logs of de frontend.
- Stuur `X-Robots-Tag: noindex` mee en zet een `robots.txt` die alles blokkeert.
- Standaard geen login. Dat is proportioneel voor weerdata, en HTTP basic auth werkt slecht in iOS-standalone-PWA's. Plaats de app wel op een niet-voor-de-hand-liggend (sub)domein of pad.
---
## 9. Lokaal ontwikkelen en deployen
- **Lokaal:** via de bestaande OrbStack shared-Apache-setup met een PHP 8.3-container, onder `weerscherm.test`. Begin met `data_source = fixture`.
- **Visuele check:** een browservenster van exact 1133 × 744 naast `docs/design/weerscherm-layouts.html` (tab A).
- **Deploy:** Git op de self-hosted Gitea (squash-and-merge), deploy via DeployPad. `storage/` moet deploys overleven: zet hem buiten de release-map of maak er een gedeelde map van.
- **README:** installatie, Netatmo-koppeling (§3.1), config, deploy, en de iPad-instellingen:
- toevoegen aan beginscherm
- Automatisch vergrendelen → Nooit
- Begeleide toegang
---
## 10. Tests
**PHPUnit, met JSON-fixtures van echte responses** (geanonimiseerde MAC-adressen):
- Mapping van `getstationsdata` naar de payload, inclusief een onbereikbare module en een ontbrekende regenmeter.
- Regenberekening: datum-mapping in de stationstijdzone, de maandgrens (1e–3e van de maand), een ontbrekende dag, en de dag van de zomertijdwissel.
- Token-refresh: de nieuwe refresh token wordt opgeslagen, een gelijktijdige refresh verloopt goed dankzij de lock, en `invalid_grant` geeft de fout `netatmo_auth`.
- Stale-fallback: bij een upstream-fout wordt de gecachte data geserveerd met `stale: true`.
**Frontend:** houd formatteer- en rekenfuncties puur en los testbaar. Denk aan de decimale komma, de markerpositie, de staafhoogtes en de WMO-mapping. Vitest mag als dev-dependency, maar zonder build-stap voor productie.
---
## 11. Acceptatiecriteria
- [ ] Op de iPad mini, gestart vanaf het beginscherm, draait de app fullscreen zonder Safari-balken en klopt de layout visueel met ontwerp A.
- [ ] 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.
- [ ] 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.
- [ ] Geen secrets in git, in de browser of in responses. `storage/` is niet via HTTP bereikbaar.
- [ ] De PHPUnit-tests uit §10 slagen.
---
## 12. Werkvolgorde
1. **Backend met fixtures:** mappenstructuur, config, `DashboardService` op fixtures, `/api/data.php`, tests voor de mapping en de regenberekening.
2. **Frontend:** ontwerp A op basis van de fixture-API, met schaal, formattering, verversen en de foutstatussen.
3. **Netatmo live:** `bin/netatmo-init.php`, TokenStore met lock, `getstationsdata` en `getmeasure`, cache, stale-fallback, tokentests.
4. **Open-Meteo live:** client, cache, WMO-mapping en de ontbrekende iconen.
5. **PWA:** manifest, iOS-meta, service worker, app-iconen.
6. **Deploy en README:** productieconfig, DeployPad, een test op de iPad aan de hand van de acceptatiecriteria.
Sluit elke fase af met iets dat te testen is, en meld afwijkingen van deze spec voordat je ze doorvoert.