fix(vigilance): désactiver onglet J+1 si absent du bulletin
All checks were successful
Deploy info-canicule / deploy (push) Successful in 1m29s

- 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 <noreply@anthropic.com>
This commit is contained in:
Florian 2026-05-28 01:57:25 +02:00
parent 29c1151fea
commit 759cda8ea9
4 changed files with 78 additions and 19 deletions

View file

@ -74,27 +74,41 @@ export async function cacheSet<T>(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<T>(
key: string,
ttlSec: number,
fetcher: () => Promise<T>,
shouldRevalidateSync?: (v: T) => boolean,
): Promise<T> {
const raw = await rawGet(key);
if (raw === null) {
return doFetchAndSet(key, ttlSec, fetcher);
}
if (!isEnvelope<T>(raw)) {
// Ancienne shape : on la sert + on refresh en background pour migrer.
triggerBackgroundRefresh(key, ttlSec, fetcher);
return raw as T;
const value: T = isEnvelope<T>(raw) ? raw.v : (raw as T);
const isFresh = isEnvelope<T>(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<unknown | null> {

View file

@ -200,7 +200,12 @@ export async function getVigilanceSnapshot(): Promise<VigilanceSnapshot> {
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<string, ColorId> {
const map = new Map<string, ColorId>();
for (const a of snapshot.alerts) {

View file

@ -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) && (
<div class="ic-card" style="margin-bottom: 28px; overflow-x: auto;">
<h3 style="margin-bottom: 16px;">
Aujourd'hui{todayLabel ? ` — ${todayLabel}` : ''} vs Demain{tomorrowLabel ? ` — ${tomorrowLabel}` : ''}
@ -291,7 +292,7 @@ const topPhen = topAlert ? PHENOMENA[topAlert.phenomenonId] : null;
<div class="comparison-grid" style="display: grid; grid-template-columns: 1.5fr 1fr 1fr; gap: 6px 12px; align-items: center; min-width: 360px;">
<span class="kicker">Phénomène</span>
<span class="kicker" style="text-align: center;">Aujourd'hui</span>
{ech === 'J'
{j1Available
? <span class="kicker" style="text-align: center;">Demain</span>
: <span class="kicker" style="text-align: center; opacity: 0.4;">Demain</span>
}

View file

@ -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;"
>