"use client"; import React, { useState, useMemo, useRef } from "react"; import { ChevronDown, ChevronRight, Loader2, Calendar } from "lucide-react"; import { CURRENCY_META } from "@/lib/constants"; import type { CalendarEvent } from "@/app/api/calendar/route"; interface Props { events: CalendarEvent[]; loading: boolean; nextWeekAvail: boolean; } const CATEGORY_LABELS: Record = { employment: "Emploi", pmi: "PMI", policy_rate: "Taux directeur", cb_speech: "Discours BC", inflation: "Inflation", 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 = { USD: "us", EUR: "eu", GBP: "gb", JPY: "jp", CAD: "ca", AUD: "au", NZD: "nz", CHF: "ch", 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 { return new Date(iso).toISOString().slice(0, 10); } function fmtDate(iso: string): { day: string; time: string } { const d = new Date(iso); return { day: d.toLocaleDateString("fr-FR", { weekday: "short", day: "2-digit", month: "short" }), time: d.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" }), }; } function fmtDayLabel(dateStr: string): string { return new Date(dateStr + "T12:00:00").toLocaleDateString("fr-FR", { weekday: "long", day: "numeric", month: "long", }); } function todayIso(): string { return new Date().toISOString().slice(0, 10); } function nextMonday(): Date { const now = new Date(); const d = new Date(now); d.setDate(now.getDate() + (now.getDay() === 0 ? 1 : 8 - now.getDay())); d.setHours(0, 0, 0, 0); return d; } function getWeekBounds() { const nm = nextMonday(); const prevStart = new Date(nm); prevStart.setDate(nm.getDate() - 14); const prevEnd = new Date(nm); prevEnd.setDate(nm.getDate() - 8); const currentStart = new Date(nm); currentStart.setDate(nm.getDate() - 7); const currentEnd = new Date(nm); currentEnd.setDate(nm.getDate() - 1); const nextEnd = new Date(nm); nextEnd.setDate(nm.getDate() + 6); const next2Start = new Date(nm); next2Start.setDate(nm.getDate() + 7); const next2End = new Date(nm); next2End.setDate(nm.getDate() + 13); const fmt = (d: Date) => d.toLocaleDateString("fr-FR", { day: "numeric", month: "short" }); return { prevWeekLabel: `${fmt(prevStart)} – ${fmt(prevEnd)}`, currentWeekLabel: `${fmt(currentStart)} – ${fmt(currentEnd)}`, nextWeekLabel: `${fmt(nm)} – ${fmt(nextEnd)}`, next2WeekLabel: `${fmt(next2Start)} – ${fmt(next2End)}`, next2StartLabel: fmt(next2Start), nextMondayIso: nm.toISOString().slice(0, 10), }; } // ── Impact dot ──────────────────────────────────────────────────────────────── function ImpactDot({ impact }: { impact: string }) { const cls = impact === "high" ? "bg-red-500" : impact === "medium" ? "bg-amber-400" : "bg-slate-600"; return ; } // ── Event row ───────────────────────────────────────────────────────────────── function EventRow({ ev, isChild, expanded, onToggle }: { ev: CalendarEvent; isChild: boolean; expanded: boolean; onToggle: () => void; }) { const { day, time } = fmtDate(ev.date); 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" : "border-l-2 border-l-transparent"; return (
{day}
{time}
{ev.currency} { (e.target as HTMLImageElement).style.display = "none"; }} />
{ev.currency}
{CATEGORY_LABELS[ev.category]}
{ev.previous ?? "—"} {ev.forecast ? {ev.forecast} : } {ev.actual ? {ev.actual} : } ); } // ── Main ────────────────────────────────────────────────────────────────────── type WeekTab = "prev" | "current" | "next" | "next2" | "all"; export default function CalendarTab({ events, loading, nextWeekAvail }: Props) { const [filterCcy, setFilterCcy] = useState("ALL"); const [expanded, setExpanded] = useState>(new Set()); const [showLow, setShowLow] = useState(false); const [weekTab, setWeekTab] = useState("all"); const [fromDate, setFromDate] = useState(todayIso()); const fromDateRef = useRef(null); const { prevWeekLabel, currentWeekLabel, nextWeekLabel, next2StartLabel, nextMondayIso } = useMemo(getWeekBounds, []); void nextMondayIso; const toggle = (groupKey: string) => setExpanded(prev => { const next = new Set(prev); next.has(groupKey) ? next.delete(groupKey) : next.add(groupKey); return next; }); const filtered = useMemo(() => events.filter(ev => { if (filterCcy !== "ALL" && ev.currency !== filterCcy) return false; if (!showLow && ev.impact === "low") return false; if (ev.isGroupChild && ev.groupKey && !expanded.has(ev.groupKey)) return false; if (weekTab === "prev" && ev.week !== "prev") return false; if (weekTab === "current" && ev.week !== "current") return false; if (weekTab === "next" && ev.week !== "next") return false; if (weekTab === "next2" && ev.week !== "next2") return false; // Pour "semaine dernière", ne pas filtrer par fromDate (les events sont passés) if (weekTab !== "prev" && isoToLocalDate(ev.date) < fromDate) return false; 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 = {}; for (const ev of filtered) { const d = isoToLocalDate(ev.date); if (!dayMap[d]) { dayMap[d] = []; days.push(d); } dayMap[d].push(ev); } days.sort(); const countPrev = events.filter(e => e.week === "prev").length; const countCurrent = events.filter(e => e.week === "current").length; const countNext = events.filter(e => e.week === "next").length; const countNext2 = events.filter(e => e.week === "next2").length; return (
{/* Header */}

Calendrier économique

Sources : Trading Economics · investingLive · Banques centrales

{/* Onglets semaine */}
{([ ["all", "Tout", null, null], ["prev", "Sem. dernière", prevWeekLabel, countPrev], ["current", "Sem. en cours", currentWeekLabel, countCurrent], ["next", "Sem. prochaine", nextWeekLabel, countNext], ["next2", "Sem. +2 et +", `${next2StartLabel} et +`, countNext2], ] as [WeekTab, string, string | null, number | null][]).map(([tab, label, sub, count]) => { const isActive = weekTab === tab; const disabled = tab === "next" && !nextWeekAvail && countNext === 0; return ( ); })}
{/* Filtre devise + date */}
Depuis {/* 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. */} { 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" />
{availableCurrencies.map(ccy => ( ))}
{/* Table */} {loading ? (
) : filtered.length === 0 ? (

Aucun événement pour cette sélection

{weekTab === "next" && !nextWeekAvail && (

ForexFactory ne publie la semaine prochaine que du lundi au vendredi.
Données disponibles dans quelques heures.

)} {fromDate > todayIso() && ( )}
) : (
{(() => { const rows: React.ReactNode[] = []; let lastWeek: string | null = null; for (const day of days) { const dayEvents = dayMap[day]; if (!dayEvents?.length) continue; const w = dayEvents[0].week; // Séparateur semaine if (weekTab === "all" && w !== lastWeek) { lastWeek = w; const weekBanners: Record = { prev: `Semaine dernière — ${prevWeekLabel}`, current: `Semaine en cours — ${currentWeekLabel}`, next: `Semaine prochaine — ${nextWeekLabel}`, next2: `À partir du ${next2StartLabel} — réunions BC + données`, }; const isNext2 = w === "next2"; const isPrev = w === "prev"; const bannerCls = isPrev ? "bg-slate-700/30" : isNext2 ? "bg-amber-500/15" : "bg-indigo-500/15"; const textCls = isPrev ? "text-slate-500" : isNext2 ? "text-amber-400" : "text-indigo-400"; rows.push( ); } // Séparateur jour rows.push( ); for (const ev of dayEvents) { rows.push( ev.groupKey && toggle(ev.groupKey)} /> ); } } return rows; })()}
Date / Heure Devise Événement Précédent Prévision Actuel Impact
📅 {weekBanners[w] ?? w}
{fmtDayLabel(day)}
)} {/* Legend */}
Impact élevé Impact moyen · Cliquer sur une ligne groupée pour voir les sous-indicateurs · Prévision = consensus marché avant publication
); }