From 290f9be7b95349e5eeab34735c6e5e62c0caa3fa Mon Sep 17 00:00:00 2001 From: Florian Date: Tue, 26 May 2026 18:58:03 +0200 Subject: [PATCH] fix+perf: corrections de revue (currentEcheance, dayOfYear, SWR, last-good, doc apikey) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vigilance: currentEcheance basée sur productDatetime (jour calme renvoyait J1 à tort) - normales: dayOfYear extrait en Europe/Paris pour 'now' (UTC mélangeait les jours après minuit) - meteofrance-auth + CLAUDE.md: header `apikey:` documenté correctement (pas Authorization Bearer) - cache: SWR — envelope {v, fu}, hard TTL = ttl*6, refresh background avec lock anti-stampede - vigilance: snapshot last-good (TTL 30j) écrit à chaque fetch, fallback final si MF+ODS KO - vigilance: nettoyage variable url morte dans fetchOpendatasoft Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 58 +++++++++++++++++------ src/lib/cache.ts | 93 ++++++++++++++++++++++++++++++++++--- src/lib/meteofrance-auth.ts | 4 +- src/lib/normales.ts | 17 +++++-- src/lib/vigilance.ts | 74 ++++++++++++++++++++++------- 5 files changed, 201 insertions(+), 45 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5b06f0b..6c7027c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,26 +10,54 @@ Hébergé sur le VPS Nocleus partagé (réseau `shared-net`, cache Valkey ACL `i ## Stack - Astro 5 SSR (`output: 'server'`, adapter `@astrojs/node` mode standalone) -- TailwindCSS 3 (via `@astrojs/tailwind`) +- 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 (le snapshot Vigilance vit en cache, refresh toutes les 15 min) +- 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) -## Source de données +## Sources de données -- **Provider par défaut** : `weatherref-france-vigilance-meteo-departement` chez Opendatasoft. - - URL : `https://public.opendatasoft.com/api/explore/v2.1/catalog/datasets/weatherref-france-vigilance-meteo-departement/records` - - Pas d'auth, JSON propre, ~1 000 records par snapshot (départements × phénomènes × échéances J/J1) - - Licence Ouverte 2.0 -- **Migration possible** : API Vigilance Météo France officielle (portail-api.meteofrance.fr) si fraîcheur Opendatasoft insuffisante. Nécessite un token sur le portail Météo France. Abstraction `vigilance.ts` à découper en deux implémentations le jour venu. +Quatre flux indépendants, tous cachés en Valkey : + +- **Vigilance** (`src/lib/vigilance.ts`) — Opendatasoft `weatherref-france-vigilance-meteo-departement` (Licence Ouverte 2.0, pas d'auth, ~1 000 records par snapshot, dépts × phénomènes × échéances J/J1). Provider Météo France officiel utilisable en fallback si la clé est dispo. URL : `https://public.opendatasoft.com/api/explore/v2.1/catalog/datasets/weatherref-france-vigilance-meteo-departement/records`. +- **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. + +Abstraction `vigilance.ts` à découper en deux implémentations (Opendatasoft / Météo France officiel) le jour où on bascule. ## Schéma cache -| Clé Valkey | TTL | Contenu | +| Clé Valkey | TTL (frais → hard) | Contenu | |---|---|---| -| `info-canicule:vigilance:snapshot` | `VIGILANCE_CACHE_TTL` (défaut 900s) | `VigilanceSnapshot` complet (fetchedAt + productDatetime + alerts[]) | +| `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::` | 30 min → ×6 | `HourlySeries` (obs SYNOP sur fenêtre glissante) | +| `climato:` | 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:info-canicule:vigilance:snapshot`. Compatible avec l'ACL `~info-canicule:*` côté VPS. +Le prefix `info-canicule:` est injecté par ioredis (`keyPrefix`), donc la clé réelle côté Valkey est `info-canicule:` (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 @@ -37,9 +65,11 @@ Le prefix `info-canicule:` est injecté par ioredis (`keyPrefix`), donc la clé - **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`. +- **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). +- **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 @@ -52,7 +82,7 @@ Le `.env.tmpl` est commit, le `.env` réel est matérialisé par `make env` (pas - 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 `Authorization: Bearer ${METEOFRANCE_API_KEY}`. +- 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 « 24 h » sur les pages dept ne s'affiche plus (skip silencieux), pas d'impact sur le reste du site. Erreur loggée côté serveur uniquement. +- 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. diff --git a/src/lib/cache.ts b/src/lib/cache.ts index 4f0aba1..3e86fa9 100644 --- a/src/lib/cache.ts +++ b/src/lib/cache.ts @@ -19,11 +19,36 @@ function getClient(): Redis { return client; } +// Envelope SWR : `v` = valeur, `fu` = freshUntil (timestamp ms). +// L'entrée Valkey expire via TTL `hardTtl` (= ratio fresh × STALE_RATIO), +// `fu` indique la limite "fraîche" en deçà de laquelle aucun refresh n'est tenté. +interface SwrEnvelope { + v: T; + fu: number; +} + +// Combien de temps on est prêt à servir une valeur stale après freshUntil, +// en multiple du TTL frais. 6× = ex. 5 min frais → on garde 30 min en cache et +// refresh en background quand on dépasse 5 min. +const STALE_RATIO = 6; + +// Locks in-process pour éviter le stampede en refresh background : N requêtes +// concurrentes sur la même clé qui dépasse freshUntil → 1 seul fetch. +const inflight = new Map>(); + +function isEnvelope(v: unknown): v is SwrEnvelope { + return !!v && typeof v === 'object' && 'v' in (v as object) && 'fu' in (v as object); +} + export async function cacheGet(key: string): Promise { try { const raw = await getClient().get(key); if (!raw) return null; - return JSON.parse(raw) as T; + const parsed = JSON.parse(raw); + // Compat : ancienne shape (valeur brute sans envelope) → on la traite comme + // une valeur fraîche pour la durée résiduelle du TTL natif Valkey. + if (isEnvelope(parsed)) return parsed.v; + return parsed as T; } catch (err) { console.warn('[cache] get failed for', key, (err as Error).message); return null; @@ -32,22 +57,76 @@ export async function cacheGet(key: string): Promise { export async function cacheSet(key: string, value: T, ttlSec: number): Promise { try { - await getClient().set(key, JSON.stringify(value), 'EX', ttlSec); + const env: SwrEnvelope = { v: value, fu: Date.now() + ttlSec * 1000 }; + await getClient().set(key, JSON.stringify(env), 'EX', ttlSec * STALE_RATIO); } catch (err) { console.warn('[cache] set failed for', key, (err as Error).message); } } +/** + * Stale-while-revalidate. + * + * - Cache miss : fetch synchrone, set, retourne. + * - Cache hit + fresh (now < freshUntil) : retourne immédiatement. + * - Cache hit + stale : retourne la valeur stale + déclenche un refresh + * background (lock in-process pour éviter le stampede). Si le fetch + * background échoue, on log mais on conserve la valeur stale en cache + * jusqu'à l'expiration `hardTtl` (= ttlSec × STALE_RATIO). + * + * Effet : les visiteurs ne paient jamais la latence d'un refetch tant qu'on + * a une valeur même stale. Sur Vigilance (5 min fresh, 30 min hard), un + * blip MF de 25 min reste invisible pour l'utilisateur. + */ export async function cacheOrFetch( key: string, ttlSec: number, fetcher: () => Promise, ): Promise { - const cached = await cacheGet(key); - if (cached !== null) return cached; - const fresh = await fetcher(); - await cacheSet(key, fresh, ttlSec); - return fresh; + const raw = await rawGet(key); + if (raw === null) { + return doFetchAndSet(key, ttlSec, fetcher); + } + if (!isEnvelope(raw)) { + // Ancienne shape : on la sert + on refresh en background pour migrer. + triggerBackgroundRefresh(key, ttlSec, fetcher); + return raw as T; + } + if (Date.now() < raw.fu) return raw.v; + triggerBackgroundRefresh(key, ttlSec, fetcher); + return raw.v; +} + +async function rawGet(key: string): Promise { + try { + const raw = await getClient().get(key); + if (!raw) return null; + return JSON.parse(raw); + } catch (err) { + console.warn('[cache] get failed for', key, (err as Error).message); + return null; + } +} + +async function doFetchAndSet(key: string, ttlSec: number, fetcher: () => Promise): Promise { + const existing = inflight.get(key) as Promise | undefined; + if (existing) return existing; + const p = (async () => { + const fresh = await fetcher(); + await cacheSet(key, fresh, ttlSec); + return fresh; + })().finally(() => { + inflight.delete(key); + }); + inflight.set(key, p); + return p; +} + +function triggerBackgroundRefresh(key: string, ttlSec: number, fetcher: () => Promise): void { + if (inflight.has(key)) return; + void doFetchAndSet(key, ttlSec, fetcher).catch((err) => { + console.warn('[cache] background refresh failed for', key, (err as Error).message); + }); } export async function pingCache(): Promise { diff --git a/src/lib/meteofrance-auth.ts b/src/lib/meteofrance-auth.ts index d520866..8b2d936 100644 --- a/src/lib/meteofrance-auth.ts +++ b/src/lib/meteofrance-auth.ts @@ -4,7 +4,9 @@ // sans flow refresh) et (b) API Key permanente / longue durée. On utilise (b). // // Configuration via env : -// METEOFRANCE_API_KEY → clé permanente, envoyée en header `Authorization: Bearer ` +// METEOFRANCE_API_KEY → clé permanente, envoyée en header `apikey: ` +// (PAS `Authorization: Bearer` — c'est le format des +// tokens OAuth2 courts, pas des API Keys du portail). // // Quand la clé approche de l'expiration (cf. duration choisie à la création), // régénérer côté portail puis mettre à jour le vault `Infra/Météo France API`, diff --git a/src/lib/normales.ts b/src/lib/normales.ts index ca5a786..dbecab4 100644 --- a/src/lib/normales.ts +++ b/src/lib/normales.ts @@ -27,12 +27,19 @@ export interface DailyNormale { } // Day-of-year en convention "leap calendar" (1..366), aligné sur le calendrier bissextile. -// Les années non-bissextiles : on bascule simplement le 1er mars au doy 60 si pas 29 fév. -// Mais pour matcher l'indexation du JSON (1..366), on utilise une convention stable. +// Extraction Y/M/D en Europe/Paris pour rester cohérent avec : +// - les pages SSR qui formatent en Europe/Paris (todayLabel, productDate), +// - les obs climato YYYY-MM-DD (parsées en UTC midnight, donc Paris == J ou J-1 +// selon offset DST, mais ça représente toujours la "journée Paris" du jour +// suivant — assez proche pour le lookup normale). +// - `new Date()` (now) : en UTC, getUTC* renvoie hier entre 00h et 01h/02h Paris. +// Toujours utiliser le jour Paris-local. export function dayOfYear(date: Date): number { - const y = date.getUTCFullYear(); - const m = date.getUTCMonth() + 1; - const d = date.getUTCDate(); + // sv-SE → format "YYYY-MM-DD" + const iso = date.toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' }); + const y = parseInt(iso.slice(0, 4), 10); + const m = parseInt(iso.slice(5, 7), 10); + const d = parseInt(iso.slice(8, 10), 10); const cumulNonLeap = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]; const cumulLeap = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335]; const isLeap = (y % 4 === 0 && y % 100 !== 0) || (y % 400 === 0); diff --git a/src/lib/vigilance.ts b/src/lib/vigilance.ts index 636264c..407dbac 100644 --- a/src/lib/vigilance.ts +++ b/src/lib/vigilance.ts @@ -1,4 +1,4 @@ -import { cacheOrFetch } from './cache'; +import { cacheOrFetch, cacheGet, cacheSet } from './cache'; import type { ColorId, PhenomenonId } from './phenomena'; import { COLORS, PHENOMENA } from './phenomena'; import { fetchMF, hasMeteoFranceCredentials } from './meteofrance-auth'; @@ -23,9 +23,18 @@ const OPENDATASOFT_BASE = 'https://public.opendatasoft.com/api/explore/v2.1/catalog/datasets/weatherref-france-vigilance-meteo-departement/records'; const CACHE_KEY = 'vigilance:snapshot'; +// Snapshot longue durée, écrit à chaque fetch réussi (peu importe le provider). +// Sert de filet de sécurité quand cache short-TTL expiré + tous providers KO. +// 30 jours : large, mais c'est la valeur la plus récente qu'on ait obtenue ; +// le visiteur voit `productDatetime` qui révèle l'ancienneté du bulletin. +const CACHE_KEY_LAST_GOOD = 'vigilance:snapshot:last-good'; +const LAST_GOOD_TTL = 30 * 24 * 60 * 60; + +async function persistLastGood(snap: VigilanceSnapshot): Promise { + await cacheSet(CACHE_KEY_LAST_GOOD, snap, LAST_GOOD_TTL); +} async function fetchOpendatasoft(): Promise { - const url = `${OPENDATASOFT_BASE}?limit=100&offset=0`; const all: VigilanceAlert[] = []; let offset = 0; let total = Infinity; @@ -175,40 +184,69 @@ function pickProvider(): { name: string; fn: () => Promise } return { name: 'opendatasoft', fn: fetchOpendatasoft }; } +async function fetchAndPersist(fn: () => Promise): Promise { + const snap = await fn(); + // Best-effort : on n'attend pas, et on n'échoue pas si Valkey est down. + void persistLastGood(snap); + return snap; +} + export async function getVigilanceSnapshot(): Promise { const ttl = parseInt(process.env.VIGILANCE_CACHE_TTL ?? '900', 10); const provider = pickProvider(); try { - return await cacheOrFetch(CACHE_KEY, ttl, provider.fn); + return await cacheOrFetch(CACHE_KEY, ttl, () => fetchAndPersist(provider.fn)); } catch (e) { // Fallback automatique vers l'autre provider en cas d'erreur (token expiré, MF API down, etc.) if (provider.name === 'meteofrance') { console.warn('[vigilance] MF failed, falling back to opendatasoft:', (e as Error).message); - return cacheOrFetch(CACHE_KEY + ':fallback', ttl, fetchOpendatasoft); + try { + return await cacheOrFetch(CACHE_KEY + ':fallback', ttl, () => fetchAndPersist(fetchOpendatasoft)); + } catch (e2) { + const lastGood = await cacheGet(CACHE_KEY_LAST_GOOD); + if (lastGood) { + console.warn('[vigilance] both providers failed, serving last-good from', lastGood.fetchedAt); + return lastGood; + } + throw e2; + } + } + // provider == opendatasoft (uniquement) : pas d'autre provider, on tente :last-good + const lastGood = await cacheGet(CACHE_KEY_LAST_GOOD); + if (lastGood) { + console.warn('[vigilance] opendatasoft failed, serving last-good from', lastGood.fetchedAt); + return lastGood; } throw e; } } /** - * Détermine quelle echeance du bulletin couvre l'instant présent. + * Détermine quelle echeance du bulletin couvre "aujourd'hui réel" pour le visiteur. * - * Le bulletin Météo France publié à 16h Paris reste valide jusqu'au prochain - * bulletin (~6h Paris le lendemain). Pendant la nuit (de minuit à 6h), on se - * retrouve techniquement dans le J+1 du bulletin, qui correspond pour - * l'utilisateur à "aujourd'hui réel". + * Le bulletin Météo France publié à ~16h Paris reste valide jusqu'au prochain + * bulletin (~6h Paris le lendemain). Entre minuit et la publication du suivant, + * la journée calendaire de `productDatetime` (= "J" du bulletin) correspond à + * "hier" pour le visiteur — on doit alors basculer sur J1. * - * Logique : si l'instant présent est avant la fin du J, on utilise J ; sinon J1. + * Comparaison strictement sur le jour calendaire en Europe/Paris : + * - si jour(now) == jour(productDatetime) → echeance = 'J' + * - sinon (bulletin de la veille servi avant la prochaine publication) → 'J1' + * + * ⚠ Ne pas se baser sur `endTime` des alertes filtrées par echeance J : + * en jour calme (toutes alertes vert), aucune alerte J n'est présente dans + * le snapshot → l'ancienne impl renvoyait J1 par défaut et l'UI affichait + * "demain" partout. Corrigé en s'appuyant sur productDatetime. */ +function parisDateKey(d: Date): string { + return d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' }); +} + export function currentEcheance(snapshot: VigilanceSnapshot): 'J' | 'J1' { - const now = new Date().toISOString(); - const jEnd = snapshot.alerts - .filter((a) => a.echeance === 'J') - .map((a) => a.endTime) - .sort() - .slice(-1)[0]; - if (jEnd && now < jEnd) return 'J'; - return 'J1'; + if (!snapshot.productDatetime) return 'J'; + const bulletinDay = parisDateKey(new Date(snapshot.productDatetime)); + const nowDay = parisDateKey(new Date()); + return nowDay === bulletinDay ? 'J' : 'J1'; } export function maxColorByDepartement(snapshot: VigilanceSnapshot, echeance: 'J' | 'J1' = 'J'): Map {