info-canicule/CLAUDE.md
Florian 9db5d4c204
Some checks failed
Deploy info-canicule / deploy (push) Failing after 4s
fix(climato): fiabiliser les jours récents à couverture station partielle
Les 1-2 derniers jours du flux climato `latest` ont souvent 0-1 station
publiée (parfois TN sans TX), produisant des agrégats département nuls ou
non représentatifs. Symptômes côté page dept : TX/TN vides ou tx==tn à
J-1/J-2 sur le graphe, et moyenne 3j/7j figée (computeAnomaly lisait
climato.days brut et retombait sur les mêmes vieux jours complets).

- climato.ts : exposer la couverture réelle par champ (txN/tnN, optionnels,
  rétro-compatibles avec le cache 24h).
- [code].astro : `_mergedDay` choisit par champ la source fiable —
  climato si couverture >= 50% de la médiane station du dept, sinon SYNOP
  (garde >= 3 obs/jour pour éviter tx==tn), sinon valeur fine, sinon null.
  Le badge anomalie utilise désormais les 7 derniers jours complets
  (finissant J-1) au lieu de climato.days brut, recollant graphe et badge.

Vérifié par simulation sur données réelles (depts 31/75/13) : jours pleins
inchangés (zéro régression), jours récents partiels comblés proprement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:04:34 +02:00

87 lines
8.8 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.

