feat: persistance localStorage + stale-if-error serveur

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 <noreply@anthropic.com>
This commit is contained in:
Capucine Gest
2026-05-28 22:58:27 +02:00
parent ecb815619b
commit 3c6e3bb52a
4 changed files with 146 additions and 16 deletions
+10
View File
@@ -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);
+45 -8
View File
@@ -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<Date>(new Date());
const [loading, setLoading] = useState(true);
const [activeDivergences, setActiveDivergences] = useState<{ currency: Currency; score: number }[]>([]);
const [driversFromCache, setDriversFromCache] = useState(false);
const [driversCacheAge, setDriversCacheAge] = useState<string | null>(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<DriverData>("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<Record<string, unknown>>("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<typeof yields>("yields");
if (cached && cached.data) setYields(cached.data);
}
setLastRefresh(new Date());
} finally {
setLoading(false);
@@ -82,7 +113,13 @@ export default function Dashboard() {
</div>
)}
<div className="text-xs text-gray-400">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
{driversFromCache && driversCacheAge && (
<span className="flex items-center gap-0.5 text-amber-500" title="Marchés affichés depuis le cache local — API indisponible">
<Database size={11} />
<span>cache {driversCacheAge}</span>
</span>
)}
{lastRefresh.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}
</div>
@@ -140,7 +177,7 @@ export default function Dashboard() {
Sources: FRED · ECB · BoE · BoC · CFTC · Frankfurter · OANDA · investinglive.com
</p>
<p>
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
</p>
</footer>
</div>
+31 -2
View File
@@ -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";
@@ -86,21 +87,41 @@ export default function CurrencyCard({ currency, expectations, yields, onDiverge
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState(false);
const [rateExp, setRateExp] = useState<RateExpectation | null>(null);
const [fromCache, setFromCache] = useState(false);
const [cacheAge, setCacheAge] = useState<string | null>(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<string,unknown>).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");
else if (rateInd?.trend === "down") setPhase("easing");
else setPhase("hawkish_pause");
} catch {
// API indisponible → on utilise le cache localStorage si disponible
const cached = loadCache<MacroData>(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
}
</div>
</div>
<div className="flex items-center justify-between">
<div className={`text-[10px] font-medium ${phaseInfo.color}`}>{phaseInfo.label}</div>
{fromCache && cacheAge && (
<div className="flex items-center gap-0.5 text-[9px] text-amber-500" title="Données issues du cache local — API indisponible lors du dernier chargement">
<Database size={9} />
<span>cache {cacheAge}</span>
</div>
)}
</div>
</div>
{/* Rate expectation pill */}
+54
View File
@@ -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<T> {
data: T;
savedAt: number; // timestamp ms
}
/** Enregistre dans localStorage. Silencieux si indisponible (SSR, storage plein…) */
export function saveCache<T>(key: string, data: T): void {
if (typeof window === "undefined") return;
try {
const entry: CacheEntry<T> = { 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<T>(key: string): CacheEntry<T> | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem(PREFIX + key);
if (!raw) return null;
return JSON.parse(raw) as CacheEntry<T>;
} 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`;
}