From 3c6e3bb52a08468f78a1ef6c5e178b60424ace21 Mon Sep 17 00:00:00 2001 From: Capucine Gest Date: Thu, 28 May 2026 22:58:27 +0200 Subject: [PATCH] feat: persistance localStorage + stale-if-error serveur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client — lib/localCache.ts (nouveau) : - saveCache / loadCache : wrapper localStorage préfixé "forex_v1_" - formatCacheDate : affiche l'âge du cache (< 1h, 2h, 3j…) Client — CurrencyCard.tsx : - Sauvegarde chaque réponse macro réussie dans localStorage (clé macro_{currency}) - Si l'API échoue → charge la dernière valeur connue depuis localStorage - Badge 🗄 "cache Xh" visible dans le header de la carte quand données stale Client — page.tsx : - Même logique pour drivers, expectations, yields - Badge cache affiché dans le header à côté de l'heure - Footer : Bytez → Groq Serveur — macro/route.ts : - stale-if-error : si tous les indicateurs reviennent null (panne API) et qu'un cache serveur précédent existe → le renvoie avec stale:true Co-Authored-By: Claude Sonnet 4.6 --- app/api/macro/route.ts | 10 +++++++ app/page.tsx | 53 ++++++++++++++++++++++++++++++------ components/CurrencyCard.tsx | 45 +++++++++++++++++++++++++------ lib/localCache.ts | 54 +++++++++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 16 deletions(-) create mode 100644 lib/localCache.ts diff --git a/app/api/macro/route.ts b/app/api/macro/route.ts index 79fb00a..5a08d75 100644 --- a/app/api/macro/route.ts +++ b/app/api/macro/route.ts @@ -178,7 +178,10 @@ export async function GET(req: NextRequest) { if (!series) return NextResponse.json({ error: "Unknown currency" }, { status: 400 }); const cached = _cache.get(currency); + // Sert le cache pendant 24h sans re-fetcher if (cached && Date.now() - cached.ts < 86_400_000) return NextResponse.json(cached.data); + // Garde une référence au cache précédent pour fallback stale-if-error + const staleCache = cached ?? null; const key = process.env.FRED_API_KEY; if (!key) return NextResponse.json({ error: "FRED_API_KEY missing" }, { status: 500 }); @@ -265,6 +268,13 @@ export async function GET(req: NextRequest) { indicators.pmiMfg = toPmiIndicator(pmiMfgRaw); indicators.pmiServices = toPmiIndicator(pmiSvcRaw); + // Stale-if-error : si tous les indicateurs sont null (API en panne), + // on renvoie le cache précédent plutôt que des tirets vides. + const hasAnyValue = Object.values(indicators).some((v) => v !== null); + if (!hasAnyValue && staleCache) { + return NextResponse.json({ ...staleCache.data, stale: true }); + } + const data = { currency, indicators, fetchedAt: new Date().toISOString() }; _cache.set(currency, { data, ts: Date.now() }); return NextResponse.json(data); diff --git a/app/page.tsx b/app/page.tsx index ee74e4a..9e7487a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,9 +1,10 @@ "use client"; import { useEffect, useState, useCallback } from "react"; -import { RefreshCw, TrendingUp, AlertTriangle, Zap } from "lucide-react"; +import { RefreshCw, TrendingUp, AlertTriangle, Zap, Database } from "lucide-react"; import { CURRENCIES, CURRENCY_META } from "@/lib/constants"; import type { Currency, DriverData } from "@/lib/types"; +import { saveCache, loadCache, formatCacheDate } from "@/lib/localCache"; import CurrencyCard from "@/components/CurrencyCard"; import DriversBar from "@/components/DriversBar"; @@ -16,6 +17,8 @@ export default function Dashboard() { const [lastRefresh, setLastRefresh] = useState(new Date()); const [loading, setLoading] = useState(true); const [activeDivergences, setActiveDivergences] = useState<{ currency: Currency; score: number }[]>([]); + const [driversFromCache, setDriversFromCache] = useState(false); + const [driversCacheAge, setDriversCacheAge] = useState(null); const refresh = useCallback(async () => { setLoading(true); @@ -27,16 +30,44 @@ export default function Dashboard() { fetch("/api/fx").then((r) => r.json()), ]); - if (driversRes.status === "fulfilled") { - const driversData = driversRes.value; - // Merge DXY from /api/fx into drivers + // ── Drivers (marchés globaux) ────────────────────────────────────────── + if (driversRes.status === "fulfilled" && !driversRes.value?.error) { + const driversData = driversRes.value as DriverData; if (fxRes.status === "fulfilled" && fxRes.value?.dxy != null) { driversData.dxy = fxRes.value.dxy; } setDrivers(driversData); + setDriversFromCache(false); + setDriversCacheAge(null); + saveCache("drivers", driversData); + } else { + // Fallback localStorage + const cached = loadCache("drivers"); + if (cached) { + setDrivers(cached.data); + setDriversFromCache(true); + setDriversCacheAge(formatCacheDate(cached.savedAt)); + } } - if (expectRes.status === "fulfilled") setExpectations(expectRes.value); - if (yieldsRes.status === "fulfilled") setYields(yieldsRes.value); + + // ── Attentes de taux ────────────────────────────────────────────────── + if (expectRes.status === "fulfilled" && !expectRes.value?.error) { + setExpectations(expectRes.value); + saveCache("expectations", expectRes.value); + } else { + const cached = loadCache>("expectations"); + if (cached) setExpectations(cached.data); + } + + // ── Rendements obligataires ─────────────────────────────────────────── + if (yieldsRes.status === "fulfilled" && !yieldsRes.value?.error) { + setYields(yieldsRes.value); + saveCache("yields", yieldsRes.value); + } else { + const cached = loadCache("yields"); + if (cached && cached.data) setYields(cached.data); + } + setLastRefresh(new Date()); } finally { setLoading(false); @@ -82,7 +113,13 @@ export default function Dashboard() { )} -
+
+ {driversFromCache && driversCacheAge && ( + + + cache {driversCacheAge} + + )} {lastRefresh.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}
@@ -140,7 +177,7 @@ export default function Dashboard() { Sources: FRED · ECB · BoE · BoC · CFTC · Frankfurter · OANDA · investinglive.com

- LLM: Bytez (Llama 3.1) · Données à titre informatif uniquement — pas de conseil financier + LLM: Groq (Llama 3.1) · Données à titre informatif uniquement — pas de conseil financier

diff --git a/components/CurrencyCard.tsx b/components/CurrencyCard.tsx index 7bc7a14..2f6a531 100644 --- a/components/CurrencyCard.tsx +++ b/components/CurrencyCard.tsx @@ -1,9 +1,10 @@ "use client"; import { useEffect, useState, useCallback } from "react"; -import { TrendingUp, TrendingDown, Minus, ChevronDown, ChevronUp, Loader2 } from "lucide-react"; +import { TrendingUp, TrendingDown, Minus, ChevronDown, ChevronUp, Loader2, Database } from "lucide-react"; import { CURRENCY_META } from "@/lib/constants"; import { biasLabel, biasColor, calcMacroScore } from "@/lib/scoring"; +import { saveCache, loadCache, formatCacheDate } from "@/lib/localCache"; import type { Currency, BiasPhase, RateExpectation } from "@/lib/types"; import NarrativeButton from "./NarrativeButton"; @@ -81,26 +82,46 @@ function Row({ label, ind, unit = "", invertSurprise = false, warn = false, cons export default function CurrencyCard({ currency, expectations, yields, onDivergenceUpdate }: Props) { const meta = CURRENCY_META[currency]; - const [data, setData] = useState(null); - const [phase, setPhase] = useState("hawkish_pause"); - const [loading, setLoading] = useState(true); + const [data, setData] = useState(null); + const [phase, setPhase] = useState("hawkish_pause"); + const [loading, setLoading] = useState(true); const [expanded, setExpanded] = useState(false); const [rateExp, setRateExp] = useState(null); + const [fromCache, setFromCache] = useState(false); + const [cacheAge, setCacheAge] = useState(null); // Single fetch — server batches all FRED calls const load = useCallback(async () => { setLoading(true); + const cacheKey = `macro_${currency}`; try { const res = await fetch(`/api/macro?currency=${currency}`); - if (!res.ok) return; + if (!res.ok) throw new Error(`HTTP ${res.status}`); const json: MacroData = await res.json(); + if ("error" in json) throw new Error(String((json as Record).error)); + setData(json); + setFromCache(false); + setCacheAge(null); + saveCache(cacheKey, json); // Infer phase from policy rate trend const rateInd = json.indicators.policyRate; - if (rateInd?.trend === "up") setPhase("tightening"); + if (rateInd?.trend === "up") setPhase("tightening"); else if (rateInd?.trend === "down") setPhase("easing"); - else setPhase("hawkish_pause"); + else setPhase("hawkish_pause"); + } catch { + // API indisponible → on utilise le cache localStorage si disponible + const cached = loadCache(cacheKey); + if (cached) { + setData(cached.data); + setFromCache(true); + setCacheAge(formatCacheDate(cached.savedAt)); + const rateInd = cached.data.indicators.policyRate; + if (rateInd?.trend === "up") setPhase("tightening"); + else if (rateInd?.trend === "down") setPhase("easing"); + else setPhase("hawkish_pause"); + } } finally { setLoading(false); } @@ -163,7 +184,15 @@ export default function CurrencyCard({ currency, expectations, yields, onDiverge } -
{phaseInfo.label}
+
+
{phaseInfo.label}
+ {fromCache && cacheAge && ( +
+ + cache {cacheAge} +
+ )} +
{/* Rate expectation pill */} diff --git a/lib/localCache.ts b/lib/localCache.ts new file mode 100644 index 0000000..d1e9f59 --- /dev/null +++ b/lib/localCache.ts @@ -0,0 +1,54 @@ +/** + * localCache — persistance localStorage pour les données du dashboard. + * + * Les données économiques (CPI, GDP, PMI…) sont publiées mensuellement. + * Si une API devient indisponible, on affiche la dernière valeur connue + * plutôt qu'un tiret vide. + * + * Clés stockées : + * forex_v1_macro_{USD|EUR|…} — indicateurs macro (CurrencyCard) + * forex_v1_drivers — marchés globaux (DriversBar) + * forex_v1_expectations — attentes taux + * forex_v1_yields — rendements obligataires + */ + +const PREFIX = "forex_v1_"; + +export interface CacheEntry { + data: T; + savedAt: number; // timestamp ms +} + +/** Enregistre dans localStorage. Silencieux si indisponible (SSR, storage plein…) */ +export function saveCache(key: string, data: T): void { + if (typeof window === "undefined") return; + try { + const entry: CacheEntry = { data, savedAt: Date.now() }; + localStorage.setItem(PREFIX + key, JSON.stringify(entry)); + } catch { + // QuotaExceededError ou autre — on ignore + } +} + +/** Lit depuis localStorage. Retourne null si absent ou corrompu. */ +export function loadCache(key: string): CacheEntry | null { + if (typeof window === "undefined") return null; + try { + const raw = localStorage.getItem(PREFIX + key); + if (!raw) return null; + return JSON.parse(raw) as CacheEntry; + } catch { + return null; + } +} + +/** Formatte une date de sauvegarde pour l'affichage */ +export function formatCacheDate(savedAt: number): string { + const d = new Date(savedAt); + const now = new Date(); + const diffH = Math.round((now.getTime() - d.getTime()) / 3_600_000); + if (diffH < 1) return "< 1h"; + if (diffH < 24) return `${diffH}h`; + const diffD = Math.round(diffH / 24); + return `${diffD}j`; +}