feat: banques centrales (vote+dot plot+previsions), calendrier elargi, fix InvestingLive, M3 auto

Central bank governance (nouvel onglet Banques centrales) :
- lib/centralBankGovernance.ts + app/api/central-bank-sources : scraping live du
  vote de la derniere reunion, dot plot Fed (SEP), et desormais les previsions
  macro (PIB + inflation) publiees par chaque BC elle-meme (Fed SEP, Eurosystem
  staff projections, BoJ Outlook Report PDF, SNB conditional forecast, BoC MPR,
  RBA SMP). GBP/NZD laisses honnetement vides quand aucune source chiffree
  fiable n'est accessible (RBNZ bloque par Cloudflare).
- components/CentralBankSourcesTab.tsx : nouvel onglet avec cards par banque.

Taux directeurs + Money Supply M3 :
- data/rate_decisions.json corrige (JPY, EUR, NZD etc. etaient perimes d'1-2
  decisions) et desormais auto-maintenu : .github/workflows/update-rate-decisions.yml
  (horaire) detecte les changements de taux via Trading Economics et fait
  glisser current -> prev sans perte de donnee.
- data/money-supply-m3.json (nouveau) + .github/workflows/fetch-money-supply.yml
  (hebdo) : M3 par devise (proxy M2 pour l'USD, la Fed ne publiant plus M3
  depuis 2006), affiche dans CurrencyCard.

Calendrier economique elargi :
- lib/calendar-countries.ts, lib/calendar-taxonomy.ts, lib/fxstreetCalendar.ts :
  couverture pays elargie + classification des evenements + source FXStreet.

Fix InvestingLive :
- lib/investinglive.ts : l'ancienne API WordPress (wp-json) renvoyait 404 depuis
  leur migration Nuxt.js -> reecrit vers api.investinglive.com/api/homepage/articles,
  + fix crash silencieux (Tldr pas toujours un tableau).

