feat(home): onglets Aujourd'hui / Demain pour la carte Vigilance
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run

Permet au visiteur de basculer entre la carte du jour et celle de J+1
(toujours présente dans le bulletin MF, sauf entre minuit et la pub de
6h où l'onglet est désactivé avec explication). Réutilise les helpers
existants (currentEcheance, maxColorByDepartement) — pas de fetch
supplémentaire, J et J1 sont déjà dans le snapshot.

Bonus :
- dayLabel calculé depuis "maintenant Paris" plutôt qu'un sample
  d'alerte → fonctionne en jour calme tout-vert.
- CLAUDE.md : clarifie que MF officiel est canonique et Opendatasoft
  fallback (au lieu de la formulation "le jour où on bascule").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian 2026-05-27 00:47:56 +02:00
parent 8638b260ff
commit 15635ed0e3
2 changed files with 73 additions and 12 deletions

View file

@ -23,13 +23,11 @@ Hébergé sur le VPS Nocleus partagé (réseau `shared-net`, cache Valkey ACL `i
Quatre flux indépendants, tous cachés en Valkey : 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`. - **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. - **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`). - **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. - **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 ## Schéma cache
| Clé Valkey | TTL (frais → hard) | Contenu | | Clé Valkey | TTL (frais → hard) | Contenu |
@ -61,7 +59,7 @@ Home et pages dept renvoient `Cache-Control: ... must-revalidate` (cf. commit `e
## Pièges connus / à surveiller ## 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 Opendatasoft renvoie les valeurs J→J1 du bulletin courant. Inutile de poll plus vite que 15 min. - **`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. - **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. - **`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. - **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.

View file

@ -25,7 +25,17 @@ try {
// Determine l'écheance "aujourd'hui réel" — peut être J ou J1 selon l'heure // Determine l'écheance "aujourd'hui réel" — peut être J ou J1 selon l'heure
// (entre minuit et la publication du bulletin suivant ~6h, J du bulletin = hier). // (entre minuit et la publication du bulletin suivant ~6h, J du bulletin = hier).
const ech = snapshot ? currentEcheance(snapshot) : 'J'; const todayEch: 'J' | 'J1' = snapshot ? currentEcheance(snapshot) : 'J';
// "Demain" n'existe que quand `todayEch === 'J'` : le bulletin n'a pas de J2.
// Entre minuit et la pub ~6h, on est sur J1 = aujourd'hui réel, donc pas de "demain" dispo.
const tomorrowAvailable = todayEch === 'J';
// Onglet sélectionné via ?echeance=tomorrow ; défaut = today.
const requestedView = new URL(Astro.request.url).searchParams.get('echeance');
const view: 'today' | 'tomorrow' =
requestedView === 'tomorrow' && tomorrowAvailable ? 'tomorrow' : 'today';
const ech: 'J' | 'J1' = view === 'tomorrow' ? 'J1' : todayEch;
const colorsByDept = snapshot ? maxColorByDepartement(snapshot, ech) : new Map(); const colorsByDept = snapshot ? maxColorByDepartement(snapshot, ech) : new Map();
const alertsToday = snapshot const alertsToday = snapshot
? snapshot.alerts.filter((a) => a.echeance === ech && a.colorId >= 2) ? snapshot.alerts.filter((a) => a.echeance === ech && a.colorId >= 2)
@ -63,15 +73,18 @@ const productDate = snapshot?.productDatetime
}) })
: 'inconnu'; : 'inconnu';
// Date de validité de l'écheance affichée (pour clarifier au visiteur) // Date de validité de l'écheance affichée (pour clarifier au visiteur).
const dayLabel = (() => { // On la dérive du "maintenant" Paris (+1 jour si l'onglet "demain" est actif),
if (!snapshot) return ''; // pas d'un sample d'alerte : en jour calme tout est vert, donc pas d'alerte sample.
const sample = snapshot.alerts.find((a) => a.echeance === ech); const fmtParisDate = (d: Date) =>
if (!sample) return ''; d.toLocaleDateString('fr-FR', {
return new Date(sample.beginTime).toLocaleDateString('fr-FR', {
weekday: 'long', day: 'numeric', month: 'long', timeZone: 'Europe/Paris', weekday: 'long', day: 'numeric', month: 'long', timeZone: 'Europe/Paris',
}); });
})(); const nowParis = new Date();
const tomorrowParis = new Date(nowParis.getTime() + 86_400_000);
const todayLabel = fmtParisDate(nowParis);
const tomorrowLabel = fmtParisDate(tomorrowParis);
const dayLabel = view === 'tomorrow' ? tomorrowLabel : todayLabel;
--- ---
<Base> <Base>
@ -94,6 +107,56 @@ const dayLabel = (() => {
</div> </div>
</section> </section>
{
!error && snapshot && (
<section class="container-tight pt-6">
<div role="tablist" aria-label="Échéance de la vigilance" class="inline-flex rounded-lg border border-slate-200 bg-white p-1 text-sm shadow-sm">
<a
href="/"
role="tab"
aria-selected={view === 'today' ? 'true' : 'false'}
class:list={[
'rounded-md px-4 py-1.5 font-medium no-underline transition-colors',
view === 'today'
? 'bg-canicule-700 text-white'
: 'text-slate-700 hover:bg-slate-100',
]}
>
Aujourd'hui
<span class="ml-1 hidden text-xs opacity-80 sm:inline capitalize">· {todayLabel}</span>
</a>
{tomorrowAvailable ? (
<a
href="/?echeance=tomorrow"
role="tab"
aria-selected={view === 'tomorrow' ? 'true' : 'false'}
class:list={[
'rounded-md px-4 py-1.5 font-medium no-underline transition-colors',
view === 'tomorrow'
? 'bg-canicule-700 text-white'
: 'text-slate-700 hover:bg-slate-100',
]}
>
Demain
<span class="ml-1 hidden text-xs opacity-80 sm:inline capitalize">· {tomorrowLabel}</span>
</a>
) : (
<span
role="tab"
aria-selected="false"
aria-disabled="true"
title="L'échéance « demain » n'est plus disponible : Météo France publie le prochain bulletin vers 6h, qui couvrira à nouveau aujourd'hui + demain."
class="cursor-not-allowed rounded-md px-4 py-1.5 font-medium text-slate-400"
>
Demain
<span class="ml-1 hidden text-xs sm:inline">· en attente du prochain bulletin</span>
</span>
)}
</div>
</section>
)
}
<section class="container-tight py-8"> <section class="container-tight py-8">
{ {
error && ( error && (