fix: OIS toujours visible + Résumé IA blanc + news séparées

- CurrencyCard: OIS toujours dans sigTabs (plus conditionnel à ratePath)
  → affiche état 'indisponible' quand données absentes, au lieu de disparaître
- NarrativeButton: couleur 'Résumé IA' blanc (#ffffff) au lieu d'amber
- NewsTab: Inflation/Géopolitique/Emploi/Énergie toujours visibles comme filtres
  → séparation TOP NEWS (articles avec étiquette macro) vs 'Autres actualités'
     (articles boursiers sans tag FX) dans section repliable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
caty21
2026-06-27 22:28:39 +02:00
co-authored by Claude Sonnet 4.6
parent 5b313a7e3e
commit c82dbb397b
3 changed files with 115 additions and 59 deletions
+14 -3
View File
@@ -1500,11 +1500,12 @@ export default function CurrencyCard({
<> <>
{/* ── Slider : OIS / COT / Sentiment ──────────────────────── */} {/* ── Slider : OIS / COT / Sentiment ──────────────────────── */}
{(() => { {(() => {
const sigTabs: { id: SignauxSlide; label: string }[] = []; // OIS est toujours présent (même si données indisponibles)
if (ratePath && ratePath.meetings.length > 0) sigTabs.push({ id: "ois", label: "OIS" }); const sigTabs: { id: SignauxSlide; label: string }[] = [
{ id: "ois", label: "OIS" },
];
if (cot) sigTabs.push({ id: "cot", label: "COT" }); if (cot) sigTabs.push({ id: "cot", label: "COT" });
if (sentiment) sigTabs.push({ id: "sent", label: "Sentiment" }); if (sentiment) sigTabs.push({ id: "sent", label: "Sentiment" });
if (!sigTabs.length) return <div className="text-center text-slate-600 text-[11px] py-4">Données insuffisantes</div>;
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
{sigTabs.length > 1 && ( {sigTabs.length > 1 && (
@@ -1535,6 +1536,16 @@ export default function CurrencyCard({
{signauxSlide === "ois" && ratePath && ratePath.meetings.length > 0 && ( {signauxSlide === "ois" && ratePath && ratePath.meetings.length > 0 && (
<OISEnhancedBlock ratePath={ratePath} /> <OISEnhancedBlock ratePath={ratePath} />
)} )}
{/* OIS — état indisponible */}
{signauxSlide === "ois" && (!ratePath || !ratePath.meetings.length) && (
<div className="flex flex-col items-center justify-center h-full gap-3 text-slate-700">
<Target size={24} />
<div className="text-center">
<p className="text-[11px] text-slate-500">Données OIS indisponibles</p>
<p className="text-[10px] text-slate-600 mt-1">Cache actualisé par GitHub Actions (2448h)</p>
</div>
</div>
)}
{/* COT */} {/* COT */}
{signauxSlide === "cot" && cot && (() => { {signauxSlide === "cot" && cot && (() => {
+3 -3
View File
@@ -45,9 +45,9 @@ export default function NarrativeButton({ currency, phase, macroScore }: Props)
<button <button
onClick={run} onClick={run}
disabled={loading} disabled={loading}
style={{ color: '#f59e0b' }} style={{ color: '#ffffff' }}
onMouseEnter={e => (e.currentTarget.style.color = '#fcd34d')} onMouseEnter={e => (e.currentTarget.style.color = '#e2e8f0')}
onMouseLeave={e => (e.currentTarget.style.color = '#f59e0b')} onMouseLeave={e => (e.currentTarget.style.color = '#ffffff')}
className="flex items-center gap-1 text-[10px] disabled:opacity-50 transition-colors px-2 py-1" className="flex items-center gap-1 text-[10px] disabled:opacity-50 transition-colors px-2 py-1"
> >
<Sparkles size={11} /> <Sparkles size={11} />
+98 -53
View File
@@ -3,7 +3,7 @@
import { useState, useMemo, useEffect, useRef } from "react"; import { useState, useMemo, useEffect, useRef } from "react";
import { import {
ExternalLink, RefreshCw, TrendingUp, TrendingDown, Minus, ExternalLink, RefreshCw, TrendingUp, TrendingDown, Minus,
Loader2, Radio, AlertTriangle, Landmark, Globe, BarChart2, Zap, Loader2, Radio, AlertTriangle, Landmark, Globe, BarChart2, Zap, ChevronDown, ChevronUp,
} from "lucide-react"; } from "lucide-react";
import type { NewsItem } from "@/app/api/news/route"; import type { NewsItem } from "@/app/api/news/route";
import type { Currency } from "@/lib/types"; import type { Currency } from "@/lib/types";
@@ -34,6 +34,7 @@ const CATEGORY_META: Record<string, { icon: React.ReactNode; color: string; labe
"Chine": { icon: <Globe size={11} />, color: "bg-red-600/20 text-red-400 border-red-600/30", label: "Chine" }, "Chine": { icon: <Globe size={11} />, color: "bg-red-600/20 text-red-400 border-red-600/30", label: "Chine" },
}; };
// Catégories affichées comme boutons de filtre (ordre d'apparition)
const PRIORITY_CATS = [ const PRIORITY_CATS = [
"Discours BC", "Décision Taux", "Probabilités Taux", "Discours BC", "Décision Taux", "Probabilités Taux",
"Chef d'État", "Données Clés", "Emploi", "Inflation", "Chef d'État", "Données Clés", "Emploi", "Inflation",
@@ -41,6 +42,12 @@ const PRIORITY_CATS = [
"Énergie", "Commodités", "Chine", "Énergie", "Commodités", "Chine",
]; ];
// Catégories toujours visibles dans la barre même sans articles correspondants
const ALWAYS_VISIBLE = new Set(["Inflation", "Géopolitique", "Emploi", "Énergie"]);
// Set pour la séparation top-news / autres
const PRIORITY_CATS_SET = new Set(PRIORITY_CATS);
// Catégories "haute priorité" : reçoivent un boost de tri // Catégories "haute priorité" : reçoivent un boost de tri
const HIGH_PRIO = new Set([ const HIGH_PRIO = new Set([
"Discours BC", "Décision Taux", "Crise", "Guerre", "Chef d'État", "Probabilités Taux", "Discours BC", "Décision Taux", "Crise", "Guerre", "Chef d'État", "Probabilités Taux",
@@ -65,9 +72,8 @@ function formatRelativeTime(isoDate: string): string {
} }
// ── Tri ─────────────────────────────────────────────────────────────────────── // ── Tri ───────────────────────────────────────────────────────────────────────
// Score plus élevé = apparaît en premier dans la liste. // Score plus élevé = apparaît en premier.
// Ordre : récence (primaire) → haute priorité (+3h) → article catégorisé (+1h) // Récence primaire → haute priorité (+3h) → catégorisé (+1h) → bruit sans catégorie (0)
// Les articles sans catégorie ni impact (bruit boursier Yahoo etc.) restent en bas.
function scoreItem(item: NewsItem): number { function scoreItem(item: NewsItem): number {
const t = new Date(item.publishedAt).getTime(); const t = new Date(item.publishedAt).getTime();
@@ -85,12 +91,12 @@ interface Props {
} }
export default function NewsTab({ items, loading, onRefresh }: Props) { export default function NewsTab({ items, loading, onRefresh }: Props) {
// Multi-sélection : Set vide = "Toutes" (aucun filtre actif)
const [selectedCats, setSelectedCats] = useState<Set<string>>(new Set()); const [selectedCats, setSelectedCats] = useState<Set<string>>(new Set());
const [filterDir, setFilterDir] = useState<"all" | "bullish" | "bearish">("all"); const [filterDir, setFilterDir] = useState<"all" | "bullish" | "bearish">("all");
const [priorityOnly, setPriorityOnly] = useState(false); const [priorityOnly, setPriorityOnly] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(true); const [autoRefresh, setAutoRefresh] = useState(true);
const [lastRefreshAt, setLastRefreshAt] = useState<Date | null>(null); const [lastRefreshAt, setLastRefreshAt] = useState<Date | null>(null);
const [othersOpen, setOthersOpen] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => { useEffect(() => {
@@ -99,7 +105,6 @@ export default function NewsTab({ items, loading, onRefresh }: Props) {
return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
}, [autoRefresh, onRefresh]); }, [autoRefresh, onRefresh]);
// Bascule une catégorie dans la sélection (OR logic — plusieurs peuvent être actives)
const toggleCat = (cat: string) => { const toggleCat = (cat: string) => {
setSelectedCats(prev => { setSelectedCats(prev => {
const next = new Set(prev); const next = new Set(prev);
@@ -109,25 +114,38 @@ export default function NewsTab({ items, loading, onRefresh }: Props) {
}); });
}; };
// Filtre + tri client // Filtre + tri — articles avec catégories macro et articles sans
const filtered = useMemo(() => { const { topFiltered, otherItems } = useMemo(() => {
const result = items.filter(item => { const hasActiveFilter = selectedCats.size > 0 || filterDir !== "all" || priorityOnly;
// Filtre "Prioritaire"
if (priorityOnly && !item.categories.some(c => HIGH_PRIO.has(c))) return false; // Articles filtrés (tous)
// Filtre catégories multi-select (OR) : l'article doit matcher AU MOINS UNE cat sélectionnée const allFiltered = items
if (selectedCats.size > 0 && !item.categories.some(c => selectedCats.has(c))) return false; .filter(item => {
// Filtre direction if (priorityOnly && !item.categories.some(c => HIGH_PRIO.has(c))) return false;
if (filterDir !== "all" && !item.impacts.some(i => i.direction === filterDir)) return false; if (selectedCats.size > 0 && !item.categories.some(c => selectedCats.has(c))) return false;
return true; if (filterDir !== "all" && !item.impacts.some(i => i.direction === filterDir)) return false;
}); return true;
// Tri côté client : récence → priorité → catégorie présente })
return result.sort((a, b) => scoreItem(b) - scoreItem(a)); .sort((a, b) => scoreItem(b) - scoreItem(a));
// TOP NEWS : articles reconnus (≥1 catégorie macro)
const top = allFiltered.filter(item => item.categories.some(c => PRIORITY_CATS_SET.has(c)));
// AUTRES : articles sans étiquette macro — uniquement quand aucun filtre actif
const others = hasActiveFilter
? []
: items
.filter(item => !item.categories.some(c => PRIORITY_CATS_SET.has(c)))
.sort((a, b) => scoreItem(b) - scoreItem(a));
return { topFiltered: top, otherItems: others };
}, [items, selectedCats, filterDir, priorityOnly]); }, [items, selectedCats, filterDir, priorityOnly]);
// Catégories présentes dans les articles actuels (pour afficher les boutons pertinents) // Boutons de catégories : dynamiques + catégories "toujours visibles"
const activeCats = useMemo(() => { const activeCats = useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
for (const item of items) item.categories.forEach(c => set.add(c)); for (const item of items) item.categories.forEach(c => set.add(c));
ALWAYS_VISIBLE.forEach(c => set.add(c)); // toujours afficher ces catégories
return PRIORITY_CATS.filter(c => set.has(c)); return PRIORITY_CATS.filter(c => set.has(c));
}, [items]); }, [items]);
@@ -141,7 +159,6 @@ export default function NewsTab({ items, loading, onRefresh }: Props) {
{/* Ligne 1 : contrôles + direction + prioritaire */} {/* Ligne 1 : contrôles + direction + prioritaire */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
{/* Live 1min */}
<button onClick={() => setAutoRefresh(a => !a)} <button onClick={() => setAutoRefresh(a => !a)}
className={`flex items-center gap-1.5 text-[9px] px-2 py-1 rounded-full border transition-colors shrink-0 ${ className={`flex items-center gap-1.5 text-[9px] px-2 py-1 rounded-full border transition-colors shrink-0 ${
autoRefresh autoRefresh
@@ -152,7 +169,6 @@ export default function NewsTab({ items, loading, onRefresh }: Props) {
{autoRefresh ? "Live 1min" : "Pause"} {autoRefresh ? "Live 1min" : "Pause"}
</button> </button>
{/* Actualiser */}
<button onClick={() => { onRefresh(); setLastRefreshAt(new Date()); }} disabled={loading} <button onClick={() => { onRefresh(); setLastRefreshAt(new Date()); }} disabled={loading}
className="flex items-center gap-1 text-[9px] text-slate-500 hover:text-slate-300 disabled:opacity-40 shrink-0 transition-colors"> className="flex items-center gap-1 text-[9px] text-slate-500 hover:text-slate-300 disabled:opacity-40 shrink-0 transition-colors">
<RefreshCw size={9} className={loading ? "animate-spin" : ""} /> <RefreshCw size={9} className={loading ? "animate-spin" : ""} />
@@ -193,7 +209,7 @@ export default function NewsTab({ items, loading, onRefresh }: Props) {
<div className="ml-auto flex items-center gap-2 shrink-0"> <div className="ml-auto flex items-center gap-2 shrink-0">
<span className="text-[10px] text-slate-600"> <span className="text-[10px] text-slate-600">
{loading ? "…" : `${filtered.length} article${filtered.length !== 1 ? "s" : ""}`} {loading ? "…" : `${topFiltered.length} article${topFiltered.length !== 1 ? "s" : ""}`}
</span> </span>
{anyFilter && ( {anyFilter && (
<button onClick={clearFilters} <button onClick={clearFilters}
@@ -210,7 +226,6 @@ export default function NewsTab({ items, loading, onRefresh }: Props) {
<span className="text-[9px] text-amber-400 uppercase tracking-wider font-semibold shrink-0"> <span className="text-[9px] text-amber-400 uppercase tracking-wider font-semibold shrink-0">
Catégorie Catégorie
</span> </span>
{/* "Toutes" remet la sélection à zéro */}
<button onClick={() => setSelectedCats(new Set())} <button onClick={() => setSelectedCats(new Set())}
className={`text-[10px] px-2 py-0.5 rounded-full font-semibold border transition-colors shrink-0 ${ className={`text-[10px] px-2 py-0.5 rounded-full font-semibold border transition-colors shrink-0 ${
selectedCats.size === 0 selectedCats.size === 0
@@ -238,23 +253,50 @@ export default function NewsTab({ items, loading, onRefresh }: Props) {
)} )}
</div> </div>
{/* ── Liste ───────────────────────────────────────────────────────────── */} {/* ── TOP NEWS ────────────────────────────────────────────────────────── */}
{loading && items.length === 0 ? ( {loading && items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-600 gap-3"> <div className="flex flex-col items-center justify-center py-16 text-slate-600 gap-3">
<Loader2 size={24} className="animate-spin" /> <Loader2 size={24} className="animate-spin" />
<span className="text-sm">Chargement des actualités</span> <span className="text-sm">Chargement des actualités</span>
<span className="text-[11px] text-slate-700">InvestingLive · Reuters · Bloomberg</span> <span className="text-[11px] text-slate-700">InvestingLive · Reuters · Bloomberg</span>
</div> </div>
) : filtered.length === 0 ? ( ) : topFiltered.length === 0 && otherItems.length === 0 ? (
<div className="text-center py-12 text-slate-600 text-sm"> <div className="text-center py-12 text-slate-600 text-sm">
Aucune actualité pour ce filtre. Aucune actualité pour ce filtre.
</div> </div>
) : ( ) : (
<div className="space-y-2"> <>
{filtered.map(item => ( {/* Articles macro / top news */}
<NewsCard key={item.id} item={item} /> {topFiltered.length > 0 && (
))} <div className="space-y-2">
</div> {topFiltered.map(item => (
<NewsCard key={item.id} item={item} />
))}
</div>
)}
{/* Section "Autres actualités" — articles sans étiquette macro */}
{otherItems.length > 0 && (
<div className="mt-1">
<button
onClick={() => setOthersOpen(o => !o)}
className="w-full flex items-center gap-2 px-3 py-2 rounded-xl border border-slate-800 bg-slate-900/50 text-slate-600 hover:text-slate-400 transition-colors text-[10px] font-medium"
>
{othersOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
<span>Autres actualités</span>
<span className="ml-auto text-slate-700">{otherItems.length} article{otherItems.length !== 1 ? "s" : ""} non reconnus</span>
</button>
{othersOpen && (
<div className="mt-2 space-y-2">
{otherItems.map(item => (
<NewsCard key={item.id} item={item} secondary />
))}
</div>
)}
</div>
)}
</>
)} )}
</div> </div>
); );
@@ -262,7 +304,7 @@ export default function NewsTab({ items, loading, onRefresh }: Props) {
// ── NewsCard ────────────────────────────────────────────────────────────────── // ── NewsCard ──────────────────────────────────────────────────────────────────
function NewsCard({ item }: { item: NewsItem }) { function NewsCard({ item, secondary = false }: { item: NewsItem; secondary?: boolean }) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const overallDir = const overallDir =
@@ -271,20 +313,22 @@ function NewsCard({ item }: { item: NewsItem }) {
: item.impacts.some(i => i.direction === "bearish") ? "bearish" : item.impacts.some(i => i.direction === "bearish") ? "bearish"
: "neutral"; : "neutral";
const isPriority = item.categories.some(c => const isPriority = !secondary && item.categories.some(c =>
["Discours BC", "Décision Taux", "Crise", "Guerre", "Chef d'État"].includes(c) ["Discours BC", "Décision Taux", "Crise", "Guerre", "Chef d'État"].includes(c)
); );
const borderCls = const borderCls = secondary
overallDir === "bullish" ? "border-emerald-500/25" : ? "border-slate-800/50"
overallDir === "bearish" ? "border-red-500/25" : : overallDir === "bullish" ? "border-emerald-500/25"
overallDir === "mixed" ? "border-amber-500/20" : : overallDir === "bearish" ? "border-red-500/25"
"border-slate-700/30"; : overallDir === "mixed" ? "border-amber-500/20"
: "border-slate-700/30";
const bgCls = const bgCls = secondary
overallDir === "bullish" ? "bg-emerald-500/5" : ? "bg-slate-900/30"
overallDir === "bearish" ? "bg-red-500/5" : : overallDir === "bullish" ? "bg-emerald-500/5"
"bg-slate-800/30"; : overallDir === "bearish" ? "bg-red-500/5"
: "bg-slate-800/30";
const topCat = PRIORITY_CATS.find(p => item.categories.includes(p)); const topCat = PRIORITY_CATS.find(p => item.categories.includes(p));
const topMeta = topCat ? CATEGORY_META[topCat] : null; const topMeta = topCat ? CATEGORY_META[topCat] : null;
@@ -292,7 +336,6 @@ function NewsCard({ item }: { item: NewsItem }) {
return ( return (
<div className={`rounded-xl border ${borderCls} ${bgCls} overflow-hidden ${isPriority ? "ring-1 ring-offset-0 ring-amber-500/20" : ""}`}> <div className={`rounded-xl border ${borderCls} ${bgCls} overflow-hidden ${isPriority ? "ring-1 ring-offset-0 ring-amber-500/20" : ""}`}>
<div className="p-3"> <div className="p-3">
{/* Header */}
<div className="flex items-center gap-2 mb-1.5 flex-wrap"> <div className="flex items-center gap-2 mb-1.5 flex-wrap">
<span className={`text-[9px] font-semibold px-2 py-0.5 rounded-full ${SOURCE_COLORS[item.source] ?? "bg-slate-700/40 text-slate-400"}`}> <span className={`text-[9px] font-semibold px-2 py-0.5 rounded-full ${SOURCE_COLORS[item.source] ?? "bg-slate-700/40 text-slate-400"}`}>
{item.source} {item.source}
@@ -308,23 +351,20 @@ function NewsCard({ item }: { item: NewsItem }) {
)} )}
</div> </div>
{/* Titre */}
<a href={item.url} target="_blank" rel="noopener noreferrer" <a href={item.url} target="_blank" rel="noopener noreferrer"
className="group flex items-start gap-1.5 mb-1.5"> className="group flex items-start gap-1.5 mb-1.5">
<span className="text-[12px] text-slate-200 leading-snug font-medium group-hover:text-white transition-colors"> <span className={`text-[12px] leading-snug font-medium group-hover:text-white transition-colors ${secondary ? "text-slate-400" : "text-slate-200"}`}>
{item.title} {item.title}
</span> </span>
<ExternalLink size={10} className="text-slate-600 group-hover:text-slate-400 shrink-0 mt-0.5" /> <ExternalLink size={10} className="text-slate-600 group-hover:text-slate-400 shrink-0 mt-0.5" />
</a> </a>
{/* Résumé */} {item.summary && !secondary && (
{item.summary && (
<p className="text-[11px] text-slate-500 mb-1.5 leading-relaxed line-clamp-2"> <p className="text-[11px] text-slate-500 mb-1.5 leading-relaxed line-clamp-2">
{item.summary} {item.summary}
</p> </p>
)} )}
{/* Impact badges + heure + bouton analyse */}
{item.impacts.length > 0 && ( {item.impacts.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
{item.impacts.map(imp => ( {item.impacts.map(imp => (
@@ -342,15 +382,20 @@ function NewsCard({ item }: { item: NewsItem }) {
))} ))}
<div className="ml-auto flex items-center gap-1.5 shrink-0"> <div className="ml-auto flex items-center gap-1.5 shrink-0">
<span className="text-[10px] text-slate-600">{formatRelativeTime(item.publishedAt)}</span> <span className="text-[10px] text-slate-600">{formatRelativeTime(item.publishedAt)}</span>
<button onClick={() => setExpanded(e => !e)} {!secondary && (
className="text-[9px] text-slate-500 hover:text-slate-300 px-1.5 py-0.5 rounded-full border border-slate-700/30 transition-colors"> <button onClick={() => setExpanded(e => !e)}
{expanded ? "▲" : "▼ Analyse"} className="text-[9px] text-slate-500 hover:text-slate-300 px-1.5 py-0.5 rounded-full border border-slate-700/30 transition-colors">
</button> {expanded ? "▲" : "▼ Analyse"}
</button>
)}
</div> </div>
</div> </div>
)} )}
{/* Détail des raisons */} {!secondary && item.impacts.length === 0 && (
<span className="text-[10px] text-slate-600">{formatRelativeTime(item.publishedAt)}</span>
)}
{expanded && item.impacts.length > 0 && ( {expanded && item.impacts.length > 0 && (
<div className="mt-2 space-y-1.5 border-t border-slate-700/30 pt-2"> <div className="mt-2 space-y-1.5 border-t border-slate-700/30 pt-2">
{item.impacts.map(imp => ( {item.impacts.map(imp => (