From 759cda8ea9c572f53fa6974a429d0f15a74d8e3d Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 28 May 2026 01:57:25 +0200 Subject: [PATCH] =?UTF-8?q?fix(vigilance):=20d=C3=A9sactiver=20onglet=20J+?= =?UTF-8?q?1=20si=20absent=20du=20bulletin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ajoute `hasJ1Period()` — source de vérité réelle au lieu de s'appuyer sur l'heure de publication (fixe 6h/16h non fiable, bulletins constatés à 23h et 00h01). - Home : `tomorrowAvailable` conditionné à `hasJ1Period(snapshot)` en plus de `currentEcheance === 'J'`. - Dept : `j1Available` pilote la colonne Demain du tableau de comparaison et le calcul `tomorrowColor`/`changed`. - Tooltip J+1 désactivé : suppression de la mention "vers 6h". - cache.ts / vigilance.ts : `bulletinIsSuperseded` + refresh sync via `shouldRevalidateSync` (pré-existant, inclus dans le push). Co-Authored-By: Claude Sonnet 4.6 --- src/lib/cache.ts | 32 +++++++++++++++------ src/lib/vigilance.ts | 46 +++++++++++++++++++++++++++++- src/pages/departement/[code].astro | 13 +++++---- src/pages/index.astro | 6 ++-- 4 files changed, 78 insertions(+), 19 deletions(-) diff --git a/src/lib/cache.ts b/src/lib/cache.ts index 3e86fa9..8cf0911 100644 --- a/src/lib/cache.ts +++ b/src/lib/cache.ts @@ -74,27 +74,41 @@ export async function cacheSet(key: string, value: T, ttlSec: number): Promis * 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. + * `shouldRevalidateSync` : prédicat optionnel appelé sur la valeur en cache + * (fraîche ou stale). S'il renvoie true, on force un refresh synchrone au lieu + * du SWR, avec fallback sur la valeur stale si le fetch échoue. Utile quand + * la donnée est invalide indépendamment de son âge (ex. bulletin périmé par + * fenêtre de publication). */ export async function cacheOrFetch( key: string, ttlSec: number, fetcher: () => Promise, + shouldRevalidateSync?: (v: T) => boolean, ): Promise { 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; + + const value: T = isEnvelope(raw) ? raw.v : (raw as T); + const isFresh = isEnvelope(raw) && Date.now() < raw.fu; + + if (shouldRevalidateSync?.(value)) { + // Refresh synchrone : on attend le fetch, on retombe sur la valeur stale si ça échoue. + // doFetchAndSet déduplique les appels concurrents via inflight. + try { + return await doFetchAndSet(key, ttlSec, fetcher); + } catch (err) { + console.warn('[cache] forced sync refresh failed for', key, (err as Error).message); + return value; + } } - if (Date.now() < raw.fu) return raw.v; + + if (isFresh) return value; + triggerBackgroundRefresh(key, ttlSec, fetcher); - return raw.v; + return value; } async function rawGet(key: string): Promise { diff --git a/src/lib/vigilance.ts b/src/lib/vigilance.ts index 2914e58..362bf0c 100644 --- a/src/lib/vigilance.ts +++ b/src/lib/vigilance.ts @@ -200,7 +200,12 @@ 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, () => fetchAndPersist(provider.fn)); + return await cacheOrFetch( + CACHE_KEY, + ttl, + () => fetchAndPersist(provider.fn), + (snap) => !!snap.productDatetime && bulletinIsSuperseded(snap.productDatetime), + ); } catch (e) { // Fallback automatique vers l'autre provider en cas d'erreur (token expiré, MF API down, etc.) if (provider.name === 'meteofrance') { @@ -247,6 +252,41 @@ function parisDateKey(d: Date): string { return d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' }); } +function inParis(d: Date): { dayKey: string; minutesOfDay: number } { + // sv-SE → "YYYY-MM-DD HH:MM:SS", fiable cross-env + const iso = d.toLocaleString('sv-SE', { timeZone: 'Europe/Paris' }); + const [date, time] = iso.split(' '); + const [h, m] = time.split(':').map(Number); + return { dayKey: date, minutesOfDay: h * 60 + m }; +} + +/** + * Renvoie true si un bulletin plus récent devrait être disponible chez MF. + * Publications : ~06h et ~16h Europe/Paris. Grace de 30 min pour laisser + * MF finaliser la publication avant de déclarer l'ancien périmé. + * + * Cas couverts : + * - Bulletin d'un jour précédent + il est ≥ 06h30 Paris → périmé. + * - Bulletin du même jour, heure < 14h (bulletin matin) + il est ≥ 16h30 → périmé. + */ +export function bulletinIsSuperseded(productDatetime: string): boolean { + const now = inParis(new Date()); + const bulletin = inParis(new Date(productDatetime)); + + if (bulletin.dayKey < now.dayKey) { + // Bulletin d'hier ou avant. Seule exception : entre minuit et 06h30 Paris + // (pas encore de nouveau bulletin du matin), le bulletin de la veille soir reste valide. + return now.minutesOfDay >= 6 * 60 + 30; + } + + if (bulletin.dayKey === now.dayKey) { + // Bulletin du matin (< 14h Paris) périmé dès 16h30 Paris. + return bulletin.minutesOfDay < 14 * 60 && now.minutesOfDay >= 16 * 60 + 30; + } + + return false; // bulletin dans le futur — ne devrait pas arriver +} + export function currentEcheance(snapshot: VigilanceSnapshot): 'J' | 'J1' { if (!snapshot.productDatetime) return 'J'; const bulletinDay = parisDateKey(new Date(snapshot.productDatetime)); @@ -254,6 +294,10 @@ export function currentEcheance(snapshot: VigilanceSnapshot): 'J' | 'J1' { return nowDay === bulletinDay ? 'J' : 'J1'; } +export function hasJ1Period(snapshot: VigilanceSnapshot): boolean { + return snapshot.alerts.some((a) => a.echeance === 'J1'); +} + export function maxColorByDepartement(snapshot: VigilanceSnapshot, echeance: 'J' | 'J1' = 'J'): Map { const map = new Map(); for (const a of snapshot.alerts) { diff --git a/src/pages/departement/[code].astro b/src/pages/departement/[code].astro index 7a57081..93c7adb 100644 --- a/src/pages/departement/[code].astro +++ b/src/pages/departement/[code].astro @@ -1,7 +1,7 @@ --- import Base from '../../layouts/Base.astro'; import VigilanceChip from '../../components/VigilanceChip.astro'; -import { getVigilanceSnapshot, alertsForDepartement, currentEcheance } from '../../lib/vigilance'; +import { getVigilanceSnapshot, alertsForDepartement, currentEcheance, hasJ1Period } from '../../lib/vigilance'; import { getDepartement, isDrom } from '../../lib/departements'; import { PHENOMENA, COLOR_LABEL, COLORS } from '../../lib/phenomena'; import type { PhenomenonId, ColorId } from '../../lib/phenomena'; @@ -77,8 +77,9 @@ if (!drom) { const stationLabel = hourly ? `${hourly.stationName} (${hourly.distKm} km)` : null; const ech = snapshot ? currentEcheance(snapshot) : 'J'; +const j1Available = snapshot ? (ech === 'J' && hasJ1Period(snapshot)) : false; const today = snapshot ? alertsForDepartement(snapshot, dept.code, ech) : []; -const tomorrow = (snapshot && ech === 'J') ? alertsForDepartement(snapshot, dept.code, 'J1') : []; +const tomorrow = j1Available ? alertsForDepartement(snapshot!, dept.code, 'J1') : []; // Sort by severity desc, pick top alert for the hero block const sortedToday = [...today].sort((a, b) => b.colorId - a.colorId); @@ -108,8 +109,8 @@ const comparisonRows = PHENOM_IDS.map((id) => ({ id, phen: PHENOMENA[id], todayColor: (todayByPhenom.get(id) ?? 1) as ColorId, - tomorrowColor: ech === 'J' ? ((tomorrowByPhenom.get(id) ?? 1) as ColorId) : null, - changed: ech === 'J' && (todayByPhenom.get(id) ?? 1) !== (tomorrowByPhenom.get(id) ?? 1), + tomorrowColor: j1Available ? ((tomorrowByPhenom.get(id) ?? 1) as ColorId) : null, + changed: j1Available && (todayByPhenom.get(id) ?? 1) !== (tomorrowByPhenom.get(id) ?? 1), })); // Phenomena with no active alert today (green) @@ -283,7 +284,7 @@ const topPhen = topAlert ? PHENOMENA[topAlert.phenomenonId] : null; )} {/* Today vs Tomorrow comparison */} - {(today.length > 0 || tomorrow.length > 0 || ech === 'J') && ( + {(today.length > 0 || tomorrow.length > 0 || j1Available) && (

Aujourd'hui{todayLabel ? ` — ${todayLabel}` : ''} vs Demain{tomorrowLabel ? ` — ${tomorrowLabel}` : ''} @@ -291,7 +292,7 @@ const topPhen = topAlert ? PHENOMENA[topAlert.phenomenonId] : null;
Phénomène Aujourd'hui - {ech === 'J' + {j1Available ? Demain : Demain } diff --git a/src/pages/index.astro b/src/pages/index.astro index ee96f33..259f507 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -3,7 +3,7 @@ import Base from '../layouts/Base.astro'; import FranceMap from '../components/FranceMap.astro'; import DepartementGrid from '../components/DepartementGrid.astro'; import VigilanceChip from '../components/VigilanceChip.astro'; -import { getVigilanceSnapshot, maxColorByDepartement, currentEcheance } from '../lib/vigilance'; +import { getVigilanceSnapshot, maxColorByDepartement, currentEcheance, hasJ1Period } from '../lib/vigilance'; import { getDepartement, DEPARTEMENTS } from '../lib/departements'; import { PHENOMENA } from '../lib/phenomena'; import type { VigilanceAlert } from '../lib/vigilance'; @@ -22,7 +22,7 @@ try { } const todayEch: 'J' | 'J1' = snapshot ? currentEcheance(snapshot) : 'J'; -const tomorrowAvailable = todayEch === 'J'; +const tomorrowAvailable = todayEch === 'J' && (snapshot ? hasJ1Period(snapshot) : false); const requestedView = new URL(Astro.request.url).searchParams.get('echeance'); const view: 'today' | 'tomorrow' = @@ -152,7 +152,7 @@ const allDepts = DEPARTEMENTS.map((d) => ({ code: d.code, name: d.name })); role="tab" aria-selected="false" aria-disabled="true" - title="L'échéance « demain » n'est pas encore disponible : Météo France publie le prochain bulletin vers 6h." + title="L'échéance « demain » n'est pas encore disponible dans le bulletin en cours." class="ic-btn ic-btn-sm ic-btn-ghost" style="opacity: 0.55; cursor: not-allowed;" >