Divers :
- .vercel/ ajoute au .gitignore (ne doit jamais etre commite, cf. son propre README).
- scripts/ (lancement PWA, push env Vercel), captures d'ecran, cache InvestingLive.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
caty21
2026-07-08 16:46:05 +02:00
parent 82a732fbdf
commit 0657b50c07
38 changed files with 3257 additions and 443 deletions
+61 -12
View File
@@ -1,9 +1,8 @@
"use client";
import React, { useState, useMemo } from "react";
import React, { useState, useMemo, useRef } from "react";
import { ChevronDown, ChevronRight, Loader2, Calendar } from "lucide-react";
import { CURRENCIES, CURRENCY_META } from "@/lib/constants";
import type { Currency } from "@/lib/types";
import { CURRENCY_META } from "@/lib/constants";
import type { CalendarEvent } from "@/app/api/calendar/route";
interface Props {
@@ -21,9 +20,19 @@ const CATEGORY_LABELS: Record<string, string> = {
gdp: "PIB",
retail_sales: "Ventes détail",
trade_balance: "Balance comm.",
sentiment: "Confiance",
housing: "Immobilier",
money_supply: "Masse monétaire",
trade_detail: "Commerce (détail)",
regional_fed: "Fed régionale",
portfolio_flows:"Flux portefeuille",
public_finance: "Finances publiques",
holiday: "Jour férié",
other: "Autre",
};
// ── Currency → ISO alpha-2 (pour flagcdn.com) ─────────────────────────────────
// Univers élargi (45 pays côté calendrier, au-delà des 8 devises majeures tradées).
const CCY_ISO: Record<string, string> = {
USD: "us", EUR: "eu", GBP: "gb", JPY: "jp",
@@ -31,8 +40,15 @@ const CCY_ISO: Record<string, string> = {
CNY: "cn", SEK: "se", NOK: "no", DKK: "dk",
SGD: "sg", HKD: "hk", MXN: "mx", BRL: "br",
ZAR: "za", INR: "in", KRW: "kr", TRY: "tr",
ARS: "ar", CLP: "cl", COP: "co", CZK: "cz",
HUF: "hu", ISK: "is", IDR: "id", ILS: "il",
KWD: "kw", PLN: "pl", RON: "ro", RUB: "ru", VND: "vn",
};
// Ordre d'affichage préféré des chips devise : majeures d'abord, puis le reste
// trié alphabétiquement (calculé dynamiquement depuis les events reçus).
const MAJOR_ORDER = ["USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD"];
// ── Helpers ───────────────────────────────────────────────────────────────────
function isoToLocalDate(iso: string): string {
@@ -100,7 +116,6 @@ function EventRow({ ev, isChild, expanded, onToggle }: {
ev: CalendarEvent; isChild: boolean; expanded: boolean; onToggle: () => void;
}) {
const { day, time } = fmtDate(ev.date);
const meta = CURRENCY_META[ev.currency];
const borderCls = !ev.isPublished && ev.impact === "high" ? "border-l-2 border-l-red-500"
: !ev.isPublished && ev.impact === "medium" ? "border-l-2 border-l-amber-400"
@@ -177,11 +192,12 @@ function EventRow({ ev, isChild, expanded, onToggle }: {
type WeekTab = "prev" | "current" | "next" | "next2" | "all";
export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
const [filterCcy, setFilterCcy] = useState<Currency | "ALL">("ALL");
const [filterCcy, setFilterCcy] = useState<string>("ALL");
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [showLow, setShowLow] = useState(false);
const [weekTab, setWeekTab] = useState<WeekTab>("all");
const [fromDate, setFromDate] = useState<string>(todayIso());
const fromDateRef = useRef<HTMLInputElement>(null);
const { prevWeekLabel, currentWeekLabel, nextWeekLabel, next2StartLabel, nextMondayIso } = useMemo(getWeekBounds, []);
void nextMondayIso;
@@ -206,6 +222,19 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
return true;
}), [events, filterCcy, showLow, expanded, weekTab, fromDate]);
// Chips devise : toutes les devises présentes dans les events reçus,
// majeures d'abord (ordre trading), puis le reste par ordre alphabétique.
const availableCurrencies = useMemo(() => {
const set = new Set(events.map(ev => ev.currency));
return Array.from(set).sort((a, b) => {
const ia = MAJOR_ORDER.indexOf(a), ib = MAJOR_ORDER.indexOf(b);
if (ia !== -1 && ib !== -1) return ia - ib;
if (ia !== -1) return -1;
if (ib !== -1) return 1;
return a.localeCompare(b);
});
}, [events]);
const days: string[] = [];
const dayMap: Record<string, CalendarEvent[]> = {};
for (const ev of filtered) {
@@ -228,7 +257,7 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold text-slate-200">Calendrier économique</h2>
<p className="text-[10px] text-slate-600 mt-0.5">Sources : ForexFactory · FRED · Banques centrales</p>
<p className="text-[10px] text-slate-600 mt-0.5">Sources : Trading Economics · investingLive · Banques centrales</p>
</div>
<label className="flex items-center gap-1.5 text-[10px] text-slate-500 cursor-pointer">
<input
@@ -290,14 +319,27 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
<div className="flex items-center gap-1.5 shrink-0">
<Calendar size={11} className="text-slate-600" />
<span className="text-[10px] text-slate-500">Depuis</span>
{/* defaultValue (pas value) : un input date="" contrôlé re-render à chaque
frappe et casse la saisie clavier native (les segments jour/mois/année
se mélangent, y compris si le re-render est déclenché indirectement via
une key). Non-contrôlé après le mount ; le bouton "Aujourd'hui" resynchronise
l'affichage à la main via la ref, sans jamais re-render l'input lui-même. */}
<input
ref={fromDateRef}
type="date"
value={fromDate}
onChange={e => setFromDate(e.target.value)}
defaultValue={fromDate}
onChange={e => {
const v = e.target.value;
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) setFromDate(v);
}}
className="text-[10px] bg-slate-800 border border-slate-700 rounded px-1.5 py-0.5 text-slate-300 focus:outline-none focus:border-amber-500/50"
/>
<button
onClick={() => setFromDate(todayIso())}
onClick={() => {
const today = todayIso();
setFromDate(today);
if (fromDateRef.current) fromDateRef.current.value = today;
}}
className="text-[9px] text-amber-500 hover:text-amber-400 underline"
>
Aujourd&apos;hui
@@ -317,7 +359,7 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
>
Tout
</button>
{CURRENCIES.map(ccy => (
{availableCurrencies.map(ccy => (
<button
key={ccy}
onClick={() => setFilterCcy(ccy === filterCcy ? "ALL" : ccy)}
@@ -327,7 +369,7 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
: "bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-slate-200"
}`}
>
{CURRENCY_META[ccy].flag} {ccy}
{CURRENCY_META[ccy as keyof typeof CURRENCY_META]?.flag ?? ""} {ccy}
</button>
))}
</div>
@@ -348,7 +390,14 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
</p>
)}
{fromDate > todayIso() && (
<button onClick={() => setFromDate(todayIso())} className="mt-2 text-[10px] text-amber-500 underline">
<button
onClick={() => {
const today = todayIso();
setFromDate(today);
if (fromDateRef.current) fromDateRef.current.value = today;
}}
className="mt-2 text-[10px] text-amber-500 underline"
>
Revenir à aujourd&apos;hui
</button>
)}
+321
View File
@@ -0,0 +1,321 @@
"use client";
import React, { useEffect, useState } from "react";
import { Loader2, ExternalLink, FileText, RefreshCw } from "lucide-react";
import {
ScatterChart, Scatter, ZAxis, LineChart, Line,
ResponsiveContainer, Tooltip, XAxis, YAxis, CartesianGrid,
} from "recharts";
import type { CBGovernance } from "@/app/api/central-bank-sources/route";
const CCY_ISO: Record<string, string> = {
USD: "us", EUR: "eu", GBP: "gb", JPY: "jp",
CHF: "ch", CAD: "ca", AUD: "au", NZD: "nz",
};
const ORDER = ["USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD"];
function Flag({ ccy }: { ccy: string }) {
return (
<div className="w-6 h-6 rounded-full overflow-hidden shrink-0 bg-slate-700">
<img
src={`https://flagcdn.com/w40/${CCY_ISO[ccy] ?? ccy.slice(0, 2).toLowerCase()}.png`}
width={24} height={24} alt={ccy} className="w-full h-full object-cover"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
</div>
);
}
// ── Dot plot Fed : scatter (année × taux, taille = nb de participants) ────────
function FedDotPlotChart({ dotPlot }: { dotPlot: NonNullable<CBGovernance["dotPlot"]> }) {
const yearIndex = new Map(dotPlot.years.map((y, i) => [y, i]));
const scatterData = dotPlot.dots.map(d => ({ x: yearIndex.get(d.year) ?? 0, y: d.rate, z: d.count, year: d.year }));
const medianData = dotPlot.years.map((y, i) => ({ x: i, y: dotPlot.medianByYear[y] }));
const allRates = dotPlot.dots.map(d => d.rate);
const minY = Math.min(...allRates, ...Object.values(dotPlot.medianByYear)) - 0.2;
const maxY = Math.max(...allRates, ...Object.values(dotPlot.medianByYear)) + 0.2;
return (
<ResponsiveContainer width="100%" height={180}>
<ScatterChart margin={{ top: 6, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid stroke="#1e293b" strokeDasharray="3 3" />
<XAxis
type="number" dataKey="x" domain={[-0.5, dotPlot.years.length - 0.5]}
ticks={dotPlot.years.map((_, i) => i)}
tickFormatter={(i: number) => dotPlot.years[i] ?? ""}
tick={{ fontSize: 8, fill: "#64748b" }} axisLine={false} tickLine={false}
/>
<YAxis
type="number" dataKey="y" domain={[minY, maxY]}
tickFormatter={(v: number) => `${v.toFixed(1)}%`}
tick={{ fontSize: 8, fill: "#64748b" }} axisLine={false} tickLine={false} width={34}
/>
<ZAxis type="number" dataKey="z" range={[20, 260]} />
<Tooltip
cursor={{ strokeDasharray: "3 3" }}
content={({ payload }) => {
const p = payload?.[0]?.payload as { year: string; y: number; z: number } | undefined;
if (!p) return null;
return (
<div style={{ background: "rgba(8,14,28,0.97)", border: "1px solid #1e293b", borderRadius: 8, padding: "6px 10px" }}>
<p style={{ color: "#94a3b8", fontSize: 9, margin: 0 }}>{p.year} · {p.y.toFixed(3)}%</p>
<p style={{ color: "#f59e0b", fontSize: 9, margin: 0, fontWeight: 700 }}>{p.z} participant{p.z > 1 ? "s" : ""}</p>
</div>
);
}}
/>
<Scatter data={scatterData} fill="#f59e0b" fillOpacity={0.65} />
<Scatter data={medianData} fill="#38bdf8" shape="diamond" legendType="none" />
</ScatterChart>
</ResponsiveContainer>
);
}
// ── Évolution : médiane "année courante" + "long terme" au fil des SEP ────────
function FedEvolutionChart({ dotPlot }: { dotPlot: NonNullable<CBGovernance["dotPlot"]> }) {
const data = dotPlot.history.map(h => {
const entries = Object.entries(h.medianByYear);
const nearTerm = entries.find(([y]) => y !== "Longer run")?.[1] ?? null;
const longRun = h.medianByYear["Longer run"] ?? null;
return { date: h.date.slice(2, 7), nearTerm, longRun };
});
return (
<ResponsiveContainer width="100%" height={110}>
<LineChart data={data} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<XAxis dataKey="date" tick={{ fontSize: 7, fill: "#475569" }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 7, fill: "#475569" }} axisLine={false} tickLine={false} width={30} tickFormatter={(v: number) => `${v.toFixed(1)}%`} />
<Tooltip
content={({ label, payload }) => {
if (!payload?.length) return null;
const items = payload as { dataKey: string; value: number }[];
return (
<div style={{ background: "rgba(8,14,28,0.97)", border: "1px solid #1e293b", borderRadius: 8, padding: "6px 10px" }}>
<p style={{ color: "#475569", fontSize: 8, margin: "0 0 4px" }}>{label}</p>
{items.map(it => (
<p key={it.dataKey} style={{ color: it.dataKey === "longRun" ? "#38bdf8" : "#f59e0b", fontSize: 9, margin: 0, fontWeight: 700 }}>
{it.dataKey === "longRun" ? "Long terme" : "Année courante"} : {it.value.toFixed(2)}%
</p>
))}
</div>
);
}}
/>
<Line type="monotone" dataKey="nearTerm" stroke="#f59e0b" strokeWidth={1.5} dot={{ r: 2.5, fill: "#1e293b", stroke: "#f59e0b" }} />
<Line type="monotone" dataKey="longRun" stroke="#38bdf8" strokeWidth={1.5} dot={{ r: 2.5, fill: "#1e293b", stroke: "#38bdf8" }} />
</LineChart>
</ResponsiveContainer>
);
}
// ── Prévisions macro (comment la BC voit sa propre économie) ──────────────────
// PIB + inflation par échéance, publiées par la BC elle-même (SEP Fed,
// Eurosystem staff projections, BoJ Outlook Report, MPR BoC, SMP RBA…).
function fmtForecastYear(y: string): string {
const m = y.match(/^(\d{4})-(\d{2})$/);
if (!m) return y;
const MONTH: Record<string, string> = { "06": "Jun", "12": "Dec" };
return `${MONTH[m[2]] ?? m[2]}${m[1].slice(2)}`;
}
function ForecastBlock({ forecast }: { forecast: NonNullable<CBGovernance["forecast"]> }) {
const years = forecast.years;
const hasGdp = Object.values(forecast.gdp).some(v => v != null);
const hasInfl = Object.values(forecast.inflation).some(v => v != null);
if (!years.length || (!hasGdp && !hasInfl)) return null;
return (
<div className="space-y-1.5">
<p className="text-[9px] text-slate-600 uppercase tracking-wide">Prévisions comment la BC voit son économie</p>
<div className="bg-slate-900/60 rounded-lg px-3 py-2.5 overflow-x-auto">
<table className="w-full text-[10px]">
<thead>
<tr>
<th className="text-left text-slate-600 font-normal pb-1"> </th>
{years.map(y => (
<th key={y} className="text-right text-slate-500 font-medium pb-1 pl-2.5 tabular-nums whitespace-nowrap">{fmtForecastYear(y)}</th>
))}
</tr>
</thead>
<tbody>
{hasGdp && (
<tr>
<td className="text-slate-400 pr-2 py-0.5 whitespace-nowrap">PIB</td>
{years.map(y => {
const v = forecast.gdp[y];
return (
<td key={y} className="text-right text-slate-200 font-semibold tabular-nums pl-2.5 whitespace-nowrap">
{v != null ? `${v > 0 ? "+" : ""}${v}%` : "—"}
</td>
);
})}
</tr>
)}
{hasInfl && (
<tr>
<td className="text-slate-400 pr-2 py-0.5 whitespace-nowrap">Inflation</td>
{years.map(y => {
const v = forecast.inflation[y];
return (
<td key={y} className="text-right text-amber-400 font-semibold tabular-nums pl-2.5 whitespace-nowrap">
{v != null ? `${v > 0 ? "+" : ""}${v}%` : "—"}
</td>
);
})}
</tr>
)}
</tbody>
</table>
</div>
<p className="text-[8px] text-slate-700 leading-snug">
{forecast.label} · publié {forecast.asOf}
{forecast.isProxy && forecast.proxyLabel ? ` · ${forecast.proxyLabel}` : ""}
</p>
</div>
);
}
// ── Carte banque centrale ──────────────────────────────────────────────────────
function CBCard({ g }: { g: CBGovernance }) {
const hasVote = g.voteSummary !== null;
return (
<div className="bg-slate-950/60 border border-slate-800 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-slate-800 flex items-center gap-2">
<Flag ccy={g.currency} />
<div className="min-w-0">
<h3 className="text-sm font-semibold text-slate-200 truncate">{g.bankName}</h3>
<p className="text-[10px] text-slate-600">{g.countryLabel} · {g.currency}</p>
</div>
</div>
<div className="px-4 py-3 space-y-3">
{hasVote ? (
<>
<div className="flex items-center justify-between">
<div>
<p className="text-[9px] text-slate-600 uppercase tracking-wide">Dernière réunion</p>
<p className="text-xs text-slate-300 font-medium">{g.meetingDate ?? "—"}</p>
</div>
<div className="text-right">
<p className="text-[9px] text-slate-600 uppercase tracking-wide">Taux</p>
<p className="text-sm text-amber-400 font-bold tabular-nums">{g.rateLevel ?? "—"}</p>
</div>
</div>
<div className="bg-slate-900/60 rounded-lg px-3 py-2.5">
<p className="text-[9px] text-slate-600 uppercase tracking-wide mb-1">Vote</p>
<p className="text-lg font-black text-slate-100 tabular-nums">{g.voteSummary}</p>
{g.voteDetail && (
<p className="text-[10px] text-slate-500 mt-1 leading-snug">{g.voteDetail}</p>
)}
</div>
{g.dotPlot && (
<div className="space-y-2">
<p className="text-[9px] text-slate-600 uppercase tracking-wide">Dot plot projections des membres</p>
<FedDotPlotChart dotPlot={g.dotPlot} />
<p className="text-[9px] text-slate-600 uppercase tracking-wide">Évolution (dernières publications SEP)</p>
<FedEvolutionChart dotPlot={g.dotPlot} />
<div className="flex items-center gap-3 text-[8px] text-slate-600">
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-amber-500 inline-block" /> Année courante</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-sky-400 inline-block" /> Long terme</span>
</div>
</div>
)}
{g.forecast && <ForecastBlock forecast={g.forecast} />}
</>
) : (
<div className="bg-slate-900/60 rounded-lg px-3 py-3 text-center">
<p className="text-[11px] text-slate-500">
{g.fetchError === "Scraping non encore implémenté pour cette banque"
? "Vote / rapport non encore automatisés pour cette banque"
: `Données indisponibles (${g.fetchError ?? "erreur"})`}
</p>
</div>
)}
<div className="flex gap-2 pt-1">
{g.reportPdfUrl && (
<a
href={g.reportPdfUrl} target="_blank" rel="noopener noreferrer"
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] font-medium bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-md px-2 py-1.5 transition-colors"
>
<FileText size={11} /> Rapport PDF
</a>
)}
<a
href={g.policyPageUrl} target="_blank" rel="noopener noreferrer"
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] font-medium bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-md px-2 py-1.5 transition-colors"
>
<ExternalLink size={11} /> Site officiel
</a>
</div>
</div>
</div>
);
}
// ── Onglet principal ────────────────────────────────────────────────────────────
export default function CentralBankSourcesTab() {
const [data, setData] = useState<Record<string, CBGovernance> | null>(null);
const [loading, setLoading] = useState(true);
const [fetchedAt, setFetchedAt] = useState<string | null>(null);
const load = React.useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/central-bank-sources", { cache: "no-store" });
const json = await res.json();
setData(json.data);
setFetchedAt(json.fetchedAt);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
return (
<div className="bg-slate-950/60 border border-slate-800 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-slate-800 flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold text-slate-200">Sources banques centrales</h2>
<p className="text-[10px] text-slate-600 mt-0.5">
Vote de la dernière réunion · dot plot (Fed) · rapport de politique monétaire · site officiel
</p>
</div>
<div className="flex items-center gap-2">
{fetchedAt && <span className="text-[9px] text-slate-600">MAJ {new Date(fetchedAt).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}</span>}
<button
onClick={load}
disabled={loading}
className="flex items-center gap-1 text-[10px] text-slate-400 hover:text-slate-200 border border-slate-800 hover:border-slate-600 rounded-md px-2 py-1 transition-colors disabled:opacity-50"
>
<RefreshCw size={11} className={loading ? "animate-spin" : ""} /> Rafraîchir
</button>
</div>
</div>
{loading && !data ? (
<div className="flex items-center justify-center py-16">
<Loader2 size={20} className="animate-spin text-slate-600" />
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 p-4">
{ORDER.map(ccy => {
const g = data?.[ccy];
return g ? <CBCard key={ccy} g={g} /> : null;
})}
</div>
)}
</div>
);
}
+60 -5
View File
@@ -39,10 +39,15 @@ interface MacroForecasts {
gdp: number | null; gdpSurprise: number | null;
employment: number | null; employmentSurprise: number | null;
}
interface MoneySupplyM3 {
value: number; unit: string; period: string; isProxy: boolean;
proxyLabel?: string; source: string;
}
interface MacroData {
currency: string;
indicators: Record<string, Ind | null>;
forecasts?: MacroForecasts | null;
moneySupplyM3?: MoneySupplyM3 | null;
fetchedAt: string;
}
@@ -216,6 +221,16 @@ const ENERGY_PROFILE: Record<string, {
NZD: { type: "import", desc: "Import pétrole. Renouvelables ~85% élec (hydro). Indépendant localement.", products: ["Lait / Produits laitiers", "Viande bovine", "Bois", "Laine"] },
};
// ─── Money Supply M3 — formatage compact (niveau, devise locale) ─────────────
function formatM3(m3: MoneySupplyM3): string {
const mult = m3.unit.includes("Bn") ? 1e9 : m3.unit.includes("Mn") ? 1e6 : 1;
const raw = m3.value * mult;
const ccy = m3.unit.split(" ")[0];
const compact = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 2 }).format(raw);
return `${compact} ${ccy}`;
}
function trendDir(t: "up"|"down"|"flat"|null): SignalDir {
if (t === "up") return "bullish";
if (t === "down") return "bearish";
@@ -387,6 +402,7 @@ function SourcesPopup({ currency, onClose }: { currency: string; onClose: () =>
icon: "🏦",
sources: [
{ label: `Trading Economics — ${country} interest rate`, url: `https://tradingeconomics.com/${country}/interest-rate`, note: `Taux directeur ${cbName} actuel` },
{ label: `Trading Economics — ${country} money supply M3`, url: `https://tradingeconomics.com/${country}/money-supply-m3`, note: currency === "USD" ? "M2 utilisé en proxy (M3 non publié par la Fed depuis 2006)" : "Masse monétaire M3" },
{ label: "FRED — Federal Reserve Economic Data", url: "https://fred.stlouisfed.org", note: "Spreads crédit HY/IG (BAMLH0A0HYM2, BAMLC0A0CM)" },
],
},
@@ -523,6 +539,11 @@ function OISEnhancedBlock({ ratePath, syncChartTab, onChartTabChange }: {
const altRate = mlIsMove ? currentRate : m0.impliedRate;
const altProb = mlIsMove ? 100 - m0.probMovePct : m0.probMovePct;
const altIsMove = !mlIsMove;
// moveLabel vaut toujours "Hold" quand le scénario principal est un statu quo (isMoveExpected
// false) — le réutiliser pour l'alternative affichait donc "X% Hold" au lieu de "X% Hike/Cut"
// quand l'alternative EST le mouvement. La direction de l'alternative-mouvement est toujours
// celle de m0.probIsCut, indépendamment du scénario principal.
const altLabel = altIsMove ? (m0.probIsCut ? "Cut" : "Hike") : "Hold";
// Chart data (limit to 10 meetings)
const chartMeetings = meetings.slice(0, 10);
@@ -588,7 +609,7 @@ function OISEnhancedBlock({ ratePath, syncChartTab, onChartTabChange }: {
{altProb >= 15 && altIsMove !== mlIsMove && (
<>
<span className="text-[8px] text-slate-700 shrink-0">·</span>
<span className="text-[9px] text-slate-500 shrink-0">{Math.round(altProb)}% {altIsMove ? moveLabel : "Hold"}</span>
<span className="text-[9px] text-slate-500 shrink-0">{Math.round(altProb)}% {altLabel}</span>
</>
)}
<span className="ml-auto text-[7px] text-slate-700 shrink-0">au {ratePath.asOf}</span>
@@ -939,6 +960,7 @@ export default function CurrencyCard({
// ── Computed values ──────────────────────────────────────────────────────────
const inds = data?.indicators;
const fc = data?.forecasts ?? null;
const m3 = data?.moneySupplyM3 ?? null;
const forScoring = {
policyRate: { value: inds?.policyRate?.value ?? null, prev: inds?.policyRate?.prev ?? null, consensus: null, surprise: inds?.policyRate?.surprise ?? null, trend: inds?.policyRate?.trend ?? null, lastUpdated: "" },
@@ -1638,8 +1660,24 @@ export default function CurrencyCard({
className="absolute inset-0 overflow-hidden"
>
{/* Skeleton chargement — affiché quand pas encore de données */}
{loading && !data && (
<div className="bg-slate-800/40 rounded-xl border border-slate-700/30 p-3 space-y-2.5 animate-pulse h-full">
<div className="h-2 bg-slate-700/60 rounded w-28 mb-3" />
{[68, 82, 55, 76, 60].map((w, i) => (
<div key={i} className="space-y-1">
<div className="flex justify-between items-center">
<div className="h-2.5 bg-slate-700/50 rounded" style={{ width: `${w}%` }} />
<div className="h-2.5 bg-slate-700/40 rounded w-10" />
</div>
<div className="h-1.5 bg-slate-700/25 rounded w-1/3 ml-4" />
</div>
))}
</div>
)}
{/* MON. — Politique Monétaire */}
{macroSlide === "mon" && (
{!loading && macroSlide === "mon" && (
<MacroBlock title="Politique Monétaire">
<IRow label="Taux directeur" ind={inds?.policyRate ?? null} unit="%" consensus={rateConsensus} isNew={indIsNew("policyRate")} />
{(() => {
@@ -1666,11 +1704,28 @@ export default function CurrencyCard({
<span className="font-semibold text-slate-200 tabular-nums">{yield10Y.toFixed(2)}%</span>
</div>
)}
{m3 && (
<div className="flex items-center justify-between text-[12px]">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-slate-600 shrink-0"></span>
<span className="text-slate-400 truncate">Money Supply M3{m3.isProxy ? " (M2)" : ""}</span>
{m3.isProxy && (
<span className="relative group/info inline-flex shrink-0 cursor-help">
<span className="inline-flex items-center justify-center w-3 h-3 rounded-full border border-slate-700 text-slate-500 text-[7px] font-bold leading-none">i</span>
<span className="pointer-events-none absolute bottom-full left-0 mb-1.5 w-52 px-2 py-1.5 rounded-md bg-slate-950 text-slate-300 text-[10px] leading-snug opacity-0 group-hover/info:opacity-100 transition-opacity duration-150 z-50 shadow-lg whitespace-normal border border-slate-700">
{m3.proxyLabel}
</span>
</span>
)}
</div>
<span className="font-semibold text-slate-200 tabular-nums shrink-0" title={`${m3.period} · ${m3.source}`}>{formatM3(m3)}</span>
</div>
)}
</MacroBlock>
)}
{/* INFL. — Inflation */}
{macroSlide === "infl" && (
{!loading && macroSlide === "infl" && (
<MacroBlock title="Inflation">
<IRow label="CPI MoM" ind={inds?.cpiMoM ?? null} unit="%" consensus={fc?.cpiMoM ?? null} isNew={indIsNew("cpiMoM")} />
<IRow label="PPI MoM" ind={inds?.ppiMoM ?? null} unit="%" consensus={fc?.ppiMoM ?? null} isNew={indIsNew("ppiMoM")} />
@@ -1680,7 +1735,7 @@ export default function CurrencyCard({
)}
{/* CRO. — Croissance */}
{macroSlide === "cro" && (
{!loading && macroSlide === "cro" && (
<MacroBlock title="Croissance">
<IRow label="PIB (QoQ%)" ind={inds?.gdp ?? null} unit="%" consensus={fc?.gdp ?? null} surpriseVsCons={fc?.gdpSurprise ?? null} isNew={indIsNew("gdp")} />
<IRow label="PMI Composite" ind={inds?.pmiComposite ?? null} consensus={fc?.pmiComposite ?? null} surpriseVsCons={fc?.pmiCompositeSurprise ?? null} isNew={indIsNew("pmiComposite")} />
@@ -1691,7 +1746,7 @@ export default function CurrencyCard({
)}
{/* EMPL. — Emploi */}
{macroSlide === "empl" && (
{!loading && macroSlide === "empl" && (
<MacroBlock title="Emploi">
<IRow label="Variation emploi" ind={inds?.employment ?? null} unit="k" consensus={fc?.employment ?? null} surpriseVsCons={fc?.employmentSurprise ?? null} isNew={indIsNew("employment")} />
<IRow label="Taux de chômage" ind={inds?.unemployment ?? null} unit="%" invertSurprise consensus={fc?.unemployment ?? null} surpriseVsCons={fc?.unemploymentSurprise ?? null} isNew={indIsNew("unemployment")} />
+42 -18
View File
@@ -52,8 +52,8 @@ function loadLS<T>(key: string, fb: T): T {
if (typeof window === "undefined") return fb;
try { const r = localStorage.getItem(key); return r ? JSON.parse(r) as T : fb; } catch { return fb; }
}
function saveLS(key: string, val: unknown) {
try { localStorage.setItem(key, JSON.stringify(val)); } catch {}
function saveLS(key: string, val: unknown): boolean {
try { localStorage.setItem(key, JSON.stringify(val)); return true; } catch { return false; }
}
const DEFAULT_SLOT = (symbol = "FX:EURUSD"): SlotState => ({
@@ -444,7 +444,9 @@ function ArchiveCard({ a, onDelete, onRestore }: {
)}
{/* Carte compacte */}
<div className="border border-slate-700/30 rounded-xl overflow-hidden bg-slate-900/30 hover:bg-slate-800/30 transition-colors">
{/* Pas de overflow-hidden ici : le menu déroulant "Restaurer" est en position
absolute et serait rogné par un ancêtre overflow-hidden (cf. bug signalé). */}
<div className="border border-slate-700/30 rounded-xl bg-slate-900/30 hover:bg-slate-800/30 transition-colors">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-slate-700/20">
<span className="text-[10px] font-bold text-sky-400/80 font-mono">{a.slot.symbol}</span>
@@ -483,18 +485,24 @@ function ArchiveCard({ a, onDelete, onRestore }: {
className="text-[8px] text-slate-600 hover:text-sky-400 transition-colors ml-1 shrink-0"
> Voir</button>
<button
onClick={onDelete}
className="text-[8px] text-red-500/30 hover:text-red-400 transition-colors ml-1 shrink-0"
></button>
onClick={() => { if (window.confirm("Supprimer définitivement cette archive ?")) onDelete(); }}
title="Supprimer définitivement"
className="text-[8px] text-red-400/80 hover:text-red-400 transition-colors ml-1 shrink-0"
> Supprimer</button>
</div>
{/* Corps : texte + images */}
{a.slot.notes && (
<div className="px-4 py-3">
{/* Texte brut (sans tags HTML) */}
{/* Texte brut (sans tags HTML) — les tags de bloc sont convertis en
retours à la ligne avant extraction, sinon .textContent colle tous
les paragraphes/lignes de liste bout à bout sans séparation. */}
{(() => {
const doc = new DOMParser().parseFromString(a.slot.notes, "text/html");
const text = doc.body.textContent?.trim() ?? "";
const withBreaks = a.slot.notes
.replace(/<\/(p|div|li|h[1-6])>/gi, "\n")
.replace(/<br\s*\/?>/gi, "\n");
const doc = new DOMParser().parseFromString(withBreaks, "text/html");
const text = (doc.body.textContent ?? "").replace(/\n{3,}/g, "\n\n").trim();
const imgs = Array.from(doc.images);
return (
<>
@@ -564,6 +572,14 @@ export default function IdeesTab() {
DEFAULT_SLOT("FX:GBPUSD"),
]);
const [archives, setArchives] = useState<Archive[]>([]);
// Passe à true si une écriture localStorage échoue (ex: quota dépassé à cause
// des images en base64 dans les notes archivées) — sans ça l'échec est silencieux
// et une suppression/modification peut sembler ne "pas marcher" après rechargement.
const [saveError, setSaveError] = useState(false);
const persist = useCallback((key: string, val: unknown) => {
setSaveError(!saveLS(key, val));
}, []);
useEffect(() => {
setSlots(loadLS<[SlotState, SlotState]>(LS_SLOTS, [DEFAULT_SLOT("FX:EURUSD"), DEFAULT_SLOT("FX:GBPUSD")]));
@@ -574,30 +590,30 @@ export default function IdeesTab() {
setSlots(prev => {
const next: [SlotState, SlotState] = [prev[0], prev[1]];
next[idx] = s;
saveLS(LS_SLOTS, next);
persist(LS_SLOTS, next);
return next;
});
}, []);
}, [persist]);
const archiveSlot = useCallback((idx: 0 | 1) => {
const entry: Archive = { id: Date.now().toString(), savedAt: new Date().toISOString(), slot: slots[idx] };
const next = [entry, ...archives];
setArchives(next);
saveLS(LS_ARCHIVES, next);
persist(LS_ARCHIVES, next);
const reset = DEFAULT_SLOT(idx === 0 ? "FX:EURUSD" : "FX:GBPUSD");
setSlots(prev => {
const n: [SlotState, SlotState] = [prev[0], prev[1]];
n[idx] = reset;
saveLS(LS_SLOTS, n);
persist(LS_SLOTS, n);
return n;
});
}, [slots, archives]);
}, [slots, archives, persist]);
const deleteArchive = useCallback((id: string) => {
const next = archives.filter(a => a.id !== id);
setArchives(next);
saveLS(LS_ARCHIVES, next);
}, [archives]);
persist(LS_ARCHIVES, next);
}, [archives, persist]);
const restoreArchive = useCallback((id: string, slotIdx: 0 | 1) => {
const entry = archives.find(a => a.id === id);
@@ -605,10 +621,10 @@ export default function IdeesTab() {
setSlots(prev => {
const next = [...prev] as [SlotState, SlotState];
next[slotIdx] = { ...entry.slot };
saveLS(LS_SLOTS, next);
persist(LS_SLOTS, next);
return next;
});
}, [archives]);
}, [archives, persist]);
return (
<div className="space-y-4">
@@ -619,6 +635,14 @@ export default function IdeesTab() {
<div className="h-px flex-1 bg-purple-500/20" />
</div>
{saveError && (
<div className="text-[10px] text-red-300 bg-red-500/10 border border-red-500/30 rounded-lg px-3 py-2">
Échec de sauvegarde locale le quota de stockage du navigateur est probablement dépassé
(les archives avec images occupent beaucoup de place). Supprime quelques archives pour libérer
de la place, sinon tes changements ne seront pas conservés au rechargement.
</div>
)}
<ResearchSlot slot={slots[0]} label="Recherche A" onChange={s => updateSlot(0, s)} onArchive={() => archiveSlot(0)} />
<ResearchSlot slot={slots[1]} label="Recherche B" onChange={s => updateSlot(1, s)} onArchive={() => archiveSlot(1)} />