// Tempo — Economic Calendar — live via Cloudflare Worker proxy → FairEconomy/ForexFactory JSON

const FLAGS = { US: '🇺🇸', JP: '🇯🇵', DE: '🇩🇪', EU: '🇪🇺', AU: '🇦🇺', UK: '🇬🇧', CA: '🇨🇦', CH: '🇨🇭', NZ: '🇳🇿', GB: '🇬🇧' };
// Semantic impact colors mapped to Tempo CSS vars (with safe fallbacks for
// the amber/yellow tones that aren't part of the core DS). Defining them as
// vars keeps the calendar legible in both light and dark themes.
const IMPACT_COLORS = {
  high:   { dot: 'var(--red)', text: 'var(--red-text)', soft: 'var(--red-soft)' },
  medium: { dot: 'var(--amber, #f59e0b)', text: 'var(--amber-text, #b45309)', soft: 'var(--amber-soft, rgba(245,158,11,0.10))' },
  low:    { dot: 'var(--yellow, #facc15)', text: 'var(--yellow-text, #a16207)', soft: 'var(--yellow-soft, rgba(250,204,21,0.10))' },
};

// Map FairEconomy / ForexFactory currency code → flag code used in the FLAGS map
const CUR_TO_FLAG = {
  USD: 'US', EUR: 'EU', GBP: 'GB', JPY: 'JP', AUD: 'AU', CAD: 'CA', CHF: 'CH', NZD: 'NZ', CNY: 'US',
};

// Small static fallback so the UI never crashes if the proxy is offline
const ECON_FALLBACK = [
  { id: 'fb-1', d: '', time: '', cur: 'USD', flag: 'US', impact: 'medium', title: 'Proxy non configuré — voir guide', actual: null, forecast: null, prev: null },
];

// localStorage cache key. Le TTL (10 min) ne sert QU'À décider d'un refresh en
// arrière-plan : un cache périmé n'est JAMAIS jeté, il est affiché immédiatement
// avec un flag `stale`. Rétention maximale : 7 jours.
const ECON_CACHE_KEY = 'tempo:econCache';
const ECON_CACHE_TTL_MS = 10 * 60 * 1000;
const ECON_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
// Default proxy URL — shared Cloudflare Worker, live for ALL users out of the box.
// Power users can still override via localStorage 'tempo:econProxy' or window.TEMPO_ECON_PROXY.
const ECON_PROXY_DEFAULT = 'https://tempo-econ-proxy.guillaume-briere92.workers.dev';

