fix(vigilance): désactiver onglet J+1 si absent du bulletin
All checks were successful
Deploy info-canicule / deploy (push) Successful in 1m29s
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:
parent
29c1151fea
commit
759cda8ea9
4 changed files with 78 additions and 19 deletions
|
|
@ -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
|
* background échoue, on log mais on conserve la valeur stale en cache
|
||||||
* jusqu'à l'expiration `hardTtl` (= ttlSec × STALE_RATIO).
|
* jusqu'à l'expiration `hardTtl` (= ttlSec × STALE_RATIO).
|
||||||
*
|
*
|
||||||
* Effet : les visiteurs ne paient jamais la latence d'un refetch tant qu'on
|
* `shouldRevalidateSync` : prédicat optionnel appelé sur la valeur en cache
|
||||||
* a une valeur même stale. Sur Vigilance (5 min fresh, 30 min hard), un
|
* (fraîche ou stale). S'il renvoie true, on force un refresh synchrone au lieu
|
||||||
* blip MF de 25 min reste invisible pour l'utilisateur.
|
* 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>(
|
export async function cacheOrFetch<T>(
|
||||||
key: string,
|
key: string,
|
||||||
ttlSec: number,
|
ttlSec: number,
|
||||||
fetcher: () => Promise<T>,
|
fetcher: () => Promise<T>,
|
||||||
|
shouldRevalidateSync?: (v: T) => boolean,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const raw = await rawGet(key);
|
const raw = await rawGet(key);
|
||||||
if (raw === null) {
|
if (raw === null) {
|
||||||
return doFetchAndSet(key, ttlSec, fetcher);
|
return doFetchAndSet(key, ttlSec, fetcher);
|
||||||
}
|
}
|
||||||
if (!isEnvelope<T>(raw)) {
|
|
||||||
// Ancienne shape : on la sert + on refresh en background pour migrer.
|
const value: T = isEnvelope<T>(raw) ? raw.v : (raw as T);
|
||||||
triggerBackgroundRefresh(key, ttlSec, fetcher);
|
const isFresh = isEnvelope<T>(raw) && Date.now() < raw.fu;
|
||||||
return raw as T;
|
|
||||||
|
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);
|
triggerBackgroundRefresh(key, ttlSec, fetcher);
|
||||||
return raw.v;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rawGet(key: string): Promise<unknown | null> {
|
async function rawGet(key: string): Promise<unknown | null> {
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,12 @@ export async function getVigilanceSnapshot(): Promise<VigilanceSnapshot> {
|
||||||
const ttl = parseInt(process.env.VIGILANCE_CACHE_TTL ?? '900', 10);
|
const ttl = parseInt(process.env.VIGILANCE_CACHE_TTL ?? '900', 10);
|
||||||
const provider = pickProvider();
|
const provider = pickProvider();
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
// Fallback automatique vers l'autre provider en cas d'erreur (token expiré, MF API down, etc.)
|
// Fallback automatique vers l'autre provider en cas d'erreur (token expiré, MF API down, etc.)
|
||||||
if (provider.name === 'meteofrance') {
|
if (provider.name === 'meteofrance') {
|
||||||
|
|
@ -247,6 +252,41 @@ function parisDateKey(d: Date): string {
|
||||||
return d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' });
|
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' {
|
export function currentEcheance(snapshot: VigilanceSnapshot): 'J' | 'J1' {
|
||||||
if (!snapshot.productDatetime) return 'J';
|
if (!snapshot.productDatetime) return 'J';
|
||||||
const bulletinDay = parisDateKey(new Date(snapshot.productDatetime));
|
const bulletinDay = parisDateKey(new Date(snapshot.productDatetime));
|
||||||
|
|
@ -254,6 +294,10 @@ export function currentEcheance(snapshot: VigilanceSnapshot): 'J' | 'J1' {
|
||||||
return nowDay === bulletinDay ? '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> {
|
export function maxColorByDepartement(snapshot: VigilanceSnapshot, echeance: 'J' | 'J1' = 'J'): Map<string, ColorId> {
|
||||||
const map = new Map<string, ColorId>();
|
const map = new Map<string, ColorId>();
|
||||||
for (const a of snapshot.alerts) {
|
for (const a of snapshot.alerts) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
import Base from '../../layouts/Base.astro';
|
import Base from '../../layouts/Base.astro';
|
||||||
import VigilanceChip from '../../components/VigilanceChip.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 { getDepartement, isDrom } from '../../lib/departements';
|
||||||
import { PHENOMENA, COLOR_LABEL, COLORS } from '../../lib/phenomena';
|
import { PHENOMENA, COLOR_LABEL, COLORS } from '../../lib/phenomena';
|
||||||
import type { PhenomenonId, ColorId } 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 stationLabel = hourly ? `${hourly.stationName} (${hourly.distKm} km)` : null;
|
||||||
|
|
||||||
const ech = snapshot ? currentEcheance(snapshot) : 'J';
|
const ech = snapshot ? currentEcheance(snapshot) : 'J';
|
||||||
|
const j1Available = snapshot ? (ech === 'J' && hasJ1Period(snapshot)) : false;
|
||||||
const today = snapshot ? alertsForDepartement(snapshot, dept.code, ech) : [];
|
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
|
// Sort by severity desc, pick top alert for the hero block
|
||||||
const sortedToday = [...today].sort((a, b) => b.colorId - a.colorId);
|
const sortedToday = [...today].sort((a, b) => b.colorId - a.colorId);
|
||||||
|
|
@ -108,8 +109,8 @@ const comparisonRows = PHENOM_IDS.map((id) => ({
|
||||||
id,
|
id,
|
||||||
phen: PHENOMENA[id],
|
phen: PHENOMENA[id],
|
||||||
todayColor: (todayByPhenom.get(id) ?? 1) as ColorId,
|
todayColor: (todayByPhenom.get(id) ?? 1) as ColorId,
|
||||||
tomorrowColor: ech === 'J' ? ((tomorrowByPhenom.get(id) ?? 1) as ColorId) : null,
|
tomorrowColor: j1Available ? ((tomorrowByPhenom.get(id) ?? 1) as ColorId) : null,
|
||||||
changed: ech === 'J' && (todayByPhenom.get(id) ?? 1) !== (tomorrowByPhenom.get(id) ?? 1),
|
changed: j1Available && (todayByPhenom.get(id) ?? 1) !== (tomorrowByPhenom.get(id) ?? 1),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Phenomena with no active alert today (green)
|
// Phenomena with no active alert today (green)
|
||||||
|
|
@ -283,7 +284,7 @@ const topPhen = topAlert ? PHENOMENA[topAlert.phenomenonId] : null;
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Today vs Tomorrow comparison */}
|
{/* 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;">
|
<div class="ic-card" style="margin-bottom: 28px; overflow-x: auto;">
|
||||||
<h3 style="margin-bottom: 16px;">
|
<h3 style="margin-bottom: 16px;">
|
||||||
Aujourd'hui{todayLabel ? ` — ${todayLabel}` : ''} vs Demain{tomorrowLabel ? ` — ${tomorrowLabel}` : ''}
|
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;">
|
<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">Phénomène</span>
|
||||||
<span class="kicker" style="text-align: center;">Aujourd'hui</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;">Demain</span>
|
||||||
: <span class="kicker" style="text-align: center; opacity: 0.4;">Demain</span>
|
: <span class="kicker" style="text-align: center; opacity: 0.4;">Demain</span>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import Base from '../layouts/Base.astro';
|
||||||
import FranceMap from '../components/FranceMap.astro';
|
import FranceMap from '../components/FranceMap.astro';
|
||||||
import DepartementGrid from '../components/DepartementGrid.astro';
|
import DepartementGrid from '../components/DepartementGrid.astro';
|
||||||
import VigilanceChip from '../components/VigilanceChip.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 { getDepartement, DEPARTEMENTS } from '../lib/departements';
|
||||||
import { PHENOMENA } from '../lib/phenomena';
|
import { PHENOMENA } from '../lib/phenomena';
|
||||||
import type { VigilanceAlert } from '../lib/vigilance';
|
import type { VigilanceAlert } from '../lib/vigilance';
|
||||||
|
|
@ -22,7 +22,7 @@ try {
|
||||||
}
|
}
|
||||||
|
|
||||||
const todayEch: 'J' | 'J1' = snapshot ? currentEcheance(snapshot) : 'J';
|
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 requestedView = new URL(Astro.request.url).searchParams.get('echeance');
|
||||||
const view: 'today' | 'tomorrow' =
|
const view: 'today' | 'tomorrow' =
|
||||||
|
|
@ -152,7 +152,7 @@ const allDepts = DEPARTEMENTS.map((d) => ({ code: d.code, name: d.name }));
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected="false"
|
aria-selected="false"
|
||||||
aria-disabled="true"
|
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"
|
class="ic-btn ic-btn-sm ic-btn-ghost"
|
||||||
style="opacity: 0.55; cursor: not-allowed;"
|
style="opacity: 0.55; cursor: not-allowed;"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue