mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-07-27 20:37:45 +00:00
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:
+61
-12
@@ -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'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'hui
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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)} />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user