function getEconProxyURL() {
  try {
    const stored = (typeof localStorage !== 'undefined') ? localStorage.getItem('tempo:econProxy') : null;
    if (stored && /^https?:\/\//i.test(stored)) return stored.replace(/\/$/, '');
  } catch (e) {}
  if (typeof window !== 'undefined' && typeof window.TEMPO_ECON_PROXY === 'string' && /^https?:\/\//i.test(window.TEMPO_ECON_PROXY)) {
    return window.TEMPO_ECON_PROXY.replace(/\/$/, '');
  }
  return ECON_PROXY_DEFAULT;
}

function isEconProxyConfigured() {
  const url = getEconProxyURL();
  return !!url && !/YOUR-SUBDOMAIN/.test(url);
}

function normalizeImpact(s) {
  const v = String(s || '').toLowerCase();
  if (v.startsWith('high'))   return 'high';
  if (v.startsWith('med'))    return 'medium';
  if (v.startsWith('low'))    return 'low';
  return 'low'; // "holiday" / "non-economic" / unknown → low
}

// Fuseau du profil (window.__appTz, fallback Europe/Paris) pour afficher/grouper
// les événements dans l'heure vécue par l'user. Self-consistent avec isoFor().
function econTz() {
  return (typeof window !== 'undefined' && window.__appTz) || 'Europe/Paris';
}

function parseFFDate(iso) {
  if (!iso) return { d: '', time: '' };
  const dt = new Date(iso);
  if (isNaN(dt.getTime())) return { d: '', time: '' };
  // Jour + heure projetés dans le fuseau du profil (helpers format-prefs).
  const tz = econTz();
  const day = (typeof localDayInTz === 'function')
    ? localDayInTz(dt, tz)
    : `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
  const time = (typeof tzTimeIn === 'function')
    ? tzTimeIn(dt, tz)
    : `${String(dt.getHours()).padStart(2, '0')}:${String(dt.getMinutes()).padStart(2, '0')}`;
  return { d: day, time, ts: dt.getTime() };
}

// Slug stable dérivé du titre (id d'événement) : minuscules, alphanumérique.
function econSlug(s) {
  return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60);
}

// `seen` (Map partagée sur un même chargement) compte les occurrences d'une même
// clé jour+heure+devise+titre pour suffixer les doublons exacts (deux discours du
// même intervenant à la même heure). L'id est STABLE d'un refresh à l'autre :
// il ne dépend plus de l'indice dans le flux brut (une insertion ForexFactory en
// cours de semaine décalait tous les ids suivants → notes « Plan trade » perdues).
function mapFFItem(raw, seen) {
  const { d, time, ts } = parseFFDate(raw.date);
  const cur = String(raw.country || raw.currency || '').toUpperCase();
  const flag = CUR_TO_FLAG[cur] || cur.slice(0, 2);
  const isSpeech = /speech|speaks|testimony/i.test(raw.title || '');
  const base = `${d}-${time}-${cur}-${econSlug(raw.title)}`;
  const n = seen.get(base) || 0;
  seen.set(base, n + 1);
  return {
    id: n ? `${base}~${n}` : base,
    ts,
    d, time, cur, flag,
    impact: normalizeImpact(raw.impact),
    title: raw.title || '—',
    actual: (raw.actual === '' || raw.actual == null) ? null : raw.actual,
    forecast: (raw.forecast === '' || raw.forecast == null) ? null : raw.forecast,
    prev: (raw.previous === '' || raw.previous == null) ? null : raw.previous,
    isSpeech,
  };
}

// Devise → nom de pays/zone en français (pour l'encart détails).
const CUR_TO_COUNTRY = {
  USD: 'États-Unis', EUR: 'Zone euro', GBP: 'Royaume-Uni', JPY: 'Japon',
  AUD: 'Australie', CAD: 'Canada', CHF: 'Suisse', NZD: 'Nouvelle-Zélande', CNY: 'Chine',
};

const IMPACT_LABEL = { high: 'Élevé', medium: 'Moyen', low: 'Faible' };

// Explication courte par TYPE d'événement (détection par mots-clés sur le titre
// anglais de ForexFactory). Tombe sur un libellé générique si rien ne matche.
// Explications par type d'événement. ORDRE IMPORTANT : du plus spécifique au plus
// général (premier match gagne) — sinon "Core CPI" tomberait sur "CPI", etc.
const EVENT_EXPLAINERS = [
  // ── Inflation ──
  { re: /core pce|pce price/i, txt: "PCE / Core PCE : la mesure d'inflation PRÉFÉRÉE de la Fed. Au-dessus des attentes = pression haussière sur les taux, généralement haussier pour la devise." },
  { re: /core cpi/i, txt: "Inflation sous-jacente (hors alimentation et énergie), plus suivie que le CPI brut. Au-dessus des attentes = taux plus élevés anticipés, haussier pour la devise." },
  { re: /\bcpi\b|consumer price|inflation rate/i, txt: "Indice des prix à la consommation (inflation). Plus élevé que prévu = pression à la hausse sur les taux, souvent haussier pour la devise." },
  { re: /core ppi/i, txt: "Prix à la production sous-jacents : inflation côté entreprises, en amont du CPI." },
  { re: /\bppi\b|producer price/i, txt: "Prix à la production : indicateur avancé de l'inflation côté entreprises." },
  // ── Emploi ──
  { re: /average hourly earnings/i, txt: "Salaires horaires moyens : inflation des salaires. En hausse = pression inflationniste, généralement haussier pour la devise." },
  { re: /non[\s-]?farm|nonfarm|\bnfp\b/i, txt: "NFP : emplois créés hors agriculture (US), l'un des chiffres les plus volatils du mois. Au-dessus des attentes = marché du travail solide, généralement haussier pour la devise." },
  { re: /\badp\b/i, txt: "Emploi privé ADP : aperçu de l'emploi US publié avant le NFP. Sert d'indice avant le rapport officiel." },
  { re: /employment change|payroll/i, txt: "Variation de l'emploi. Au-dessus des attentes = économie qui embauche, généralement haussier pour la devise." },
  { re: /unemployment rate/i, txt: "Taux de chômage. Une baisse (sous les attentes) est habituellement haussière pour la devise." },
  { re: /jobless|unemployment claims|initial claims|continuing claims/i, txt: "Inscriptions hebdomadaires au chômage. MOINS de demandeurs que prévu = marché du travail solide = haussier pour la devise." },
  { re: /jolts|job openings/i, txt: "JOLTS : postes vacants. Beaucoup d'offres = marché du travail tendu, plutôt haussier pour la devise." },
  // ── Croissance / activité ──
  { re: /\bgdp\b|gross domestic/i, txt: "PIB : croissance de l'économie. Au-dessus des attentes = économie robuste, généralement haussier pour la devise." },
  { re: /core retail sales/i, txt: "Ventes au détail hors automobile : cœur de la consommation des ménages. Au-dessus des attentes = haussier." },
  { re: /retail sales/i, txt: "Ventes au détail : baromètre de la consommation. Au-dessus des attentes = haussier pour la devise." },
  { re: /ism|services pmi|manufacturing pmi|flash pmi|\bpmi\b|purchasing managers/i, txt: "PMI / ISM : enquête d'activité (>50 = expansion, <50 = contraction). Au-dessus des attentes = haussier pour la devise." },
  { re: /durable goods/i, txt: "Commandes de biens durables : investissement des entreprises (chiffre volatil). Au-dessus des attentes = haussier." },
  { re: /industrial production|manufacturing production/i, txt: "Production industrielle : volume produit par l'industrie. Au-dessus des attentes = haussier." },
  // ── Banques centrales ──
  { re: /rate decision|interest rate|rate statement|cash rate|bank rate|fed funds|federal funds|funds rate|official cash|refi rate/i, txt: "Décision de taux directeur : l'événement le plus puissant pour une devise. Le taux ET le ton du communiqué orientent fortement le marché." },
  { re: /press conference|conférence de presse/i, txt: "Conférence de presse de la banque centrale. Le ton (accommodant/restrictif) compte souvent plus que la décision elle-même." },
  { re: /economic projections|dot plot/i, txt: "Projections économiques / dot plot : trajectoire de taux anticipée par les membres. Très scruté, fort impact." },
  // Les interventions (…Speaks/Speech) AVANT la règle FOMC générique, sinon
  // "FOMC Member X Speaks" serait étiqueté "compte-rendu" au lieu d'"intervention".
  { re: /speaks|speech|testimony|testifies|governor/i, txt: "Intervention d'un responsable de banque centrale. Surveille le ton (accommodant = baissier devise / restrictif = haussier) plus que tout chiffre." },
  { re: /fomc|meeting minutes|monetary policy|account of the/i, txt: "Compte-rendu / réunion de banque centrale : le marché y cherche des indices sur la suite des taux." },
  // ── Externe / sentiment / divers ──
  { re: /trade balance|current account/i, txt: "Solde des échanges extérieurs. Un excédent supérieur aux attentes est généralement haussier pour la devise." },
  { re: /consumer confidence|cb consumer|consumer sentiment|michigan/i, txt: "Confiance des consommateurs : appétit de dépense des ménages. Au-dessus des attentes = signal positif pour la devise." },
  { re: /\bzew\b|\bifo\b|business climate/i, txt: "Climat des affaires / sentiment économique. Au-dessus des attentes = signal positif pour la devise." },
  { re: /building permits|housing starts|home sales|new home|existing home/i, txt: "Immobilier (permis / ventes) : santé du secteur résidentiel, sensible aux taux. Au-dessus des attentes = plutôt haussier." },
  { re: /crude oil|oil invent|\beia\b/i, txt: "Stocks de pétrole : impact direct sur le pétrole et les devises liées (ex. CAD). Stocks en hausse = baissier pour le pétrole." },
  { re: /bank holiday|holiday|férié/i, txt: "Jour férié bancaire : liquidité réduite sur cette devise, mouvements parfois erratiques. Prudence." },
];

function eventExplainer(e) {
  for (const x of EVENT_EXPLAINERS) if (x.re.test(e.title || '')) return x.txt;
  const imp = e.impact === 'high'
    ? "Publication à fort impact : volatilité forte attendue, prudence 5 min avant à 10 min après."
    : e.impact === 'medium'
      ? "Publication à impact modéré : la devise peut réagir si le chiffre s'écarte des attentes."
      : "Publication à faible impact : effet généralement limité sur la devise.";
  return `Chiffre ${e.cur} suivi par les marchés. ${imp}`;
}

// Conseil d'impact concret (fenêtre de volatilité).
function impactAdvice(impact) {
  if (impact === 'high') return "Volatilité forte — évite d'ouvrir une position 5 min avant et 10 min après.";
  if (impact === 'medium') return "Volatilité modérée — surveille un écart marqué vs prévision.";
  return "Impact faible — peu de mouvement attendu.";
}

// Parse un nombre depuis une valeur FF (ex "3.2%", "-0.1K", "250B").
function econNum(s) {
  if (s == null) return null;
  const m = (s + '').match(/(-?[\d.]+)/);
  return m ? parseFloat(m[1]) : null;
}

// Compare deux valeurs et renvoie {sign, label, color} pour un delta lisible.
function deltaInfo(a, b) {
  const na = econNum(a), nb = econNum(b);
  if (na == null || nb == null) return null;
  const diff = na - nb;
  if (Math.abs(diff) < 1e-9) return { color: 'var(--fg-3)', label: 'identique' };
  const up = diff > 0;
  const txt = (up ? '+' : '') + (Math.round(diff * 1000) / 1000);
  return { color: up ? 'var(--green)' : 'var(--red)', label: txt };
}

// Heure exacte (HH:MM — FF ne donne pas les secondes) + date longue FR, dans le
// fuseau du profil. On reconstruit les composantes via le jour/heure projetés.
function exactLocalDateTime(e) {
  if (!e.ts) return e.time || '—';
  const dt = new Date(e.ts);
  const tz = econTz();
  const days = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'];
  const mm = ['janv.', 'févr.', 'mars', 'avril', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.'];
  // Jour civil + heure dans le fuseau du profil.
  const dayISO = (typeof localDayInTz === 'function') ? localDayInTz(dt, tz) : null;
  const time = (typeof tzTimeIn === 'function') ? tzTimeIn(dt, tz) : `${String(dt.getHours()).padStart(2, '0')}:${String(dt.getMinutes()).padStart(2, '0')}`;
  if (dayISO && /^\d{4}-\d{2}-\d{2}$/.test(dayISO)) {
    const [y, mo, da] = dayISO.split('-').map(Number);
    // Date « ancre » à midi UTC pour lire le bon jour de semaine sans dérive de fuseau.
    const anchor = new Date(Date.UTC(y, mo - 1, da, 12));
    return `${days[anchor.getUTCDay()]} ${da} ${mm[mo - 1]} · ${time} (heure ${tz})`;
  }
  const hh = String(dt.getHours()).padStart(2, '0'), mn = String(dt.getMinutes()).padStart(2, '0');
  return `${days[dt.getDay()]} ${dt.getDate()} ${mm[dt.getMonth()]} · ${hh}:${mn} (heure locale)`;
}

// ── Notes "Plan trade" persistées en localStorage (léger, par événement) ──
const ECON_NOTES_KEY = 'tempo:econNotes';
function readEconNotes() {
  try {
    const raw = (typeof localStorage !== 'undefined') ? localStorage.getItem(ECON_NOTES_KEY) : null;
    const obj = raw ? JSON.parse(raw) : null;
    return (obj && typeof obj === 'object') ? obj : {};
  } catch (e) { return {}; }
}
function writeEconNote(id, text) {
  try {
    if (typeof localStorage === 'undefined') return;
    const all = readEconNotes();
    if (text && text.trim()) all[id] = text.trim(); else delete all[id];
    localStorage.setItem(ECON_NOTES_KEY, JSON.stringify(all));
  } catch (e) {}
}

// Purge des notes orphelines : une note dont l'id ne correspond plus à aucun
// événement du flux courant est conservée 7 jours (clé compagnon
// tempo:econNotesSeen = { id: dernier ts où l'id correspondait encore / date de
// première orphelinité }), puis supprimée — localStorage ne s'encombre plus.
const ECON_NOTES_SEEN_KEY = 'tempo:econNotesSeen';
const ECON_NOTES_ORPHAN_MS = 7 * 24 * 60 * 60 * 1000;
function purgeEconNotes(events) {
  try {
    if (typeof localStorage === 'undefined') return;
    const notes = readEconNotes();
    const ids = Object.keys(notes);
    let seen = {};
    try {
      const raw = localStorage.getItem(ECON_NOTES_SEEN_KEY);
      const obj = raw ? JSON.parse(raw) : null;
      if (obj && typeof obj === 'object') seen = obj;
    } catch (e) {}
    const current = new Set(events.map(e => e.id));
    const now = Date.now();
    const nextSeen = {};
    let changed = false;
    for (const id of ids) {
      if (current.has(id)) { nextSeen[id] = now; continue; }
      const last = Number(seen[id]) || 0;
      if (last && now - last > ECON_NOTES_ORPHAN_MS) { delete notes[id]; changed = true; continue; }
      nextSeen[id] = last || now; // démarre l'horloge d'orphelinité
    }
    if (changed) localStorage.setItem(ECON_NOTES_KEY, JSON.stringify(notes));
    localStorage.setItem(ECON_NOTES_SEEN_KEY, JSON.stringify(nextSeen));
  } catch (e) {}
}

// "il y a 12 s" / "il y a 3 min" / "il y a 2 j" pour le tampon de dernière mise à jour.
function relativeAgo(fromDate, now) {
  const s = Math.max(0, Math.floor((now.getTime() - fromDate.getTime()) / 1000));
  if (s < 5) return "à l'instant";
  if (s < 60) return `il y a ${s} s`;
  const m = Math.floor(s / 60);
  if (m < 60) return `il y a ${m} min`;
  const h = Math.floor(m / 60);
  if (h < 24) return `il y a ${h} h`;
  return `il y a ${Math.floor(h / 24)} j`;
}

const FOREXFACTORY_CAL_URL = 'https://www.forexfactory.com/calendar';

// Timeout réseau : aucun fetch ne doit bloquer l'UI plus de 6 s.
const ECON_FETCH_TIMEOUT_MS = 6000;

// Signal d'abandon défensif : AbortSignal.timeout si dispo, sinon
// AbortController + setTimeout (et no-op si l'environnement n'a rien).
function econTimeoutSignal(ms) {
  try {
    if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
      return { signal: AbortSignal.timeout(ms), done: () => {} };
    }
    if (typeof AbortController !== 'undefined') {
      const ctrl = new AbortController();
      const id = setTimeout(() => ctrl.abort(), ms);
      return { signal: ctrl.signal, done: () => clearTimeout(id) };
    }
  } catch (e) {}
  return { signal: undefined, done: () => {} };
}

// Seule la semaine en cours est récupérée : le flux « nextweek » est mort
// côté FairEconomy (404 définitif) et a été retiré du client.
// Au plus 1 retry (transient 429 / cache edge froid).
// Renvoie { data, cachedAt, stale } : cachedAt lit l'en-tête x-tempo-cached-at
// du worker (l'ÂGE RÉEL de la donnée servie, pas l'heure du fetch — un worker
// qui sert du stale de 3 jours ne doit pas s'afficher « mis à jour à l'instant »),
// stale lit x-tempo-stale (données datées côté worker).
async function fetchEcon(attempt = 1) {
  const url = getEconProxyURL() + '/';
  const t = econTimeoutSignal(ECON_FETCH_TIMEOUT_MS);
  try {
    const res = await fetch(url, { credentials: 'omit', mode: 'cors', signal: t.signal });
    if (!res.ok) throw new Error('HTTP ' + res.status); // e.g. 429 upstream rate-limit
    const data = await res.json(); // throws if the body is an HTML error page
    if (!Array.isArray(data)) throw new Error('Réponse invalide');
    let cachedAt = null, stale = false;
    try {
      const at = parseInt(res.headers.get('x-tempo-cached-at') || '', 10);
      if (Number.isFinite(at) && at > 0) cachedAt = at;
      stale = res.headers.get('x-tempo-stale') === '1';
    } catch (e) {}
    return { data, cachedAt: cachedAt || Date.now(), stale };
  } catch (e) {
    if (attempt < 2) {
      await new Promise(r => setTimeout(r, 1200 + Math.random() * 800));
      return fetchEcon(attempt + 1);
    }
    throw e;
  } finally {
    t.done();
  }
}

// Ne jette JAMAIS un cache simplement périmé : on retourne toujours les derniers
// événements connus avec un flag `stale` (le TTL ne décide que du refresh en
// fond). Seul un cache invalide ou vieux de plus de 7 jours est écarté.
function readEconCache() {
  try {
    const raw = (typeof localStorage !== 'undefined') ? localStorage.getItem(ECON_CACHE_KEY) : null;
    if (!raw) return null;
    const obj = JSON.parse(raw);
    if (!obj || !obj.ts || !Array.isArray(obj.events)) return null;
    const age = Date.now() - obj.ts;
    if (age > ECON_CACHE_MAX_AGE_MS) return null;
    // `at` = horodatage x-tempo-cached-at du worker (âge réel de la donnée) ;
    // `srvStale` = le worker servait déjà du stale. Absents sur un vieux cache.
    return {
      ts: obj.ts,
      at: Number.isFinite(obj.at) ? obj.at : obj.ts,
      srvStale: !!obj.srvStale,
      events: obj.events,
      stale: age > ECON_CACHE_TTL_MS,
    };
  } catch (e) { return null; }
}

function writeEconCache(events, cachedAt, srvStale) {
  try {
    if (typeof localStorage === 'undefined') return;
    localStorage.setItem(ECON_CACHE_KEY, JSON.stringify({
      ts: Date.now(),
      at: Number.isFinite(cachedAt) ? cachedAt : Date.now(),
      srvStale: !!srvStale,
      events,
    }));
  } catch (e) {}
}

// `now` : horloge 30 s du composant (useEconClock) — sert aussi de tick de
// REVALIDATION : données plus vieilles que le TTL → refresh silencieux en fond
// (avec backoff en cas d'échecs répétés), idem au retour d'onglet
// (visibilitychange). Un refresh silencieux échoué n'est ainsi plus jamais
// définitif : l'onglet ouvert toute la journée se resynchronise tout seul.
function useEconomicEvents(now) {
  const configured = isEconProxyConfigured();
  // Rendu IMMÉDIAT depuis le cache, même périmé : les derniers événements
  // connus s'affichent toujours instantanément, le refresh se fait derrière.
  const [events, setEvents] = React.useState(() => readEconCache()?.events || ECON_FALLBACK);
  // lastUpdate = horodatage RÉEL de la donnée (x-tempo-cached-at du worker),
  // pas l'heure du dernier fetch réussi.
  const [lastUpdate, setLastUpdate] = React.useState(() => {
    const c = readEconCache();
    return c ? new Date(c.at || c.ts) : null;
  });
  // `stale` : le worker a signalé servir des données datées (x-tempo-stale).
  const [stale, setStale] = React.useState(() => {
    const c = readEconCache();
    return !!(c && c.srvStale);
  });
  const [loading, setLoading] = React.useState(false);
  const [error, setError] = React.useState('');
  const [tick, setTick] = React.useState(0);
  // Garde contre les setState après démontage (fetch en vol).
  const mountedRef = React.useRef(true);
  React.useEffect(() => () => { mountedRef.current = false; }, []);
  // Revalidation : fetch en vol, échecs consécutifs (backoff), dernière tentative,
  // miroir de lastUpdate (lisible sans re-souscrire les effets).
  const inFlightRef = React.useRef(false);
  const failsRef = React.useRef(0);
  const lastTryRef = React.useRef(0);
  const lastUpdateRef = React.useRef(lastUpdate ? lastUpdate.getTime() : 0);

  // Un fetch (semaine en cours uniquement). `silent` = refresh en arrière-plan :
  // ne touche ni le spinner ni la bannière d'erreur (UX stable). setEvents dès
  // que le flux répond — aucune attente d'un second feed.
  const load = React.useCallback(async (silent) => {
    if (silent && inFlightRef.current) return false; // déjà un refresh en vol
    inFlightRef.current = true;
    lastTryRef.current = Date.now();
    if (!silent) { setLoading(true); setError(''); }
    let res;
    try {
      res = await fetchEcon();
    } catch (e) {
      // Échec total (ex. 429 amont, timeout). On NE vide NI les events NI le
      // cache — les dernières données connues restent affichées.
      inFlightRef.current = false;
      failsRef.current += 1;
      if (!mountedRef.current) return false;
      console.warn('[Tempo] econ: source indisponible, conservation du cache.');
      if (!silent) { setError('unavailable'); setLoading(false); }
      return false;
    }
    inFlightRef.current = false;
    failsRef.current = 0;
    if (!mountedRef.current) return false;
    const seen = new Map();
    const out = res.data.map(r => mapFFItem(r, seen));
    out.sort((a, b) => (a.ts || 0) - (b.ts || 0));
    setEvents(out);
    const upd = new Date(res.cachedAt || Date.now());
    setLastUpdate(upd);
    lastUpdateRef.current = upd.getTime();
    setStale(!!res.stale);
    if (!silent) { setError(''); setLoading(false); }
    writeEconCache(out, res.cachedAt, res.stale);
    purgeEconNotes(out);
    return true;
  }, []);

  // Au montage : si le cache est encore frais (TTL), rien à faire ; s'il est
  // périmé, refresh SILENCIEUX derrière les données déjà affichées ; s'il n'y a
  // jamais eu de données, fetch visible (spinner). Le refresh manuel (tick > 0)
  // force toujours un fetch visible.
  React.useEffect(() => {
    if (!configured) return;
    if (tick === 0) {
      const c = readEconCache();
      if (c && !c.stale) return;
      load(!!c);
      return;
    }
    load(false);
  }, [tick, configured, load]);

  // Revalidation sur l'horloge 30 s : données plus vieilles que le TTL et pas de
  // fetch en vol → refresh silencieux. Backoff exponentiel après échec (1, 2,
  // 4… min, plafond 15 min) ; sinon re-tentative au rythme du TTL (cas d'un
  // worker qui sert du stale : inutile de marteler plus vite).
  React.useEffect(() => {
    if (!configured || !now) return;
    const nowMs = now.getTime();
    if (nowMs - (lastUpdateRef.current || 0) <= ECON_CACHE_TTL_MS) return;
    if (inFlightRef.current) return;
    const wait = failsRef.current > 0
      ? Math.min(15 * 60e3, 60e3 * Math.pow(2, failsRef.current - 1))
      : ECON_CACHE_TTL_MS;
    if (nowMs - (lastTryRef.current || 0) < wait) return;
    load(true);
  }, [now, configured, load]);

  // Retour sur l'onglet : si les données affichées ont dépassé le TTL pendant
  // l'absence, refresh silencieux immédiat.
  React.useEffect(() => {
    if (!configured || typeof document === 'undefined') return;
    const onVis = () => {
      if (document.visibilityState !== 'visible') return;
      if (inFlightRef.current) return;
      if (Date.now() - (lastUpdateRef.current || 0) <= ECON_CACHE_TTL_MS) return;
      load(true);
    };
    document.addEventListener('visibilitychange', onVis);
    return () => document.removeEventListener('visibilitychange', onVis);
  }, [configured, load]);

  const refresh = () => setTick(t => t + 1);

  return { events, lastUpdate, loading, error, stale, configured, refresh };
}

function ImpactDot({ impact, size = 8 }) {
  const c = IMPACT_COLORS[impact];
  return (
    <span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: size + 6, height: size + 6, borderRadius: '50%', background: c.soft }}>
      <span style={{ width: size, height: size, borderRadius: '50%', background: c.dot }}></span>
    </span>
  );
}

function CurrencyChip({ cur, flag }) {
  return (
    <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '3px 8px 3px 4px', borderRadius: 999, background: 'var(--bg-soft)', border: '1px solid var(--line)' }}>
      <span style={{ fontSize: 13, lineHeight: 1 }}>{FLAGS[flag] || '🌐'}</span>
      <span className="mono" style={{ fontSize: 10.5, fontWeight: 600 }}>{cur}</span>
    </span>
  );
}

// Le flux FairEconomy ne fournit structurellement PAS de champ « actual » :
// la colonne affiche « — » avec une infobulle honnête. La branche valeur est
// conservée par rétrocompatibilité si la source venait à en fournir un jour.
function ActualVsForecast({ actual, forecast }) {
  if (actual == null) return <span title="Valeur publiée non fournie par la source" style={{ color: 'var(--fg-3)', fontFamily: 'JetBrains Mono, monospace', fontSize: 12 }}>—</span>;
  const parse = (s) => { const m = (s + '').match(/(-?[\d.]+)/); return m ? parseFloat(m[1]) : null; };
  const a = parse(actual), f = parse(forecast);
  let color = 'var(--fg)';
  if (a != null && f != null) {
    if (a > f) color = 'var(--green)'; else if (a < f) color = 'var(--red)';
  }
  return <span className="mono" style={{ fontSize: 12.5, fontWeight: 600, color, letterSpacing: '-.005em' }}>{actual}</span>;
}

function NextEventBanner({ events, now }) {
  // Use the actual event timestamps to find the next high/medium impact event
  const nowMs = now.getTime();
  const upcoming = events
    .filter(e => e.ts && e.ts > nowMs && (e.impact === 'high' || e.impact === 'medium'))
    .sort((a, b) => a.ts - b.ts);
  const nextEvent = upcoming[0];
  if (!nextEvent) return null;
  const totalMin = Math.max(0, Math.floor((nextEvent.ts - nowMs) / 60000));
  const inHrs = Math.floor(totalMin / 60), inMin = totalMin % 60;
  return (
    <div style={{
      position: 'relative', overflow: 'hidden',
      background: 'var(--hero-grad, linear-gradient(135deg, #0f172a 0%, #1e293b 60%, #0c4a6e 100%))',
      borderRadius: 14, padding: '16px 22px', color: 'var(--on-accent, #fff)',
      display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16,
    }}>
      <div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 1, background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.20), transparent)', animation: 'shimmer 4s var(--ease) infinite' }}></div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
        <ImpactDot impact={nextEvent.impact} size={10}/>
        <div>
          <div style={{ fontSize: 11, fontWeight: 500, color: 'var(--on-accent-soft, rgba(255,255,255,0.55))', letterSpacing: '.06em', textTransform: 'uppercase', marginBottom: 4 }}>Prochain événement</div>
          <div style={{ fontSize: 16, fontWeight: 600, letterSpacing: '-.01em' }}>{nextEvent.title}</div>
        </div>
      </div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 22 }}>
        <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
          <span style={{ fontSize: 11, color: 'var(--on-accent-soft, rgba(255,255,255,0.55))', letterSpacing: '.04em' }}>DANS</span>
          <span className="mono" style={{ fontSize: 22, fontWeight: 700, letterSpacing: '-.018em', color: 'var(--on-accent-green, #86efac)' }}>
            {inHrs > 0 ? `${inHrs}h ${String(inMin).padStart(2, '0')}` : `${inMin} min`}
          </span>
        </div>
        <CurrencyChip cur={nextEvent.cur} flag={nextEvent.flag}/>
      </div>
    </div>
  );
}

// Petite ligne « libellé : valeur » réutilisée dans l'encart détails.
function FactRow({ label, children }) {
  return (
    <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, padding: '7px 0', borderBottom: '1px solid var(--line)' }}>
      <span style={{ fontSize: 11.5, color: 'var(--fg-3)', flexShrink: 0 }}>{label}</span>
      <span style={{ fontSize: 12.5, fontWeight: 500, textAlign: 'right' }}>{children}</span>
    </div>
  );
}

// Encart « Plan trade » : note d'intention locale, persistée en localStorage.
function TradePlanNote({ e, autoFocus, onSaved }) {
  const [text, setText] = React.useState(() => readEconNotes()[e.id] || '');
  const [saved, setSaved] = React.useState(false);
  const ref = React.useRef(null);
  React.useEffect(() => {
    if (autoFocus && ref.current) ref.current.focus();
  }, [autoFocus]);
  const save = () => {
    writeEconNote(e.id, text);
    setSaved(true);
    onSaved && onSaved();
    setTimeout(() => setSaved(false), 1600);
  };
  const clear = () => { setText(''); writeEconNote(e.id, ''); onSaved && onSaved(); };
  return (
    <div>
      <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--fg-3)', letterSpacing: '.06em', textTransform: 'uppercase', marginBottom: 8 }}>
        Mon plan pour cet événement
      </div>
      <textarea
        ref={ref}
        value={text}
        onChange={ev => { setText(ev.target.value); setSaved(false); }}
        onClick={ev => ev.stopPropagation()}
        placeholder="Biais, niveaux à surveiller, scénario si surprise haussière/baissière…"
        className="input"
        style={{ minHeight: 64, resize: 'vertical', lineHeight: 1.5 }}
      />
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 10 }}>
        <button onClick={ev => { ev.stopPropagation(); save(); }} className="btn btn-blue tap" style={{ fontSize: 12 }}>
          {Ico.check} Enregistrer
        </button>
        {text && (
          <button onClick={ev => { ev.stopPropagation(); clear(); }} className="btn tap" style={{ fontSize: 12 }}>
            Effacer
          </button>
        )}
        {saved && <span style={{ fontSize: 11.5, color: 'var(--green-text)', fontWeight: 500 }}>Enregistré ✓</span>}
      </div>
    </div>
  );
}

// Mémoïsé : avec ~100 lignes, chaque tick d'horloge re-rendait toute la liste.
// `isNow` est calculé par le parent (dérivé de l'heure) ; le comparateur ignore
// `onToggle` (closure recréée à chaque rendu mais au comportement stable :
// elle ne capture que e.id et un setState fonctionnel).
const EventRow = React.memo(function EventRow({ e, isPast, isNow, isExpanded, onToggle, planFocus }) {
  // Re-lecture de la note pour rafraîchir l'étoile sur le bouton après save.
  const [noteVer, setNoteVer] = React.useState(0);
  const hasNote = React.useMemo(() => !!readEconNotes()[e.id], [e.id, noteVer, isExpanded]);

  return (
    <React.Fragment>
      <div onClick={() => onToggle('details')} className="tap" style={{
        display: 'grid', gridTemplateColumns: '70px 24px 90px 1fr 90px 90px 90px 90px 28px',
        padding: '12px 20px', borderBottom: '1px solid var(--line)',
        alignItems: 'center', gap: 8, cursor: 'pointer',
        opacity: isPast ? .55 : 1,
        background: isNow ? 'var(--blue-soft-2, var(--blue-soft))' : 'var(--bg-card)',
        transition: 'background .4s var(--ease)',
        position: 'relative',
      }}>
        {isNow && <span style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 3, background: 'var(--blue)' }}></span>}
        <span className="mono" style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--fg-2)' }}>{e.time}</span>
        <ImpactDot impact={e.impact}/>
        <CurrencyChip cur={e.cur} flag={e.flag}/>
        <span style={{ fontSize: 13, fontWeight: 500, letterSpacing: '-.005em' }}>
          {e.isSpeech && <span style={{ marginRight: 6 }}>🗣</span>}
          {e.title}
        </span>
        <ActualVsForecast actual={e.actual} forecast={e.forecast}/>
        <span className="mono" style={{ fontSize: 12.5, color: 'var(--fg-2)', fontWeight: 500 }}>{e.forecast || '—'}</span>
        <span className="mono" style={{ fontSize: 12.5, color: 'var(--fg-3)' }}>{e.prev || '—'}</span>
        <span>
          {(e.impact === 'high' || e.impact === 'medium') && !isPast && (
            <button
              onClick={ev => { ev.stopPropagation(); onToggle('plan'); }}
              className="tap"
              title="Noter mon intention pour cet événement"
              style={{
                background: hasNote ? 'var(--blue-soft)' : 'transparent', color: 'var(--blue)',
                border: '1px solid var(--blue-border)', borderRadius: 7,
                padding: '3px 9px', fontSize: 11, fontWeight: 500,
                cursor: 'pointer', fontFamily: 'inherit',
              }}>{hasNote ? '★ Plan' : 'Plan trade'}</button>
          )}
        </span>
        <span style={{ display: 'flex', justifyContent: 'flex-end', color: 'var(--fg-3)', transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform .2s var(--ease)' }}>{Ico.chev}</span>
      </div>

      {isExpanded && (
        <div onClick={ev => ev.stopPropagation()} style={{ padding: '18px 40px 22px', background: 'var(--bg-soft)', borderBottom: '1px solid var(--line)' }}>
          <div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: 28 }}>
            {/* Colonne gauche : explication + plan trade */}
            <div>
              <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
                <span style={{ fontSize: 15, fontWeight: 600, letterSpacing: '-.01em' }}>
                  {e.isSpeech && <span style={{ marginRight: 6 }}>🗣</span>}{e.title}
                </span>
                <span className="pill" style={{ fontSize: 10.5, padding: '2px 8px', background: IMPACT_COLORS[e.impact].soft, color: IMPACT_COLORS[e.impact].text }}>
                  Impact {IMPACT_LABEL[e.impact]}
                </span>
              </div>

              <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--fg-3)', letterSpacing: '.06em', textTransform: 'uppercase', marginBottom: 6 }}>De quoi s'agit-il ?</div>
              <p style={{ fontSize: 13, lineHeight: 1.55, color: 'var(--fg-2)', margin: '0 0 8px' }}>{eventExplainer(e)}</p>
              <p style={{ fontSize: 12.5, lineHeight: 1.5, color: 'var(--fg-3)', margin: '0 0 18px' }}>{impactAdvice(e.impact)}</p>

              <TradePlanNote e={e} autoFocus={planFocus} onSaved={() => setNoteVer(v => v + 1)}/>

              <div style={{ marginTop: 16 }}>
                <a href={FOREXFACTORY_CAL_URL} target="_blank" rel="noopener noreferrer" onClick={ev => ev.stopPropagation()} className="btn tap" style={{ fontSize: 12, textDecoration: 'none' }}>
                  Voir sur ForexFactory ↗
                </a>
              </div>
            </div>

            {/* Colonne droite : faits chiffrés */}
            <div style={{ background: 'var(--bg-card)', border: '1px solid var(--line)', borderRadius: 10, padding: '14px 16px' }}>
              <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--fg-3)', letterSpacing: '.06em', textTransform: 'uppercase', marginBottom: 8 }}>Détails</div>
              <FactRow label="Quand">
                <span style={{ color: isPast ? 'var(--fg-3)' : 'var(--fg)' }}>{exactLocalDateTime(e)}</span>
              </FactRow>
              <FactRow label="Pays / devise">
                <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
                  <span>{FLAGS[e.flag] || '🌐'}</span>
                  {(CUR_TO_COUNTRY[e.cur] || e.cur)} <span className="mono" style={{ color: 'var(--fg-3)', fontSize: 11.5 }}>({e.cur})</span>
                </span>
              </FactRow>
              <FactRow label="Actuel">
                {e.actual != null
                  ? <ActualVsForecast actual={e.actual} forecast={e.forecast}/>
                  : <span title="Valeur publiée non fournie par la source" style={{ color: 'var(--fg-3)', fontSize: 11.5 }}>non fournie par la source</span>}
              </FactRow>
              <FactRow label="Prévision">
                <span className="mono" style={{ color: 'var(--fg-2)' }}>{e.forecast || '—'}</span>
              </FactRow>
              <FactRow label="Précédent">
                <span className="mono" style={{ color: 'var(--fg-3)' }}>{e.prev || '—'}</span>
              </FactRow>
              {(() => {
                const dF = deltaInfo(e.actual, e.forecast);
                const dP = deltaInfo(e.actual, e.prev);
                if (!dF && !dP) return null;
                return (
                  <div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 6 }}>
                    {dF && <div style={{ fontSize: 11.5, color: 'var(--fg-3)' }}>vs prévision : <span className="mono" style={{ color: dF.color, fontWeight: 600 }}>{dF.label}</span></div>}
                    {dP && <div style={{ fontSize: 11.5, color: 'var(--fg-3)' }}>vs précédent : <span className="mono" style={{ color: dP.color, fontWeight: 600 }}>{dP.label}</span></div>}
                  </div>
                );
              })()}
            </div>
          </div>
        </div>
      )}
    </React.Fragment>
  );
}, (prev, next) =>
  prev.e === next.e &&
  prev.isPast === next.isPast &&
  prev.isNow === next.isNow &&
  prev.isExpanded === next.isExpanded &&
  prev.planFocus === next.planFocus
);

// Horloge locale au calendrier, tick 30 s : les seuls affichages dérivés sont
// à la minute (compte à rebours, « il y a X », passé/à venir) — inutile de
// re-rendre chaque seconde comme le useLiveTime global (1 s) des autres pages.
function useEconClock() {
  const [t, setT] = React.useState(() => new Date());
  React.useEffect(() => {
    const id = setInterval(() => setT(new Date()), 30 * 1000);
    return () => clearInterval(id);
  }, []);
  return t;
}

function EconomicCalendar({ nav }) {
  const now = useEconClock();
  const [impact, setImpact] = React.useState('all');
  const [currencies, setCurrencies] = React.useState([]);
  const [dateFilter, setDateFilter] = React.useState('today');
  // { id, mode } — mode 'plan' = ouvert via « Plan trade » (focus la note).
  const [expanded, setExpanded] = React.useState(null);
  const { events: allEvents, lastUpdate, loading, error, stale, configured, refresh } = useEconomicEvents(now);

  // Ouvre/replie une ligne. Si déjà ouverte sur le même id, re-cliquer la replie ;
  // sinon on (ré)ouvre dans le mode demandé ('details' ou 'plan').
  const toggleRow = (id, mode) => setExpanded(prev =>
    prev && prev.id === id && prev.mode === mode ? null : { id, mode }
  );

  // ── ISO date helpers basés sur le fuseau du profil (cohérent avec parseFFDate) ──
  const isoFor = (offsetDays = 0) => {
    const d = new Date();
    d.setDate(d.getDate() + offsetDays);
    const tz = econTz();
    if (typeof localDayInTz === 'function') {
      try { return localDayInTz(d, tz); } catch (e) { /* fallback */ }
    }
    const y = d.getFullYear(), m = String(d.getMonth() + 1).padStart(2, '0'), dd = String(d.getDate()).padStart(2, '0');
    return `${y}-${m}-${dd}`;
  };
  const todayISO = isoFor(0);
  const tomorrowISO = isoFor(1);

  // Count of genuine events (the offline fallback stub has an empty date `d`).
  const realCount = allEvents.filter(e => e.d).length;

  // Les événements SANS date (stub hors-ligne ECON_FALLBACK) ne sont jamais
  // rendus comme des lignes : en vue « Cette semaine », le stub apparaissait en
  // contradiction avec la pastille « Hors-ligne » — le placeholder
  // « Calendrier momentanément indisponible » prend le relais (realCount === 0).
  let filtered = allEvents.filter(e => e.d);
  if (impact !== 'all') filtered = filtered.filter(e => e.impact === impact);
  if (currencies.length) filtered = filtered.filter(e => currencies.includes(e.cur));
  if (dateFilter === 'today') filtered = filtered.filter(e => e.d === todayISO);
  if (dateFilter === 'tomorrow') filtered = filtered.filter(e => e.d === tomorrowISO);

  const DAYS = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi'];
  const MONTHS = ['janv.', 'févr.', 'mars', 'avril', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.'];
  const dayLabel = (d) => {
    if (!d) return '—';
    if (d === todayISO) {
      const dt = new Date(d + 'T00:00:00');
      return `Aujourd'hui · ${DAYS[dt.getDay()]} ${dt.getDate()} ${MONTHS[dt.getMonth()]}`;
    }
    if (d === tomorrowISO) {
      const dt = new Date(d + 'T00:00:00');
      return `Demain · ${DAYS[dt.getDay()]} ${dt.getDate()} ${MONTHS[dt.getMonth()]}`;
    }
    const dt = new Date(d + 'T00:00:00');
    if (isNaN(dt.getTime())) return d;
    return `${DAYS[dt.getDay()]} ${dt.getDate()} ${MONTHS[dt.getMonth()]}`;
  };
  const groupByDay = filtered.reduce((acc, e) => { (acc[e.d] = acc[e.d] || []).push(e); return acc; }, {});
  const dayKeys = Object.keys(groupByDay).sort();

  const toggleCurrency = (c) => setCurrencies(prev => prev.includes(c) ? prev.filter(x => x !== c) : [...prev, c]);

  const isPast = (e) => {
    if (!e.ts) return false;
    return e.ts < now.getTime();
  };
  // « En cours » : dans une fenêtre de ±5 min autour de l'heure de publication.
  const isNowEvent = (e) => !isPast(e) && !!e.ts && Math.abs(e.ts - now.getTime()) <= 5 * 60 * 1000;

  return (
    <div className="scroll" style={{ width: '100%', height: '100%', overflow: 'auto' }}>
      <PageHeader title="Calendrier économique"/>
      <div style={{ padding: '20px 24px 32px' }}>
        <div style={{ marginBottom: 16 }}>
          <NextEventBanner events={allEvents} now={now}/>
        </div>

        {/* Toolbar */}
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 14, flexWrap: 'wrap' }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
            <div style={{ display: 'inline-flex', gap: 4, padding: 4, background: 'var(--bg-elev)', borderRadius: 9 }}>
              {[
                { id: 'today',    l: "Aujourd'hui" },
                { id: 'tomorrow', l: 'Demain' },
                { id: 'week',     l: 'Cette semaine' },
              ].map(t => (
                <button key={t.id} onClick={() => setDateFilter(t.id)} className="tap" style={{
                  background: dateFilter === t.id ? 'var(--bg-card)' : 'transparent',
                  color: dateFilter === t.id ? 'var(--blue)' : 'var(--fg-2)',
                  border: dateFilter === t.id ? '1.5px solid var(--blue)' : '1.5px solid transparent',
                  borderRadius: 6, padding: '5px 14px', fontSize: 12.5, fontWeight: dateFilter === t.id ? 600 : 500,
                  cursor: 'pointer', fontFamily: 'inherit',
                }}>{t.l}</button>
              ))}
            </div>
          </div>

          <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
            <span style={{ fontSize: 11, color: 'var(--fg-3)' }}>Impact</span>
            <div style={{ display: 'inline-flex', gap: 4 }}>
              {[
                { id: 'all',  l: 'Tous',   color: null },
                { id: 'high', l: 'Élevé',  color: 'var(--red)' },
                { id: 'medium', l: 'Moyen', color: 'var(--amber, #f59e0b)' },
                { id: 'low',  l: 'Faible', color: 'var(--yellow, #facc15)' },
              ].map(b => (
                <button key={b.id} onClick={() => setImpact(b.id)} className="tap" style={{
                  padding: '5px 11px', borderRadius: 7,
                  background: impact === b.id ? 'var(--blue-soft)' : 'var(--bg-card)',
                  border: '1px solid ' + (impact === b.id ? 'var(--blue)' : 'var(--line)'),
                  color: impact === b.id ? 'var(--blue)' : 'var(--fg-2)',
                  fontSize: 11.5, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
                  display: 'inline-flex', alignItems: 'center', gap: 6,
                }}>
                  {b.color && <span style={{ width: 7, height: 7, borderRadius: '50%', background: b.color }}></span>}
                  {b.l}
                </button>
              ))}
            </div>
          </div>

          <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
            <span style={{ fontSize: 11, color: 'var(--fg-3)' }}>Devises</span>
            <div style={{ display: 'inline-flex', gap: 4 }}>
              {['USD', 'EUR', 'GBP', 'JPY', 'AUD'].map(c => {
                const on = currencies.includes(c);
                return (
                  <button key={c} onClick={() => toggleCurrency(c)} className="tap" style={{
                    padding: '5px 10px', borderRadius: 7,
                    background: on ? 'var(--blue-soft)' : 'var(--bg-card)',
                    border: '1px solid ' + (on ? 'var(--blue)' : 'var(--line)'),
                    color: on ? 'var(--blue)' : 'var(--fg-2)',
                    fontSize: 11, fontWeight: 600, cursor: 'pointer',
                    fontFamily: 'JetBrains Mono, monospace',
                  }}>{c}</button>
                );
              })}
            </div>
          </div>

          <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 'auto' }}>
            {configured ? (
              /* 3 états : vert « Sync ForexFactory » (frais), ambre « Données
                 datées » (le worker signale du stale via x-tempo-stale), gris
                 « Hors-ligne » (échec du dernier fetch visible). */
              <span
                className={'pill ' + (error ? 'pill-gray' : (stale ? '' : 'pill-green'))}
                style={{
                  fontSize: 10.5, padding: '3px 9px',
                  ...((stale && !error) ? { background: 'var(--amber-soft, rgba(245,158,11,0.10))', color: 'var(--amber-text, #b45309)' } : {}),
                }}>
                <span style={{ width: 5, height: 5, borderRadius: '50%', background: error ? 'var(--fg-3)' : (stale ? 'var(--amber, #f59e0b)' : 'var(--green)'), animation: (error || stale) ? 'none' : 'pulse-dot 2s var(--ease) infinite' }}></span>
                {loading ? 'Sync…' : (error ? 'Hors-ligne' : (stale ? 'Données datées' : 'Sync ForexFactory'))}
              </span>
            ) : (
              <span className="pill pill-gray" style={{ fontSize: 10.5, padding: '3px 9px' }}>Non configuré</span>
            )}
            {lastUpdate && (
              <span
                style={{ fontSize: 10.5, color: 'var(--fg-3)' }}
                title={`Dernière synchro à ${String(lastUpdate.getHours()).padStart(2, '0')}:${String(lastUpdate.getMinutes()).padStart(2, '0')}:${String(lastUpdate.getSeconds()).padStart(2, '0')}`}
              >
                Mis à jour {relativeAgo(lastUpdate, now)}
              </span>
            )}
            <button onClick={refresh} className="btn tap" disabled={loading || !configured} style={{ fontSize: 11, padding: '4px 10px', opacity: (loading || !configured) ? .5 : 1 }} title="Re-fetch ForexFactory">
              {Ico.refresh || null} Actualiser
            </button>
          </div>
        </div>

        {/* Setup / error banners */}
        {!configured && (
          <div style={{
            marginBottom: 14, padding: '14px 16px', borderRadius: 10,
            background: 'var(--blue-soft)', border: '1px solid var(--blue-border)',
            display: 'flex', alignItems: 'flex-start', gap: 12,
          }}>
            <div style={{ width: 28, height: 28, borderRadius: 8, background: 'var(--bg-card)', color: 'var(--blue)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{Ico.bell}</div>
            <div style={{ fontSize: 12.5, color: 'var(--fg)', lineHeight: 1.5 }}>
              <b>Calendrier économique non configuré.</b> Pour activer la sync live ForexFactory, déploie le Worker Cloudflare <span className="mono" style={{ fontSize: 11.5 }}>cloudflare-worker-proxy.js</span>, puis colle son URL dans la console :
              <div className="mono" style={{ marginTop: 6, fontSize: 11.5, background: 'var(--bg-card)', padding: '6px 10px', borderRadius: 6, border: '1px solid var(--blue-border)', color: 'var(--fg-2)' }}>
                {"localStorage.setItem('tempo:econProxy', 'https://tempo-econ-proxy.<ton-subdomain>.workers.dev')"}
              </div>
              <div style={{ marginTop: 6, fontSize: 11.5, color: 'var(--fg-2)' }}>Guide complet → <span className="mono">WORKER_SETUP.md</span></div>
            </div>
          </div>
        )}
        {configured && error && (
          <div style={{
            marginBottom: 14, padding: '10px 14px', borderRadius: 10,
            background: 'var(--amber-soft, #fff7ed)', border: '1px solid var(--amber-border, #fed7aa)',
            fontSize: 12.5, color: 'var(--amber-text, #b45309)',
          }}>
            Calendrier temporairement indisponible (ForexFactory surchargé). Les derniers
            événements connus restent affichés — clique « Actualiser » dans quelques minutes.
          </div>
        )}

        <div className="card" style={{ padding: 0, overflow: 'hidden' }}>
          {dayKeys.length === 0 && (
            <div style={{ padding: '60px 20px', textAlign: 'center', color: 'var(--fg-3)', lineHeight: 1.6 }}>
              {(loading && realCount === 0)
                ? 'Chargement du calendrier…'
                : !configured
                  ? 'Configure le proxy pour voir les événements live.'
                  : error
                    ? 'Calendrier momentanément indisponible. Réessaie dans quelques minutes.'
                    : (dateFilter === 'today' && realCount > 0)
                      ? <>Aucun événement aujourd'hui avec ces filtres.<br/><span style={{ fontSize: 12 }}>Essaie <b>Demain</b> ou <b>Cette semaine</b>.</span></>
                      : 'Aucun événement avec ces filtres.'}
            </div>
          )}
          {dayKeys.map((dk, di) => {
            const list = groupByDay[dk];
            return (
              <div key={dk}>
                <div style={{
                  padding: '12px 20px', background: 'var(--bg-soft)',
                  borderTop: di > 0 ? '1px solid var(--line)' : 'none',
                  borderBottom: '1px solid var(--line)',
                  display: 'flex', alignItems: 'center', justifyContent: 'space-between',
                }}>
                  <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
                    <span style={{ fontSize: 13, fontWeight: 600 }}>{dayLabel(dk)}</span>
                    <span className="mono" style={{ fontSize: 11, color: 'var(--fg-3)' }}>{list.length} événements</span>
                  </div>
                  <div style={{ display: 'flex', gap: 16 }}>
                    <span style={{ fontSize: 11, color: 'var(--fg-3)' }}>
                      <span style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--red)', display: 'inline-block', marginRight: 5 }}></span>
                      {list.filter(e => e.impact === 'high').length} élevés
                    </span>
                    <span style={{ fontSize: 11, color: 'var(--fg-3)' }}>
                      <span style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--amber, #f59e0b)', display: 'inline-block', marginRight: 5 }}></span>
                      {list.filter(e => e.impact === 'medium').length} moyens
                    </span>
                  </div>
                </div>

                <div style={{ display: 'grid', gridTemplateColumns: '70px 24px 90px 1fr 90px 90px 90px 90px 28px', padding: '10px 20px', borderBottom: '1px solid var(--line)', background: 'var(--bg-card)', gap: 8 }}>
                  {['TIME', '', 'CUR', 'EVENT', 'ACTUAL', 'FORECAST', 'PREVIOUS', 'TRADE', ''].map((h, i) => (
                    <span key={i} style={{ fontSize: 10, fontWeight: 600, color: 'var(--fg-3)', letterSpacing: '.06em' }}>{h}</span>
                  ))}
                </div>

                {list.map(e => (
                  <EventRow
                    key={e.id}
                    e={e}
                    isPast={isPast(e)}
                    isNow={isNowEvent(e)}
                    isExpanded={!!expanded && expanded.id === e.id}
                    planFocus={!!expanded && expanded.id === e.id && expanded.mode === 'plan'}
                    onToggle={(mode) => toggleRow(e.id, mode)}
                  />
                ))}
              </div>
            );
          })}
        </div>

        <p style={{ fontSize: 11, color: 'var(--fg-3)', marginTop: 14, textAlign: 'center' }}>
          Données <a href={FOREXFACTORY_CAL_URL} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--blue)', textDecoration: 'none' }}>ForexFactory</a> · semaine en cours (heures, prévisions et valeurs précédentes) — la source ne fournit pas les valeurs publiées : consulte ForexFactory pour les chiffres en direct
        </p>
      </div>
    </div>
  );
}

Object.assign(window, { EconomicCalendar });