# CLAUDE.md — info-canicule
## Objectif
Site d'utilité publique qui affiche en temps réel la Vigilance Météo France par département
+ les conseils officiels (canicule, orages, vent, pluie, neige, avalanches, vagues-submersion).
Hébergé sur le VPS Nocleus partagé (réseau `shared-net`, cache Valkey ACL `info-canicule`).
## Stack
- Astro 5 SSR (`output: 'server'`, adapter `@astrojs/node` mode standalone)
- TailwindCSS 3 (via `@astrojs/tailwind`) + `@tailwindcss/typography`
- `@astrojs/sitemap` (statiques) + `src/pages/sitemap-departements.xml.ts` (les 96 dépts dynamiques)
- `@sentry/astro` opt-in via `SENTRY_DSN` (cible GlitchTip, sample 0.1, source maps non uploadées). Si DSN absent, intégration omise.
- ioredis pour le cache Valkey
- TypeScript strict
- Pas de DB Postgres (toutes les données vivent en cache Valkey, refresh à la demande)
- Tests Playwright (`tests/`, `pnpm test:e2e` / `test:e2e:local`)
- OG image générée au build par `scripts/build-og-image.mjs` (sharp, devDep)
## Sources de données
Quatre flux indépendants, tous cachés en Valkey :
- **Vigilance** (`src/lib/vigilance.ts`) — **API officielle Météo France** `DPVigilance/v1/cartevigilance/encours` (source canonique, publication directe, couvre métropole + côtes). Auth via `METEOFRANCE_API_KEY`. Fallback automatique sur **Opendatasoft** `weatherref-france-vigilance-meteo-departement` (Licence Ouverte 2.0, no-auth) si MF échoue, puis sur `:last-good` (30 j). Override possible via `VIGILANCE_PROVIDER=opendatasoft|meteofrance` pour debug.
- **Observations horaires SYNOP** (`src/lib/observations.ts`) — API Météo France, station SYNOP la plus proche du centroïde du dept, pré-calculé dans `src/data/stations-synop.json`. Alimente l'onglet « 24 h » sur les pages dept.
- **Climato quotidienne** (`src/lib/climato.ts`) — fichiers CSV gzippés `data.gouv.fr/meteofrance/.../QUOT` (`latest-2025-2026` pour l'année courante, `previous-1950-2024` pour l'historique long). 365 jours glissants par dept. Cold-fetch optimisé : on skippe `previous-1950-2024` si `latest` suffit (cf. commit `ac46637`).
- **Normales 1991-2020** (`src/lib/normales.ts`) — JSON statique `src/data/normales.json` (généré par `scripts/build-normales.mjs`), indexé par dept × day-of-year (1..366), lissage 7 jours.
## Schéma cache
| Clé Valkey | TTL (frais → hard) | Contenu |
|---|---|---|
| `vigilance:snapshot` | `VIGILANCE_CACHE_TTL` (défaut 900s) → ×6 | `VigilanceSnapshot` (fetchedAt + productDatetime + alerts[]) |
| `vigilance:snapshot:fallback` | idem | Snapshot Opendatasoft de secours si provider principal échoue |
| `vigilance:snapshot:last-good` | 30 jours | Dernier snapshot connu (filet de sécurité quand tous providers KO) |
| `mf:hourly:<dept>:<hours>` | 30 min → ×6 | `HourlySeries` (obs SYNOP sur fenêtre glissante) |
| `climato:<dept>` | 24h → ×6 | `ClimatoSeries` (365 jours) |
Le prefix `info-canicule:` est injecté par ioredis (`keyPrefix`), donc la clé réelle côté Valkey est `info-canicule:<clé>` (l'ancien doc parlait d'un double prefix — c'était une erreur). Compatible avec l'ACL `~info-canicule:*` côté VPS.
**SWR** : `cacheOrFetch` stocke `{v, fu}` (valeur + freshUntil). Au-delà de `fu` mais avant l'expiration Valkey (= ttl × 6), la valeur est servie stale et un refresh est déclenché en background (lock in-process pour éviter le stampede). Un blip MF de ~25 min reste invisible côté visiteur. Voir `src/lib/cache.ts`.
**last-good** : à chaque fetch réussi, le snapshot Vigilance est aussi persisté en `:last-good` (TTL 30j). Si MF + Opendatasoft échouent en même temps et que `:snapshot`/`:fallback` sont expirés, on sert `:last-good` plutôt que de planter la page. Le visiteur voit la date du bulletin dans l'UI (déjà affichée) et constate l'ancienneté.
## Pages exposées
- `/` — home + carte/grille des dépts
- `/departement/[code]` — fiche dept avec onglets Vigilance / Observations SYNOP 24h / Climato 365j vs normales
- `/embed/` + `/embed/dept/[code]` — versions embarquables (iframe-friendly) pour intégration tierce
- `/conseils/` + `/conseils/registre-canicule` — fiches statiques
- `/api/health` — JSON `{status, cache, vigilance: {productDatetime, ageSeconds}}`. Usage : UptimeRobot + cron HC.io de fraîcheur. Pas de CORS, `Cache-Control: no-store`, renvoie 503 si Valkey KO.
- `/sitemap-index.xml` + `/sitemap-departements.xml` (les 96 dépts filtrés hors `@astrojs/sitemap` standard).
## Cache HTTP (en plus du cache Valkey)
Home et pages dept renvoient `Cache-Control: ... must-revalidate` (cf. commit `e72f25b`) pour autoriser le revalidation des CDN/navigateurs tout en bornant la fraîcheur. À garder cohérent avec le TTL Valkey.
## Pièges connus / à surveiller
- **`product_datetime` change 2× par jour** (~06h et 16h Paris). Tant que Météo France n'a pas publié le suivant, le snapshot renvoie les valeurs J→J1 du bulletin courant. Inutile de poll plus vite que 15 min. **Les deux bulletins contiennent J ET J1** — l'onglet "Demain" sur la home est donc toujours disponible quand on est dans la fenêtre du bulletin (sauf entre minuit et la pub de 6h, où `currentEcheance` renvoie J1 et il n'y a plus de J2 dispo).
- **Echéance "J" = aujourd'hui, "J1" = demain**. Ne pas confondre avec un horizon en heures.
- **`phenomenon_id`** : 1=vent, 2=pluie, 3=orages, 5=neige/verglas, 6=canicule, 8=avalanches, 9=vagues-submersion. Pas de 4 ni 7.
- **Andorre** (`domain_id = 99`) est inclus dans le flux Vigilance mais n'est pas un département français. Mappé comme `99 — Andorre (zone Vigilance)` côté front pour ne pas l'écarter silencieusement.
- **Corse** : codes `2A` et `2B`, pas `20`. Mais pour la **climato `previous-1950-2024`**, le fichier source est encore sous code `20` (split 2A/2B post-1976) — `buildUrl()` dans `climato.ts` gère ce cas.
- **SYNOP renvoie les températures en Kelvin**. `observations.ts` les convertit via `K2C()`. Piège classique si on ajoute un nouveau champ température.
- **DROM** : pas (encore) inclus dans le mapping `departements.ts` côté front. Vigilance Métropole only pour le MVP. Pour ajouter Outre-mer : étendre `DEPARTEMENTS` + tester si `domain_id` correspond aux codes 971-978.
- **Conseils par phénomène** (`advice.ts`) : texte curated depuis sante.gouv.fr et meteofrance.fr. À relire / actualiser périodiquement (au moins 1× par an).
- **Climato — couverture station des derniers jours très partielle** : dans `latest-2025-2026`, les 1-2 jours les plus récents ont souvent 0-1 station publiée (parfois TN sans TX) → l'agrégat département est nul ou non représentatif. La page dept (`[code].astro`, `_mergedDay`) ne fait confiance à l'agrégat climato d'un jour que si sa couverture ≥ 50 % de la médiane station du dept ; sinon elle bascule sur le SYNOP (garde ≥ 3 obs/jour pour éviter `tx==tn`), puis sur la valeur fine, puis `null`. Le badge anomalie utilise les 7 derniers jours **complets** (finissant J-1, today exclu car partiel). Ne pas réintroduire de lecture directe de `climato.days` brut pour le calcul d'anomalie.
- **Normales 1991-2020** : fichier `src/data/normales.json` committé. Régénération via `scripts/build-normales.mjs` quand la décennie de référence Météo France change (~tous les 10 ans, prochain ~2031).
- **Cache miss au boot** : si Valkey est down, `cacheOrFetch` log un warning mais re-fetch à chaque requête — pas de fallback persistant. Acceptable pour un service stateless, mais surveiller la latence Opendatasoft.
## Déploiement
Pattern Reteno : push main → CI Forgejo SSH au VPS → `git fetch && reset --hard && make env && docker compose up -d --build --wait`.
Le `.env.tmpl` est commit, le `.env` réel est matérialisé par `make env` (pass-cli, vault `Infra`).
## Météo France API — rotation API Key
- Vault item : `Infra/Météo France API`, field `api_key` (hidden) + `created_at` + `expires_at`.
- Le portail (portail-api.meteofrance.fr) propose une **API Key longue durée** (durée choisie à la création, max ~10 ans). Pas de flow OAuth2 client_credentials dispo gratuitement → on n'utilise QUE l'API Key, pas de refresh automatique.
- Code : `src/lib/meteofrance-auth.ts` — header `apikey: ${METEOFRANCE_API_KEY}` (PAS `Authorization: Bearer`, qui est le format des tokens OAuth2 courts du portail).
- Rotation : avant l'expiration notée dans le vault, regénérer côté portail, copier dans le vault, `make env` côté VPS, `docker compose up -d --force-recreate app`.
- Si la clé expire / est révoquée : l'onglet « Observations 24 h » (SYNOP) sur les pages dept ne s'affiche plus (skip silencieux), pas d'impact sur Vigilance, Climato ni le reste du site. Erreur loggée côté serveur uniquement.
- Si on veut être alerté : prévoir un cron HC.io qui curl `/api/vigilance` non, qui curl un endpoint test `MF` (à coder). Pour l'instant : se reposer sur la date `expires_at` du vault item.