// Tempo — Calendrier économique — live via le Worker Cloudflare proxy.
// Source PRINCIPALE : route /cal2 du worker (calendrier TradingView, 2 semaines,
// AVEC valeurs publiées « actual »). REPLI automatique : route / historique
// (FairEconomy/ForexFactory, semaine en cours, sans actual) si /cal2 est
// indisponible OU si l'ancien worker est encore déployé (il répond à /cal2
// avec la MÊME payload FairEconomy que / — détection par la forme des items,
// jamais par le code HTTP). Rétrocompatibilité totale dans les deux sens.
//
// Affichage : tout nombre, date, heure et titre passe par les formateurs de
// design/format-prefs.jsx (fmtNum, fmtPct, fmtDayLong, tzTimeIn, fmtDuration…)
// au moment du RENDU. Les événements stockés gardent des nombres bruts : un
// ancien cache (valeurs « 2.701% » déjà mises en forme) est relu et reformaté.

// Couleurs d'impact : rouge (élevé), orange (moyen), gris (faible) — trois
// teintes nettement distinctes, même à 7 px et sur une ligne passée (.55).
const IMPACT_COLORS = {
  high:   { dot: 'var(--red)',    text: 'var(--red-text)',   soft: 'var(--red-soft)' },
  medium: { dot: 'var(--orange)', text: 'var(--amber-text)', soft: 'var(--amber-soft)' },
  low:    { dot: 'var(--fg-3)',   text: 'var(--fg-2)',       soft: 'var(--bg-elev)' },
};

// Devise → code pays ISO2 (pour le nom du pays dans l'encart détails).
const ECON_CUR_COUNTRY = {
  USD: 'US', EUR: 'EU', GBP: 'GB', JPY: 'JP', AUD: 'AU', CAD: 'CA', CHF: 'CH', NZD: 'NZ', CNY: 'CN',
};

// Ordre fixe des puces de devise (seules celles présentes dans le flux s'affichent).
const ECON_CUR_ORDER = ['USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD', 'CHF', 'NZD', 'CNY'];

// 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.
// V2 (shape /cal2 : events normalisés + champ src) sous une NOUVELLE clé ;
// l'ancienne clé v1 reste lue en migration douce (premier rendu instantané
// après mise à jour du client) et n'est plus écrite.
const ECON_CACHE_KEY = 'tempo:econCache';
const ECON_CACHE_V2_KEY = 'tempo:econCacheV2';
const ECON_CACHE_TTL_MS = 10 * 60 * 1000;
const ECON_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
// Fenêtre « publication imminente » : après un événement Élevé/Moyen du jour,
// refresh silencieux toutes les 5 min pendant 45 min pour attraper l'actual.
const ECON_FAST_TTL_MS = 5 * 60 * 1000;
const ECON_FAST_WINDOW_MS = 45 * 60 * 1000;
// URL du proxy par défaut — Worker Cloudflare partagé, actif pour TOUS les
// utilisateurs sans configuration.
// Surcharge (localStorage 'tempo:econProxy' ou window.TEMPO_ECON_PROXY) :
// UNIQUEMENT en développement (localhost / 127.0.0.1) et UNIQUEMENT en https://.
// En production la surcharge est ignorée : cette base reçoit aussi l'URL
// privée du calendrier ICS (routines.jsx) et sert des données non signées —
// un script injecté ne doit pas pouvoir la rediriger durablement.
const ECON_PROXY_DEFAULT = 'https://tempo-econ-proxy.guillaume-briere92.workers.dev';

function isEconDevHost() {
  try {
    return typeof location !== 'undefined' && /^(localhost|127\.0\.0\.1)$/.test(location.hostname);
  } catch (e) { return false; }
}

function getEconProxyURL() {
  if (isEconDevHost()) {
    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 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.
function econTz() {
  return (typeof window !== 'undefined' && window.__appTz) || 'Europe/Paris';
}

// Instant → jour civil + heure dans le fuseau du profil (format-prefs).
function parseFFDate(iso) {
  if (iso == null || iso === '') return { d: '', time: '' };
  const dt = new Date(iso);
  if (isNaN(dt.getTime())) return { d: '', time: '' };
  const tz = econTz();
  return { d: localDayInTz(dt, tz), time: tzTimeIn(dt, tz), 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);
}

// ── Valeurs chiffrées ───────────────────────────────────────────────
// Les événements stockent des NOMBRES (aNum/fNum/pNum, sur une même échelle)
// + unit ('%', '£', 'A$'…) + scale ('', 'K', 'M', 'B', 'T'). Les champs
// actual/forecast/prev ne gardent la chaîne brute de la source QUE si elle
// est illisible (affichée telle quelle). Mise en forme française au rendu.
const ECON_SCALE_FACTOR = { '': 1, K: 1e3, M: 1e6, B: 1e9, T: 1e12 };
const ECON_SCALE_LABEL = { K: 'k', M: 'M', B: 'Md', T: 'T' };
const ECON_UNIT_LABEL = { cf: 'pi³' }; // pieds cubes (stocks de gaz naturel)

function econScaleOf(s) {
  const k = String(s || '').toUpperCase();
  return Object.prototype.hasOwnProperty.call(ECON_SCALE_FACTOR, k) ? k : '';
}

// Lecture d'une valeur texte de la source (« 3.2% », « -0.1K », « 250B ») ou
// d'un ancien cache (« 6.2B £ », « -29.7B A$ », « 2.701% »). null si illisible.
function econParseStr(v) {
  if (v == null) return null;
  const s = String(v).trim().replace(/,/g, '');
  const m = s.match(/^([+\-−]?)(\d+(?:\.\d+)?|\.\d+)\s*([KMBT]?)\s*(%?)(?:\s+(\S.*))?$/);
  if (!m) return null;
  const n = parseFloat(m[2]);
  if (!isFinite(n)) return null;
  return {
    n: (m[1] === '-' || m[1] === '−') ? -n : n,
    scale: m[3] || '',
    unit: m[4] ? '%' : (m[5] || '').trim(),
  };
}

// Trois champs bruts (nombre pré-échelonné TradingView OU chaîne FairEconomy)
// → nombres comparables sur une MÊME échelle + unité commune.
function econNormVals(rawA, rawF, rawP, unit0, scale0) {
  const baseUnit = (typeof unit0 === 'string') ? unit0.trim() : '';
  const baseScale = econScaleOf(scale0);
  const one = (v) => {
    if (v == null || v === '') return null;
    if (typeof v === 'number') return isFinite(v) ? { n: v, scale: baseScale, unit: baseUnit } : null;
    return econParseStr(v) || { raw: String(v).trim() };
  };
  const ps = [one(rawA), one(rawF), one(rawP)];
  const nums = ps.filter(p => p && p.n != null);
  const scale = baseScale || (nums[0] ? nums[0].scale : '');
  const withUnit = nums.find(p => p.unit);
  const unit = baseUnit || (withUnit ? withUnit.unit : '');
  const conv = (p) => (p && p.n != null)
    ? p.n * ECON_SCALE_FACTOR[p.scale || ''] / ECON_SCALE_FACTOR[scale || '']
    : null;
  const raw = (p) => (p && p.raw) ? p.raw : null;
  return {
    aNum: conv(ps[0]), fNum: conv(ps[1]), pNum: conv(ps[2]),
    actual: raw(ps[0]), forecast: raw(ps[1]), prev: raw(ps[2]),
    unit, scale,
  };
}

// Nombre de décimales utiles (0 à 3) : 2.701 → 3, 1719 → 0, 0.04 → 2.
function econDec(n) {
  const a = Math.abs(n);
  for (let k = 0; k < 3; k++) {
    const x = a * Math.pow(10, k);
    if (Math.abs(x - Math.round(x)) < 1e-6 * Math.max(1, x)) return k;
  }
  return 3;
}

// Mise en forme française : « 2,701 % », « −18,3 Md £ », « 1 719 k », « 6,748 T $ ».
function econFmtVal(n, unit, scale, sign = false, dec) {
  if (typeof n !== 'number' || !isFinite(n)) return null;
  const d = dec == null ? econDec(n) : dec;
  const u = String(unit || '').trim();
  if (u === '%') return fmtPct(n, d, sign);
  const sc = ECON_SCALE_LABEL[econScaleOf(scale)] || '';
  const ul = ECON_UNIT_LABEL[u] || u;
  return fmtNum(n, d, sign) + (sc ? ' ' + sc : '') + (ul ? ' ' + ul : '');
}

// Vue chiffrée d'un événement (mémoïsée par objet) : nombres comparables,
// unité, échelle et nombre de décimales COMMUN aux trois colonnes (Publié,
// Prévision, Précédent s'alignent : « 3,0 % » / « 3,1 % »).
const ECON_NUMS_CACHE = (typeof WeakMap !== 'undefined') ? new WeakMap() : null;
function econNums(e) {
  if (ECON_NUMS_CACHE && ECON_NUMS_CACHE.has(e)) return ECON_NUMS_CACHE.get(e);
  const fin = (x) => (typeof x === 'number' && isFinite(x)) ? x : null;
  let v;
  if (e.unit !== undefined) {
    v = {
      a: fin(e.aNum), f: fin(e.fNum), p: fin(e.pNum),
      unit: e.unit || '', scale: e.scale || '',
      raw: { actual: e.actual, forecast: e.forecast, prev: e.prev },
    };
  } else {
    // Ancien cache (avant le formatage au rendu) : chaînes déjà mises en forme
    // à l'anglaise, relues puis reformatées.
    const r = econNormVals(e.actual, e.forecast, e.prev, '', '');
    v = {
      a: r.aNum, f: r.fNum, p: r.pNum, unit: r.unit, scale: r.scale,
      raw: { actual: r.actual, forecast: r.forecast, prev: r.prev },
    };
  }
  v.dec = Math.max(0, ...[v.a, v.f, v.p].filter(x => x != null).map(econDec));
  if (ECON_NUMS_CACHE) ECON_NUMS_CACHE.set(e, v);
  return v;
}

// Texte affiché d'une colonne ('actual' | 'forecast' | 'prev'), ou null.
function econCell(e, key) {
  if (!e) return null;
  const v = econNums(e);
  const n = v[{ actual: 'a', forecast: 'f', prev: 'p' }[key]];
  if (n != null) return econFmtVal(n, v.unit, v.scale, false, v.dec);
  const raw = v.raw[key];
  return (raw == null || raw === '') ? null : String(raw);
}

function econSame(a, b, dec) {
  const p = Math.pow(10, dec);
  return Math.round(a * p) === Math.round(b * p);
}

// Identifiant d'un événement, commun aux trois convertisseurs. Les notes
// « Mon plan » sont rangées sous cet identifiant (tempo:econNotes) : il doit
// donc rester le même d'un rechargement à l'autre ET quand le fuseau du profil
// change. Il est fait de l'instant UTC en millisecondes (jamais du jour ni de
// l'heure affichés, qui dépendent du fuseau), de la zone et du titre d'origine :
//   « 1791376200000-USD-unemployment-rate »
// Zone = la devise, complétée du pays quand ce n'est pas celui de la devise
// (« EUR.DE ») : une même publication sort au même instant pour la zone euro,
// la France et l'Allemagne, et sans le pays ces lignes ne se distinguaient que
// par leur ordre dans le flux (une note pouvait changer de pays).
// Il ne dépend pas non plus de l'indice dans le flux brut : une insertion en
// cours de semaine décalerait tous les identifiants suivants.
// `seen` (Map partagée sur un même chargement) compte les occurrences d'une
// même base pour suffixer les doublons exacts (« ~1 », « ~2 » : deux discours
// du même intervenant au même instant).
// Les anciennes clés (« 2026-10-07-14:30-USD-unemployment-rate ») sont
// reprises par migrateLegacyEconNotes.
function econEventId(ts, cur, flag, title, seen) {
  const zone = (flag && flag !== ECON_CUR_COUNTRY[cur]) ? `${cur}.${flag}` : cur;
  const base = `${ts || 0}-${zone}-${econSlug(title)}`;
  const n = seen.get(base) || 0;
  seen.set(base, n + 1);
  return n ? `${base}~${n}` : base;
}

// Source de secours (ForexFactory) : `country` porte en réalité la DEVISE.
// Deux cas particuliers :
// - « All » = événement mondial (réunion de l'OPEP, G20…) : ni devise ni pays,
//   champ `global`. L'écran affiche « Monde », aucune puce de filtre n'est créée.
// - devise EUR : le pays ne figure que dans le titre (« German Industrial
//   Production »). On le reprend de là, comme le fait la source principale,
//   sinon tout indicateur national serait rangé sous « Zone euro ».
const ECON_FF_PREFIX_FLAG = { german: 'DE', french: 'FR', italian: 'IT', spanish: 'ES' };
function mapFFItem(raw, seen) {
  const { d, time, ts } = parseFFDate(raw.date);
  const code = String(raw.country || raw.currency || '').toUpperCase();
  const global = code === 'ALL';
  const cur = global ? '' : code;
  const pm = cur === 'EUR' ? /^(German|French|Italian|Spanish)\s/i.exec(raw.title || '') : null;
  const flag = global ? '' : (pm ? ECON_FF_PREFIX_FLAG[pm[1].toLowerCase()] : (ECON_CUR_COUNTRY[cur] || cur.slice(0, 2)));
  const isSpeech = /speech|speaks|testimony/i.test(raw.title || '');
  return {
    id: econEventId(ts, cur, flag, raw.title, seen),
    ts,
    d, time, cur, flag,
    ...(global ? { global: true } : {}),
    impact: normalizeImpact(raw.impact),
    title: raw.title || '—',
    ...econNormVals(raw.actual, raw.forecast, raw.previous, '', ''),
    isSpeech,
  };
}

// ── Mapping TradingView (/cal2) ─────────────────────────────────────
// Un item TradingView se reconnaît à son champ `importance` (numérique) —
// l'item FairEconomy porte `impact` (chaîne). Échelle importance VÉRIFIÉE
// empiriquement le 2026-08-30 (distribution sur 324 événements réels :
// -1 ×229, 0 ×79, 1 ×16 ; NFP et décisions de taux = 1) : -1 → low,
// 0 → medium, 1 → high.
function isTVShape(raw) {
  return !!raw && typeof raw === 'object' && raw.importance !== undefined && raw.impact === undefined;
}

function impactFromImportance(v) {
  const n = Number(v);
  if (n >= 1) return 'high';
  if (n === 0) return 'medium';
  return 'low'; // -1 et valeurs inconnues/NaN
}

// Item TradingView → shape interne (même identifiant que les autres
// convertisseurs, voir econEventId : les notes « Mon plan » survivent aux
// rechargements et au changement de fuseau). Les deux sources n'intitulent pas
// toujours un événement de la même façon : au changement de source, une note
// suit son événement quand il est reconnaissable dans l'autre flux
// (reattachEconNotes), sinon elle attend le retour de sa source.
// Champs supplémentaires : period (ex. « Aug »), agency (source officielle).
function mapTVItem(raw, seen) {
  const { d, time, ts } = parseFFDate(raw.date);
  const country = String(raw.country || '').toUpperCase();
  const cur = String(raw.currency || '').toUpperCase() || country;
  const flag = COUNTRY_NAMES[country] ? country : (ECON_CUR_COUNTRY[cur] || country.slice(0, 2));
  const title = raw.title || raw.indicator || '—';
  const isSpeech = /speech|speaks|testimony/i.test(title);
  return {
    id: econEventId(ts, cur, flag, title, seen),
    ts,
    d, time, cur, flag,
    impact: impactFromImportance(raw.importance),
    title,
    ...econNormVals(raw.actual, raw.forecast, raw.previous, raw.unit, raw.scale),
    period: (typeof raw.period === 'string') ? raw.period : '',
    agency: (typeof raw.source === 'string' && raw.source !== 'faireconomy') ? raw.source : '',
    isSpeech,
  };
}

// ── Mapping /cal2 NORMALISÉ (nouveau worker) ────────────────────────
// Le worker v2 sert des événements DÉJÀ normalisés : { id, ts (ms UTC),
// country (ISO2), currency (ISO3), title, impact 'High'|'Medium'|'Low',
// actual/forecast/previous (nombres pré-échelonnés TradingView OU strings
// FairEconomy en repli serveur), unit, period, scale, agency }. Reconnu par
// `ts` numérique + `impact` chaîne (l'item FairEconomy brut porte `date`,
// l'item TradingView brut porte `importance`).
function isCal2Shape(raw) {
  return !!raw && typeof raw === 'object' && typeof raw.ts === 'number' &&
    typeof raw.impact === 'string' && raw.date === undefined && raw.importance === undefined;
}

function mapCal2Item(raw, seen) {
  const { d, time, ts } = parseFFDate(raw.ts); // new Date(ms) — même fuseau profil
  const country = String(raw.country || '').toUpperCase();
  const cur = String(raw.currency || '').toUpperCase() || country;
  const flag = COUNTRY_NAMES[country] ? country : (ECON_CUR_COUNTRY[cur] || country.slice(0, 2));
  const title = raw.title || '—';
  const isSpeech = /speech|speaks|testimony/i.test(title);
  return {
    id: econEventId(ts, cur, flag, title, seen),
    ts,
    d, time, cur, flag,
    impact: normalizeImpact(raw.impact),
    title,
    // Nombre TradingView (pré-échelonné, unit/scale fournis) ou chaîne
    // FairEconomy (repli serveur, ex. « 0.3% ») : même normalisation.
    ...econNormVals(raw.actual, raw.forecast, raw.previous, raw.unit, raw.scale),
    period: (typeof raw.period === 'string') ? raw.period : '',
    agency: (typeof raw.agency === 'string') ? raw.agency : '',
    isSpeech,
  };
}

// Contrôle de forme d'un événement au format interne, commun au flux converti
// et au cache local : un objet, un identifiant et un titre en texte, un instant
// absent ou fini. Un événement sans date reste valide (les convertisseurs en
// produisent quand la source ne date pas une ligne) : il n'est simplement pas
// affiché. Tout le reste de l'écran peut alors lire e.id, e.title et e.ts sans
// se protéger.
function econValidEvent(e) {
  return !!e && typeof e === 'object' && !Array.isArray(e) &&
    typeof e.id === 'string' && typeof e.title === 'string' &&
    (e.ts == null || Number.isFinite(e.ts));
}

// Réponse brute d'une source → événements au format interne. Conversion PAR
// ÉLÉMENT (jamais par réponse : un flux mixte, ou une réponse de la source de
// secours servie sur /cal2, restent correctement convertis) et protégée : un
// élément illisible (null, texte, nombre, tableau, titre qui n'est pas un
// texte…) est écarté seul, les autres sont gardés. Ne lève jamais. `dropped` =
// nombre d'éléments écartés.
function econConvertFeed(data) {
  const list = Array.isArray(data) ? data : [];
  const seen = new Map();
  const events = [];
  for (const r of list) {
    if (!r || typeof r !== 'object' || Array.isArray(r)) continue;
    let ev = null;
    try {
      ev = isTVShape(r) ? mapTVItem(r, seen)
        : isCal2Shape(r) ? mapCal2Item(r, seen)
          : mapFFItem(r, seen);
    } catch (err) { ev = null; }
    if (econValidEvent(ev)) events.push(ev);
  }
  events.sort((a, b) => (a.ts || 0) - (b.ts || 0));
  return { events, dropped: list.length - events.length };
}

// ── Sens d'interprétation d'un chiffre par rapport à la prévision ──
// PRUDENCE TOTALE par défaut : on ne colore QUE les indicateurs dont le sens
// est sans ambiguïté, tout le reste s'affiche neutre.
//   -1 (INVERSÉS, plus bas = meilleur) : taux de chômage, inscriptions au
//      chômage (initial/continuing jobless claims).
//   AMBIGUS (jamais colorés, testés AVANT la liste positive) : tout ce qui est
//      prix/inflation (CPI, PPI, PCE, deflator…, une inflation « plus haute »
//      n'est pas « meilleure »), taux directeurs, balances commerciales,
//      stocks de pétrole, discours.
//   +1 (plus haut = meilleur) : croissance/activité/emploi/confiance
//      explicitement listés (PIB, ventes au détail, PMI/ISM, NFP, permis…).
// Toujours évalué sur le titre ANGLAIS d'origine (e.title).
const ECON_DIR_INVERTED = /unemployment rate|jobless|unemployment claims|initial claims|continuing claims/i;
const ECON_DIR_AMBIGUOUS = /price|deflator|\bcpi\b|\bppi\b|\bpce\b|inflation|rate decision|interest rate|cash rate|bank rate|trade balance|current account|budget|crude|oil|invent|speech|speaks/i;
const ECON_DIR_POSITIVE = /gdp|gross domestic|retail sales|\bpmi\b|\bism\b|purchasing managers|non[\s-]?farm|payroll|\badp\b|employment change|job openings|jolts|industrial production|manufacturing production|durable goods|factory orders|capacity utilization|consumer confidence|consumer sentiment|michigan|\bzew\b|\bifo\b|business climate|housing starts|building permits|home sales|new home|existing home/i;

function econDirection(title) {
  const t = String(title || '');
  if (ECON_DIR_INVERTED.test(t)) return -1;
  if (ECON_DIR_AMBIGUOUS.test(t)) return 0;
  if (ECON_DIR_POSITIVE.test(t)) return 1;
  return 0;
}

// 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',
};

// Code pays ISO2 (TradingView) → nom français. Plus précis que la devise pour
// les pays de la zone euro (DE/FR → EUR) dans l'encart détails.
const COUNTRY_NAMES = {
  US: 'États-Unis', EU: 'Zone euro', GB: 'Royaume-Uni', UK: 'Royaume-Uni',
  JP: 'Japon', AU: 'Australie', NZ: 'Nouvelle-Zélande', CA: 'Canada',
  CH: 'Suisse', DE: 'Allemagne', FR: 'France', IT: 'Italie', ES: 'Espagne', CN: 'Chine',
};

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

// ── Source officielle en français ───────────────────────────────────
// Le flux publie le nom de l'organisme en anglais (ou dans sa langue). La
// table traduit les organismes connus (clé en minuscules) ; les noms propres
// (INSEE, S&P Global, ISM, ADP…) restent tels quels. Un nom générique
// (« Central Bank », « Bureau of Statistics ») est résolu par le pays de
// l'événement. Organisme inconnu → '' : la ligne est masquée plutôt que
// d'afficher de l'anglais.
const ECON_CENTRAL_BANK_FR = {
  US: 'Réserve fédérale (Fed)', EU: 'Banque centrale européenne (BCE)', GB: 'Banque d’Angleterre (BoE)',
  UK: 'Banque d’Angleterre (BoE)', JP: 'Banque du Japon (BoJ)', AU: 'Banque de réserve d’Australie (RBA)',
  NZ: 'Banque de réserve de Nouvelle-Zélande (RBNZ)', CA: 'Banque du Canada (BoC)', CH: 'Banque nationale suisse (BNS)',
  DE: 'Bundesbank', FR: 'Banque de France', IT: 'Banque d’Italie', ES: 'Banque d’Espagne', CN: 'Banque populaire de Chine (PBoC)',
};
// Noms génériques : libellé français + pays (ou nom exact par pays).
const ECON_AGENCY_GENERIC_FR = {
  'central bank': { byFlag: ECON_CENTRAL_BANK_FR, fr: 'Banque centrale' },
  'reserve bank': { byFlag: ECON_CENTRAL_BANK_FR, fr: 'Banque de réserve' },
  'national bank': { byFlag: { CH: 'Banque nationale suisse (BNS)', AU: 'National Australia Bank (NAB)' }, fr: 'Banque nationale' },
  'bureau of statistics': { byFlag: { AU: 'Bureau australien des statistiques (ABS)' }, fr: 'Bureau des statistiques' },
  'statistics bureau': { byFlag: { JP: 'Bureau des statistiques du Japon' }, fr: 'Bureau des statistiques' },
  'federal statistical office': { byFlag: { DE: 'Office fédéral de la statistique (Destatis)', CH: 'Office fédéral de la statistique (OFS)' }, fr: 'Office fédéral de la statistique' },
  'ministry of finance': { fr: 'Ministère des Finances' },
  'department of treasury': { byFlag: { US: 'Département du Trésor' }, fr: 'Trésor' },
  'department of the treasury': { byFlag: { US: 'Département du Trésor' }, fr: 'Trésor' },
  'financial management service': { byFlag: { US: 'Département du Trésor' }, fr: 'Trésor' },
  'treasury board': { fr: 'Conseil du Trésor' },
  'department of labour': { byFlag: { US: 'Département du Travail' }, fr: 'Ministère du Travail' },
  'department of labor': { byFlag: { US: 'Département du Travail' }, fr: 'Ministère du Travail' },
  'department of agriculture': { byFlag: { US: 'Département de l’Agriculture (USDA)' }, fr: 'Ministère de l’Agriculture' },
  'cabinet office': { byFlag: { JP: 'Bureau du Cabinet du Japon' }, fr: 'Bureau du Cabinet' },
  'institute of economic research': { byFlag: { NZ: 'Institut de recherche économique (NZIER)' }, fr: 'Institut de recherche économique' },
};
const ECON_AGENCY_FR = {
  // Banques centrales et institutions
  'federal reserve': 'Réserve fédérale (Fed)',
  'federal reserve bank of new york': 'Fed de New York',
  'federal reserve bank of dallas': 'Fed de Dallas',
  'federal reserve bank of philadelphia': 'Fed de Philadelphie',
  'federal reserve bank of richmond': 'Fed de Richmond',
  'federal reserve bank of kansas city': 'Fed de Kansas City',
  'federal reserve bank of chicago': 'Fed de Chicago',
  'federal reserve bank of atlanta': 'Fed d’Atlanta',
  'european central bank': 'Banque centrale européenne (BCE)',
  'bank of england': 'Banque d’Angleterre (BoE)',
  'bank of japan': 'Banque du Japon (BoJ)',
  'bank of canada': 'Banque du Canada (BoC)',
  'swiss national bank': 'Banque nationale suisse (BNS)',
  'reserve bank of australia': 'Banque de réserve d’Australie (RBA)',
  'reserve bank of new zealand': 'Banque de réserve de Nouvelle-Zélande (RBNZ)',
  'european commission': 'Commission européenne',
  'eurostat': 'Eurostat',
  // Statistique publique
  'bureau of labour statistics': 'Bureau des statistiques du travail (BLS)',
  'bureau of labor statistics': 'Bureau des statistiques du travail (BLS)',
  'census bureau': 'Bureau du recensement',
  'bureau of economics analysis': 'Bureau d’analyse économique (BEA)',
  'bureau of economic analysis': 'Bureau d’analyse économique (BEA)',
  'statistics canada': 'Statistique Canada',
  'office for national statistics': 'Office national de la statistique (ONS)',
  'stats nz': 'Statistique Nouvelle-Zélande',
  'statistics new zealand': 'Statistique Nouvelle-Zélande',
  'insee': 'INSEE',
  'dares': 'DARES',
  'bundesagentur für arbeit': 'Agence fédérale pour l’emploi',
  'amt für statistik berlin-brandenburg': 'Office statistique de Berlin-Brandebourg',
  'bayerisches landesamt für statistik': 'Office statistique de Bavière',
  'landesbetrieb information und technik nordrhein-westfalen': 'Office statistique de Rhénanie-du-Nord-Westphalie',
  'statistik hessen': 'Office statistique de Hesse',
  'statistisches landesamt baden-württemberg': 'Office statistique de Bade-Wurtemberg',
  'statistisches landesamt des freistaates sachsen': 'Office statistique de Saxe',
  'state secretariat for economic affairs': 'Secrétariat d’État à l’économie (SECO)',
  'economic institute (kof)': 'Institut KOF',
  'kba - federal motor transport authority': 'Office fédéral des véhicules à moteur (KBA)',
  // Ministères et Trésor
  'agence trésor': 'Agence France Trésor',
  'ministère de l\'économie et des finances': 'Ministère de l’Économie et des Finances',
  'ministère de l’économie et des finances': 'Ministère de l’Économie et des Finances',
  'ministry of economy trade and industry (meti)': 'Ministère de l’Économie, du Commerce et de l’Industrie (METI)',
  'ministry of health, labour and welfare': 'Ministère de la Santé, du Travail et des Affaires sociales',
  'ministry of internal affairs and communications': 'Ministère de l’Intérieur et des Communications',
  'ministry of land, infrastructure, transport and tourism': 'Ministère du Territoire, des Infrastructures et des Transports',
  'u.s. energy information administration': 'Agence d’information sur l’énergie (EIA)',
  'energy information administration': 'Agence d’information sur l’énergie (EIA)',
  'federal housing finance agency': 'Agence fédérale de financement du logement (FHFA)',
  // Organismes privés et associations (noms propres conservés)
  'institute for supply management': 'ISM',
  'automatic data processing, inc.': 'ADP',
  'american petroleum institute (api)': 'Institut américain du pétrole (API)',
  'mortgage bankers association of america': 'Association des banquiers hypothécaires (MBA)',
  'university of michigan': 'Université du Michigan',
  'baker hughes company': 'Baker Hughes',
  'challenger, gray and christmas, inc.': 'Challenger, Gray & Christmas',
  'freddie': 'Freddie Mac',
  'freddie mac': 'Freddie Mac',
  'redbook research inc.': 'Redbook',
  'manheim consulting inc.': 'Manheim',
  'wards intelligence': 'Wards Intelligence',
  'technometrica market intelligence/realclearmarkets': 'TIPP / RealClearMarkets',
  'logistics managers\' index': 'Logistics Managers’ Index',
  's&p global': 'S&P Global',
  'standard and poor\'s': 'S&P Global',
  'ccfa - comite des constructeurs francais d\'automobiles': 'Comité des constructeurs français d’automobiles (CCFA)',
  'brc - british retail consortium': 'British Retail Consortium (BRC)',
  'halifax and bank of scotland': 'Halifax',
  'nationwide building society': 'Nationwide',
  'royal institution of chartered surveyors (rics)': 'RICS',
  'society of motor manufacturers and traders limited (smmt)': 'SMMT',
  'anz bank': 'ANZ',
  'anz - indeed australian job ads': 'ANZ-Indeed',
  'australian industry group': 'Ai Group',
  'cotality': 'Cotality',
  'melbourne institute': 'Melbourne Institute',
  'westpac banking corporation, melbourne institute': 'Westpac, Melbourne Institute',
  'globaldairytrade': 'Global Dairy Trade',
  'ivey business school': 'Ivey Business School',
  'machine tool builders\' association (jmtba)': 'Association des fabricants de machines-outils (JMTBA)',
  'tankan sponsored by thomson reuters': 'Reuters',
  'ubs and cfa society': 'UBS, CFA Society',
  'procure.ch ubs': 'procure.ch, UBS',
  'deutsche bundesbank': 'Bundesbank',
  'business': 'BusinessNZ',
  'cfib - canadian federation of independent business': 'Fédération canadienne de l’entreprise indépendante (FCEI)',
  'mortgage and housing corporation': 'Société canadienne d’hypothèques et de logement (SCHL)',
  'national association of home builders': 'Association nationale des constructeurs de logements (NAHB)',
  'national association of realtors': 'Association nationale des agents immobiliers (NAR)',
  'national federation of independent business': 'Fédération nationale des entreprises indépendantes (NFIB)',
};
function econAgencyFr(agency, flag) {
  const raw = String(agency == null ? '' : agency).trim();
  if (!raw) return '';
  const key = raw.toLowerCase().replace(/\s+/g, ' ');
  if (ECON_AGENCY_FR[key]) return ECON_AGENCY_FR[key];
  const g = ECON_AGENCY_GENERIC_FR[key];
  const f = String(flag || '').toUpperCase();
  if (g) {
    if (g.byFlag && g.byFlag[f]) return g.byFlag[f];
    return COUNTRY_NAMES[f] ? `${g.fr} · ${COUNTRY_NAMES[f]}` : '';
  }
  // Sigle seul (« ISM », « ZEW ») : lisible tel quel.
  if (/^[A-Z][A-Z0-9&.\-]{1,9}$/.test(raw)) return raw;
  return '';
}

// ── Titres en français ──────────────────────────────────────────────
// La source publie ses titres en anglais. e.title reste en anglais (id, slug,
// notes, econDirection, eventExplainer et isSpeech en dépendent) : la
// traduction ne sert qu'à l'AFFICHAGE (liste, bandeau, détail), le titre
// d'origine restant en info-bulle. Un titre non reconnu s'affiche tel quel
// (jamais de demi-traduction).
// Étapes : discours → adjudications → qualificatifs retirés (pays FF,
// Prelim/Flash/Final, MoM/YoY/QoQ) → table ECON_TITLE_FR (du plus spécifique
// au plus général, premier motif gagnant) → recomposition.
const ECON_TITLE_PER = {
  mom: 'sur un mois', 'm/m': 'sur un mois',
  yoy: 'sur un an', 'y/y': 'sur un an',
  qoq: 'sur un trimestre', 'q/q': 'sur un trimestre',
};
const ECON_TITLE_STATUS = {
  final: 'définitif', prel: 'préliminaire', prelim: 'préliminaire', flash: 'flash',
  'flash estimate': 'flash',
  adv: 'estimation avancée', advance: 'estimation avancée', revised: 'révisé',
};
const ECON_TITLE_COUNTRY = { german: 'Allemagne', french: 'France', italian: 'Italie', spanish: 'Espagne' };
// Banques centrales (et comités : FOMC = Fed, MPC = BoE, Buba = Bundesbank) →
// nom affiché. Un comité porte le nom de SA banque : la source principale
// écrit « Fed Logan Speech », la source de secours « FOMC Member Logan Speaks »,
// et les deux doivent donner le même libellé (une note « Mon plan » suit son
// événement d'une source à l'autre grâce à ce libellé, voir econMatchKey).
const ECON_BANK_FR = {
  fed: 'Fed', fomc: 'Fed', ecb: 'BCE', boe: 'BoE', mpc: 'BoE', boj: 'BoJ', rba: 'RBA', rbnz: 'RBNZ',
  snb: 'BNS', boc: 'BoC', bundesbank: 'Bundesbank', buba: 'Bundesbank', pboc: 'PBoC', riksbank: 'Riksbank',
};
const ECON_REGION_FR = {
  bavaria: 'Bavière', saxony: 'Saxe', hesse: 'Hesse', brandenburg: 'Brandebourg',
  'baden wuerttemberg': 'Bade-Wurtemberg', 'north rhine westphalia': 'Rhénanie-du-Nord-Westphalie',
};
const ECON_INSTR_FR = {
  bill: 'bons du Trésor', bills: 'bons du Trésor', note: 'T-Notes', notes: 'T-Notes',
  bond: 'obligations', bonds: 'obligations', frn: 'obligations à taux variable',
  'eu-bonds': 'obligations de l’UE', tips: 'TIPS', bubill: 'Bubill', btf: 'BTF',
  bund: 'Bund', bobl: 'Bobl', schatz: 'Schatz', jgb: 'JGB', gilt: 'Gilt', btp: 'BTP',
  oat: 'OAT', oati: 'OATi', 'oat€i': 'OAT€i', bonos: 'Bonos',
  'index-linked treasury gilt': 'Gilt indexé', 'index-linked gilt': 'Gilt indexé',
  'conventional gilt': 'Gilt', 'long conventional gilt': 'Gilt à long terme',
};

// « de » élidé devant une voyelle : « d’OAT », « d’obligations », « de Bund ».
function econDe(s) {
  return /^[aeiouyâêîôûéèAEIOUYÂÊÎÔÛÉÈ]/.test(s) ? 'd’' + s : 'de ' + s;
}
function econBankOf(code) {
  const b = ECON_BANK_FR[String(code || '').toLowerCase()];
  return b ? 'de la ' + b : '';
}

const ECON_TITLE_FR = [
  // ── Emploi ──
  { re: /^adp (?:employment change weekly|weekly employment change)$/i, fr: 'Emploi privé ADP (hebdomadaire)' },
  { re: /^adp (?:non[\s-]?farm )?employment change$/i, fr: 'Emploi privé ADP' },
  { re: /^(?:private non[\s-]?farm payrolls|non[\s-]?farm payrolls private)$/i, fr: 'Créations d’emplois privées non agricoles' },
  { re: /^non[\s-]?farm (?:payrolls|employment change)$/i, fr: 'Créations d’emplois non agricoles (NFP)' },
  { re: /^government payrolls$/i, fr: 'Créations d’emplois publics' },
  { re: /^manufacturing payrolls$/i, fr: 'Créations d’emplois dans l’industrie' },
  { re: /^(?:initial jobless claims|unemployment claims)$/i, fr: 'Inscriptions hebdomadaires au chômage' },
  { re: /^continuing jobless claims$/i, fr: 'Inscriptions continues au chômage' },
  { re: /^jobless claims 4-week average$/i, fr: 'Inscriptions au chômage (moyenne sur 4 semaines)' },
  { re: /^u-6 unemployment rate$/i, fr: 'Taux de chômage U-6' },
  { re: /^monthly unemployment rate$/i, fr: 'Taux de chômage mensuel' },
  { re: /^unemployment rate$/i, fr: 'Taux de chômage' },
  { re: /^unemployment change$/i, fr: 'Variation du nombre de chômeurs' },
  { re: /^unemployed persons$/i, fr: 'Nombre de chômeurs' },
  { re: /^unemployment benefit claims$/i, fr: 'Demandes d’allocations chômage' },
  { re: /^claimant count change$/i, fr: 'Variation des demandeurs d’allocations chômage' },
  { re: /^jobseekers total$/i, fr: 'Nombre de demandeurs d’emploi' },
  { re: /^full time employment chg$/i, fr: 'Variation de l’emploi à temps plein' },
  { re: /^part time employment chg$/i, fr: 'Variation de l’emploi à temps partiel' },
  { re: /^employment change$/i, fr: 'Variation de l’emploi' },
  { re: /^participation rate$/i, fr: 'Taux de participation' },
  { re: /^average hourly earnings$/i, fr: 'Salaire horaire moyen' },
  { re: /^average weekly earnings$/i, fr: 'Salaire hebdomadaire moyen' },
  { re: /^average earnings index(?: \+ ?bonus)?$/i, fr: 'Salaire moyen' },
  { re: /^average weekly hours$/i, fr: 'Durée hebdomadaire moyenne du travail' },
  { re: /^jolts job openings$/i, fr: 'Offres d’emploi (JOLTS)' },
  { re: /^jolts job quits$/i, fr: 'Démissions (JOLTS)' },
  { re: /^challenger job cuts$/i, fr: 'Suppressions d’emplois (Challenger)' },
  { re: /^jobs\/applications ratio$/i, fr: 'Ratio offres/demandes d’emploi' },
  // ── Inflation ──
  { re: /^core pce price index$/i, fr: 'Indice des prix PCE sous-jacent' },
  { re: /^pce price index$/i, fr: 'Indice des prix PCE' },
  { re: /^core pce prices$/i, fr: 'Prix PCE sous-jacents' },
  { re: /^pce prices$/i, fr: 'Prix PCE' },
  { re: /^(?:rba )?trimmed mean cpi$/i, fr: 'Inflation moyenne tronquée (RBA)' },
  { re: /^(?:rba )?weighted median cpi$/i, fr: 'Inflation médiane pondérée (RBA)' },
  { re: /^tokyo core cpi$/i, fr: 'Inflation sous-jacente de Tokyo' },
  { re: /^tokyo cpi ex food and energy$/i, fr: 'Inflation de Tokyo hors alimentation et énergie' },
  { re: /^tokyo cpi$/i, fr: 'Inflation de Tokyo' },
  { re: /^national core cpi$/i, fr: 'Inflation sous-jacente nationale' },
  { re: /^(bavaria|saxony|hesse|brandenburg|baden wuerttemberg|north rhine westphalia) cpi$/i, fr: m => `Inflation (${ECON_REGION_FR[m[1].toLowerCase()]})` },
  { re: /^core (?:cpi|inflation rate)$/i, fr: 'Inflation sous-jacente' },
  { re: /^(?:harmoni[sz]ed inflation rate|hicp)$/i, fr: 'Inflation harmonisée (IPCH)' },
  { re: /^(?:cpi|inflation rate|consumer price index)$/i, fr: 'Inflation (IPC)' },
  { re: /^core ppi$/i, fr: 'Prix à la production sous-jacents' },
  { re: /^(?:ppi|producer price index)$/i, fr: 'Prix à la production' },
  { re: /^import prices$/i, fr: 'Prix à l’importation' },
  { re: /^export prices$/i, fr: 'Prix à l’exportation' },
  { re: /^commodity prices$/i, fr: 'Prix des matières premières' },
  { re: /^brc shop price (?:index|inflation)$/i, fr: 'Inflation des prix en magasin (BRC)' },
  { re: /^sppi$/i, fr: 'Prix des services aux entreprises (SPPI)' },
  { re: /^(?:td-)?mi inflation gauge$/i, fr: 'Jauge d’inflation (TD-MI)' },
  { re: /^consumer inflation expectations$/i, fr: 'Anticipations d’inflation des consommateurs' },
  { re: /^(?:michigan|uom) 5[\s-]?(?:year|y) inflation expectations$/i, fr: 'Anticipations d’inflation à 5 ans (Michigan)' },
  { re: /^(?:michigan|uom) inflation expectations$/i, fr: 'Anticipations d’inflation (Michigan)' },
  { re: /^dmp 1y cpi expectations$/i, fr: 'Anticipations d’inflation à 1 an (DMP)' },
  { re: /^dmp 3m output price expectations$/i, fr: 'Anticipations de prix de vente à 3 mois (DMP)' },
  { re: /^selling price expectations$/i, fr: 'Anticipations de prix de vente' },
  { re: /^gdp price index$/i, fr: 'Indice des prix du PIB' },
  // ── Croissance et activité ──
  { re: /^gdp growth rate$/i, fr: 'Croissance du PIB' },
  { re: /^gdp sales$/i, fr: 'Ventes finales (PIB)' },
  { re: /^gdp$/i, fr: 'PIB' },
  { re: /^(?:retail sales ex autos|core retail sales)$/i, fr: 'Ventes au détail hors automobile' },
  { re: /^retail sales$/i, fr: 'Ventes au détail' },
  { re: /^retail inventories ex autos$/i, fr: 'Stocks des détaillants hors automobile' },
  { re: /^s&p global composite pmi$/i, fr: 'PMI composite (S&P Global)' },
  { re: /^s&p global services pmi$/i, fr: 'PMI des services (S&P Global)' },
  { re: /^s&p global manufacturing pmi$/i, fr: 'PMI manufacturier (S&P Global)' },
  { re: /^composite pmi$/i, fr: 'PMI composite' },
  { re: /^services pmi$/i, fr: 'PMI des services' },
  { re: /^manufacturing pmi$/i, fr: 'PMI manufacturier' },
  { re: /^non-manufacturing pmi$/i, fr: 'PMI non manufacturier' },
  { re: /^(ratingdog|caixin|hcob|au jibun bank|judo bank) (manufacturing|services|composite) pmi$/i, fr: m => `${{ manufacturing: 'PMI manufacturier', services: 'PMI des services', composite: 'PMI composite' }[m[2].toLowerCase()]} (${m[1]})` },
  { re: /^ism manufacturing pmi$/i, fr: 'ISM manufacturier' },
  { re: /^ism (?:services|non-manufacturing) pmi$/i, fr: 'ISM des services' },
  { re: /^ism manufacturing (employment|new orders|prices)$/i, fr: m => `ISM manufacturier (${{ employment: 'emploi', 'new orders': 'nouvelles commandes', prices: 'prix' }[m[1].toLowerCase()]})` },
  { re: /^ism services (business activity|employment|new orders|prices)$/i, fr: m => `ISM des services (${{ 'business activity': 'activité', employment: 'emploi', 'new orders': 'nouvelles commandes', prices: 'prix' }[m[1].toLowerCase()]})` },
  { re: /^chicago pmi$/i, fr: 'PMI de Chicago' },
  { re: /^procure\.ch manufacturing pmi$/i, fr: 'PMI manufacturier (procure.ch)' },
  { re: /^(?:durable goods orders ex transp(?:ortation)?|core durable goods orders)$/i, fr: 'Commandes de biens durables hors transport' },
  { re: /^durable goods orders ex defen[cs]e$/i, fr: 'Commandes de biens durables hors défense' },
  { re: /^durable goods orders$/i, fr: 'Commandes de biens durables' },
  { re: /^non defen[cs]e goods orders ex air$/i, fr: 'Commandes de biens d’équipement hors défense et aéronautique' },
  { re: /^factory orders ex transportation$/i, fr: 'Commandes à l’industrie hors transport' },
  { re: /^factory orders$/i, fr: 'Commandes à l’industrie' },
  { re: /^industrial production$/i, fr: 'Production industrielle' },
  { re: /^manufacturing production$/i, fr: 'Production manufacturière' },
  { re: /^manufacturing sales$/i, fr: 'Ventes manufacturières' },
  { re: /^car production$/i, fr: 'Production automobile' },
  { re: /^construction orders$/i, fr: 'Commandes dans la construction' },
  { re: /^construction spending$/i, fr: 'Dépenses de construction' },
  { re: /^construction output$/i, fr: 'Production dans la construction' },
  { re: /^nzier capacity utili[sz]ation$/i, fr: 'Taux d’utilisation des capacités (NZIER)' },
  { re: /^capacity utili[sz]ation$/i, fr: 'Taux d’utilisation des capacités' },
  // ── Immobilier ──
  { re: /^(?:building permits|building approvals|building consents)$/i, fr: 'Permis de construire' },
  { re: /^housing starts$/i, fr: 'Mises en chantier' },
  { re: /^new home sales$/i, fr: 'Ventes de logements neufs' },
  { re: /^existing home sales$/i, fr: 'Ventes de logements anciens' },
  { re: /^pending home sales$/i, fr: 'Promesses de ventes de logements' },
  { re: /^private house approvals$/i, fr: 'Permis de construire de maisons individuelles' },
  { re: /^(?:house price index|hpi)$/i, fr: 'Indice des prix immobiliers' },
  { re: /^s&p\/cs composite-20 hpi$/i, fr: 'Prix immobiliers (Case-Shiller 20 villes)' },
  { re: /^nationwide (?:housing prices|hpi)$/i, fr: 'Prix immobiliers (Nationwide)' },
  { re: /^s&p\/case-shiller home price$/i, fr: 'Prix immobiliers (Case-Shiller)' },
  { re: /^cotality dwelling prices$/i, fr: 'Prix des logements (Cotality)' },
  { re: /^housing credit$/i, fr: 'Crédit immobilier' },
  { re: /^mortgage approvals$/i, fr: 'Prêts immobiliers accordés' },
  { re: /^mortgage lending$/i, fr: 'Prêts immobiliers nets' },
  { re: /^mba 30-year mortgage rate$/i, fr: 'Taux hypothécaire à 30 ans (MBA)' },
  { re: /^mba mortgage applications$/i, fr: 'Demandes de prêts immobiliers (MBA)' },
  { re: /^mba mortgage market index$/i, fr: 'Indice du marché hypothécaire (MBA)' },
  { re: /^mba mortgage refinance index$/i, fr: 'Indice des refinancements (MBA)' },
  { re: /^mba purchase index$/i, fr: 'Indice des demandes d’achat (MBA)' },
  { re: /^(\d+)-year mortgage rate$/i, fr: m => `Taux hypothécaire à ${m[1]} ans` },
  // ── Confiance et enquêtes ──
  { re: /^cb consumer confidence$/i, fr: 'Confiance des consommateurs (Conference Board)' },
  { re: /^gfk consumer confidence$/i, fr: 'Confiance des consommateurs (GfK)' },
  { re: /^anz roy morgan consumer confidence$/i, fr: 'Confiance des consommateurs (ANZ-Roy Morgan)' },
  { re: /^westpac consumer confidence index$/i, fr: 'Confiance des consommateurs (Westpac)' },
  { re: /^westpac consumer confidence change$/i, fr: 'Variation de la confiance des consommateurs (Westpac)' },
  { re: /^consumer confidence$/i, fr: 'Confiance des consommateurs' },
  { re: /^(?:michigan|uom) consumer sentiment$/i, fr: 'Moral des consommateurs (Michigan)' },
  { re: /^michigan consumer expectations$/i, fr: 'Anticipations des consommateurs (Michigan)' },
  { re: /^michigan current conditions$/i, fr: 'Conditions actuelles (Michigan)' },
  { re: /^(anz|nzier|nab) business confidence$/i, fr: m => `Confiance des entreprises (${m[1].toUpperCase()})` },
  { re: /^business confidence$/i, fr: 'Confiance des entreprises' },
  { re: /^business climate indicator$/i, fr: 'Indicateur du climat des affaires' },
  { re: /^ifo business climate$/i, fr: 'Climat des affaires (Ifo)' },
  { re: /^ifo current conditions$/i, fr: 'Situation actuelle (Ifo)' },
  { re: /^ifo expectations$/i, fr: 'Anticipations (Ifo)' },
  { re: /^zew economic sentiment(?: index)?$/i, fr: 'Sentiment économique (ZEW)' },
  { re: /^economic sentiment(?: index)?$/i, fr: 'Sentiment économique' },
  { re: /^industrial sentiment$/i, fr: 'Confiance dans l’industrie' },
  { re: /^services sentiment$/i, fr: 'Confiance dans les services' },
  { re: /^cfib business barometer$/i, fr: 'Baromètre des entreprises (CFIB)' },
  { re: /^cbi distributive trades$/i, fr: 'Commerce de distribution (CBI)' },
  { re: /^cbi industrial trends orders$/i, fr: 'Commandes industrielles (CBI)' },
  { re: /^kof leading indicators?$/i, fr: 'Indicateur avancé (KOF)' },
  { re: /^kof economic barometer$/i, fr: 'Baromètre économique (KOF)' },
  { re: /^ubs economic expectations$/i, fr: 'Anticipations économiques (UBS)' },
  { re: /^(?:leading economic index|cb leading index)$/i, fr: 'Indicateur économique avancé' },
  { re: /^coincident index$/i, fr: 'Indicateur coïncident' },
  { re: /^chicago fed national activity index$/i, fr: 'Activité nationale (Fed de Chicago)' },
  { re: /^dallas fed manufacturing index$/i, fr: 'Indice manufacturier (Fed de Dallas)' },
  { re: /^dallas fed services index$/i, fr: 'Indice des services (Fed de Dallas)' },
  { re: /^dallas fed services revenues index$/i, fr: 'Chiffre d’affaires des services (Fed de Dallas)' },
  { re: /^kansas fed composite index$/i, fr: 'Indice composite (Fed de Kansas City)' },
  { re: /^kansas fed manufacturing index$/i, fr: 'Indice manufacturier (Fed de Kansas City)' },
  { re: /^richmond fed manufacturing index$/i, fr: 'Indice manufacturier (Fed de Richmond)' },
  { re: /^richmond fed manufacturing shipments index$/i, fr: 'Livraisons manufacturières (Fed de Richmond)' },
  { re: /^richmond fed services revenues index$/i, fr: 'Chiffre d’affaires des services (Fed de Richmond)' },
  { re: /^(?:philadelphia|philly) fed manufacturing index$/i, fr: 'Indice manufacturier (Fed de Philadelphie)' },
  { re: /^(?:ny )?empire state manufacturing index$/i, fr: 'Indice manufacturier Empire State (Fed de New York)' },
  { re: /^tankan (?:large manufacturers|manufacturing) index$/i, fr: 'Tankan des grandes entreprises manufacturières' },
  { re: /^tankan large manufacturing outlook$/i, fr: 'Tankan, perspectives des grandes entreprises manufacturières' },
  { re: /^tankan (?:large )?non-manufacturing index$/i, fr: 'Tankan des grandes entreprises non manufacturières' },
  { re: /^tankan (?:large )?non-manufacturing outlook$/i, fr: 'Tankan, perspectives des entreprises non manufacturières' },
  { re: /^tankan small manufacturers index$/i, fr: 'Tankan des petites entreprises manufacturières' },
  { re: /^tankan large all industry capex$/i, fr: 'Tankan, investissements des grandes entreprises' },
  // ── Échanges extérieurs et finances publiques ──
  { re: /^(?:balance of trade|trade balance)$/i, fr: 'Balance commerciale' },
  { re: /^goods trade balance$/i, fr: 'Balance commerciale des biens' },
  { re: /^current account$/i, fr: 'Balance courante' },
  { re: /^exports$/i, fr: 'Exportations' },
  { re: /^imports$/i, fr: 'Importations' },
  { re: /^(?:federal |gov(?:ernment)? )?budget balance$/i, fr: 'Solde budgétaire' },
  { re: /^public sector net borrowing(?: ex banks)?$/i, fr: 'Besoin de financement du secteur public' },
  { re: /^foreign bond investment$/i, fr: 'Investissements en obligations étrangères' },
  { re: /^stock investment by foreigners$/i, fr: 'Investissements étrangers en actions' },
  // ── Monnaie et crédit ──
  { re: /^m(\d) money supply$/i, fr: m => `Masse monétaire M${m[1]}` },
  { re: /^money supply$/i, fr: 'Masse monétaire' },
  { re: /^monetary base$/i, fr: 'Base monétaire' },
  { re: /^loans to companies$/i, fr: 'Prêts aux entreprises' },
  { re: /^loans to households$/i, fr: 'Prêts aux ménages' },
  { re: /^private sector credit$/i, fr: 'Crédit au secteur privé' },
  { re: /^(?:boe )?consumer credit$/i, fr: 'Crédit à la consommation' },
  { re: /^net lending to individuals$/i, fr: 'Prêts nets aux particuliers' },
  { re: /^credit card spending$/i, fr: 'Dépenses par carte bancaire' },
  { re: /^fed balance sheet$/i, fr: 'Bilan de la Fed' },
  { re: /^boj jgb purchase$/i, fr: 'Achats de JGB par la BoJ' },
  { re: /^ny fed bill purchases (\d+) to (\d+) months$/i, fr: m => `Achats de bons du Trésor par la Fed de New York (${m[1]} à ${m[2]} mois)` },
  // ── Consommation, revenus, entreprises ──
  { re: /^personal income$/i, fr: 'Revenus des ménages' },
  { re: /^personal spending$/i, fr: 'Dépenses des ménages' },
  { re: /^real personal spending$/i, fr: 'Dépenses réelles des ménages' },
  { re: /^real consumer spending$/i, fr: 'Consommation réelle des ménages' },
  { re: /^household spending$/i, fr: 'Dépenses des ménages' },
  { re: /^household consumption$/i, fr: 'Consommation des ménages' },
  { re: /^consumer spending$/i, fr: 'Dépenses de consommation' },
  { re: /^corporate profits$/i, fr: 'Bénéfices des entreprises' },
  { re: /^business investment$/i, fr: 'Investissement des entreprises' },
  { re: /^wholesale inventories$/i, fr: 'Stocks des grossistes' },
  { re: /^wholesale sales$/i, fr: 'Ventes des grossistes' },
  { re: /^new car registrations$/i, fr: 'Immatriculations de voitures neuves' },
  { re: /^new car sales$/i, fr: 'Ventes de voitures neuves' },
  { re: /^total vehicle sales$/i, fr: 'Ventes totales de véhicules' },
  { re: /^omdia total vehicle sales$/i, fr: 'Ventes totales de véhicules (Omdia)' },
  { re: /^redbook$/i, fr: 'Ventes hebdomadaires (Redbook)' },
  // ── Énergie et matières premières ──
  { re: /^api crude oil stock change$/i, fr: 'Stocks de pétrole brut (API)' },
  { re: /^api weekly statistical bulletin$/i, fr: 'Bulletin pétrolier hebdomadaire (API)' },
  { re: /^(?:eia crude oil stocks change|crude oil inventories)$/i, fr: 'Stocks de pétrole brut (EIA)' },
  { re: /^eia crude oil imports change$/i, fr: 'Importations de pétrole brut (EIA)' },
  { re: /^eia cushing crude oil stocks change$/i, fr: 'Stocks de pétrole brut à Cushing (EIA)' },
  { re: /^eia distillate fuel production change$/i, fr: 'Production de distillats (EIA)' },
  { re: /^eia distillate stocks change$/i, fr: 'Stocks de distillats (EIA)' },
  { re: /^eia gasoline production change$/i, fr: 'Production d’essence (EIA)' },
  { re: /^eia gasoline stocks change$/i, fr: 'Stocks d’essence (EIA)' },
  { re: /^eia heating oil stocks change$/i, fr: 'Stocks de fioul domestique (EIA)' },
  { re: /^(?:eia natural gas stocks change|natural gas storage)$/i, fr: 'Stocks de gaz naturel (EIA)' },
  { re: /^eia refinery crude runs change$/i, fr: 'Pétrole brut raffiné (EIA)' },
  { re: /^baker hughes oil rig count$/i, fr: 'Plateformes pétrolières en activité (Baker Hughes)' },
  { re: /^baker hughes total rigs? count$/i, fr: 'Plateformes de forage en activité (Baker Hughes)' },
  { re: /^quarterly grain stocks - (corn|soy|wheat)$/i, fr: m => `Stocks trimestriels de céréales (${{ corn: 'maïs', soy: 'soja', wheat: 'blé' }[m[1].toLowerCase()]})` },
  // ── Banques centrales ──
  { re: /^(?:fed interest rate decision|federal funds rate)$/i, fr: 'Décision de taux de la Fed' },
  { re: /^(\w+) interest rate decision$/i, fr: m => econBankOf(m[1]) ? `Décision de taux ${econBankOf(m[1])}` : null },
  // Intitulés ForexFactory (source de secours) des décisions de taux.
  { re: /^cash rate$/i, fr: 'Décision de taux de la RBA' },
  { re: /^official cash rate$/i, fr: 'Décision de taux de la RBNZ' },
  { re: /^main refinancing rate$/i, fr: 'Décision de taux de la BCE' },
  { re: /^official bank rate$/i, fr: 'Décision de taux de la BoE' },
  { re: /^overnight rate$/i, fr: 'Décision de taux de la BoC' },
  { re: /^(snb|boj) policy rate$/i, fr: m => `Décision de taux ${econBankOf(m[1])}` },
  { re: /^(\w+) rate statement$/i, fr: m => econBankOf(m[1]) ? `Communiqué ${econBankOf(m[1])}` : null },
  { re: /^(\w+) (?:monetary policy )?press conference$/i, fr: m => econBankOf(m[1]) ? `Conférence de presse ${econBankOf(m[1])}` : null },
  { re: /^fomc (?:meeting )?minutes$/i, fr: 'Compte rendu du FOMC' },
  { re: /^fomc statement$/i, fr: 'Communiqué du FOMC' },
  { re: /^fomc economic projections$/i, fr: 'Projections économiques du FOMC' },
  { re: /^(\w+) monetary policy meeting minutes$/i, fr: m => econBankOf(m[1]) ? `Compte rendu de politique monétaire ${econBankOf(m[1])}` : null },
  { re: /^monetary policy meeting minutes$/i, fr: 'Compte rendu de politique monétaire' },
  { re: /^(\w+) monetary policy statement$/i, fr: m => econBankOf(m[1]) ? `Déclaration de politique monétaire ${econBankOf(m[1])}` : null },
  { re: /^(\w+) summary of opinions$/i, fr: m => econBankOf(m[1]) ? `Résumé des opinions ${econBankOf(m[1])}` : null },
  { re: /^(?:boe financial policy committee record|fpc meeting minutes)$/i, fr: 'Compte rendu du comité de politique financière de la BoE' },
  { re: /^fpc statement$/i, fr: 'Communiqué du comité de politique financière de la BoE' },
  { re: /^(\w+) financial stability (?:review|report)$/i, fr: m => econBankOf(m[1]) ? `Rapport sur la stabilité financière ${econBankOf(m[1])}` : null },
  { re: /^ecb general council meeting$/i, fr: 'Réunion du conseil général de la BCE' },
  { re: /^ecb non-monetary policy meeting$/i, fr: 'Réunion non monétaire de la BCE' },
  { re: /^snb quarterly bulletin$/i, fr: 'Bulletin trimestriel de la BNS' },
  // ── Divers et jours fériés ──
  { re: /^un general assembly$/i, fr: 'Assemblée générale de l’ONU' },
  { re: /^president trump and president xi summit$/i, fr: 'Sommet Trump–Xi' },
  { re: /^respect for the aged day$/i, fr: 'Jour férié (fête des personnes âgées)' },
  { re: /^autumnal equinox day$/i, fr: 'Jour férié (équinoxe d’automne)' },
  { re: /^vernal equinox day$/i, fr: 'Jour férié (équinoxe de printemps)' },
  { re: /^german unity day$/i, fr: 'Jour férié (fête de l’Unité allemande)' },
  { re: /^national day for truth and reconciliation$/i, fr: 'Jour férié (Journée de la vérité et de la réconciliation)' },
  { re: /^thanksgiving day$/i, fr: 'Jour férié (Thanksgiving)' },
  { re: /^independence day$/i, fr: 'Jour férié (fête de l’Indépendance)' },
  { re: /^labou?r day$/i, fr: 'Jour férié (fête du Travail)' },
  { re: /^christmas day$/i, fr: 'Jour férié (Noël)' },
  { re: /^new year'?s day$/i, fr: 'Jour férié (Nouvel An)' },
  { re: /^good friday$/i, fr: 'Jour férié (Vendredi saint)' },
  { re: /^easter monday$/i, fr: 'Jour férié (lundi de Pâques)' },
  { re: /^(?:bank )?holiday$/i, fr: 'Jour férié' },
  { re: /^daylight saving time shift$/i, fr: 'Changement d’heure' },
  { re: /^columbus day$/i, fr: 'Jour férié (Columbus Day)' },
  { re: /^health and sports day$/i, fr: 'Jour férié (journée du sport)' },
  { re: /^early close bond market$/i, fr: 'Fermeture anticipée du marché obligataire' },
  // ── Compléments : titres du flux /cal2 restés en anglais ──
  // Emploi et salaires
  { re: /^anz-indeed job ads$/i, fr: 'Offres d’emploi (ANZ-Indeed)' },
  { re: /^average cash earnings$/i, fr: 'Salaire nominal moyen' },
  { re: /^average hourly wages$/i, fr: 'Salaire horaire moyen' },
  { re: /^overtime pay$/i, fr: 'Rémunération des heures supplémentaires' },
  { re: /^philly fed employment$/i, fr: 'Emploi (Fed de Philadelphie)' },
  // Prix
  { re: /^cpi common$/i, fr: 'Inflation, mesure commune (BoC)' },
  { re: /^cpi median$/i, fr: 'Inflation médiane (BoC)' },
  { re: /^cpi trimmed-mean$/i, fr: 'Inflation moyenne tronquée (BoC)' },
  { re: /^cpi s\.?a\.?$/i, fr: 'Inflation (IPC, corrigée des variations saisonnières)' },
  { re: /^food inflation$/i, fr: 'Inflation alimentaire' },
  { re: /^ppi ex food, energy and trade$/i, fr: 'Prix à la production hors alimentation, énergie et commerce' },
  { re: /^producer (?:&|and) import prices$/i, fr: 'Prix à la production et à l’importation' },
  { re: /^wholesale prices$/i, fr: 'Prix de gros' },
  { re: /^used car prices$/i, fr: 'Prix des voitures d’occasion' },
  { re: /^(?:global dairy trade|gdt) price index$/i, fr: 'Indice des prix des produits laitiers (GDT)' },
  { re: /^philly fed prices paid$/i, fr: 'Prix payés (Fed de Philadelphie)' },
  // Activité, enquêtes
  { re: /^ai group (construction|industry|manufacturing) index$/i, fr: m => `${{ construction: 'Indice de la construction', industry: 'Indice de l’industrie', manufacturing: 'Indice manufacturier' }[m[1].toLowerCase()]} (Ai Group)` },
  { re: /^atlanta fed gdpnow$/i, fr: 'Estimation du PIB GDPNow (Fed d’Atlanta)' },
  { re: /^gdp 3-month avg$/i, fr: 'PIB, moyenne sur 3 mois' },
  { re: /^niesr monthly gdp tracker$/i, fr: 'Estimation mensuelle du PIB (NIESR)' },
  { re: /^boc business outlook survey$/i, fr: 'Enquête sur les perspectives des entreprises (BoC)' },
  { re: /^boc survey of consumer expectations$/i, fr: 'Enquête sur les attentes des consommateurs (BoC)' },
  { re: /^business nz pmi$/i, fr: 'PMI manufacturier (BusinessNZ)' },
  { re: /^services nz psi$/i, fr: 'Indice des services (BusinessNZ)' },
  { re: /^composite nz pci$/i, fr: 'Indice composite d’activité (BusinessNZ)' },
  { re: /^ivey pmi(?: s\.?a\.?)?$/i, fr: 'PMI Ivey' },
  { re: /^s&p global construction pmi$/i, fr: 'PMI de la construction (S&P Global)' },
  { re: /^construction pmi$/i, fr: 'PMI de la construction' },
  { re: /^(?:eco watchers survey current|economy watchers sentiment)$/i, fr: 'Enquête Eco Watchers (situation actuelle)' },
  { re: /^eco watchers survey outlook$/i, fr: 'Enquête Eco Watchers (perspectives)' },
  { re: /^reuters tankan index$/i, fr: 'Indice Tankan (Reuters)' },
  { re: /^tertiary industry index$/i, fr: 'Indice du secteur tertiaire' },
  { re: /^lmi logistics managers index$/i, fr: 'Indice des directeurs logistiques (LMI)' },
  { re: /^nfib business optimism index$/i, fr: 'Optimisme des petites entreprises (NFIB)' },
  { re: /^rcm\/tipp economic optimism(?: index)?$/i, fr: 'Optimisme économique (RCM/TIPP)' },
  { re: /^ny fed services activity index$/i, fr: 'Activité des services (Fed de New York)' },
  { re: /^philly fed business conditions$/i, fr: 'Conditions d’activité (Fed de Philadelphie)' },
  { re: /^philly fed capex index$/i, fr: 'Intentions d’investissement (Fed de Philadelphie)' },
  { re: /^philly fed new orders$/i, fr: 'Nouvelles commandes (Fed de Philadelphie)' },
  { re: /^westpac leading index$/i, fr: 'Indicateur avancé (Westpac)' },
  { re: /^fed beige book$/i, fr: 'Livre beige de la Fed' },
  { re: /^machine tool orders$/i, fr: 'Commandes de machines-outils' },
  { re: /^machinery orders$/i, fr: 'Commandes de machines' },
  { re: /^business inventories$/i, fr: 'Stocks des entreprises' },
  // Consommation, immobilier
  { re: /^brc retail sales monitor$/i, fr: 'Ventes au détail à périmètre constant (BRC)' },
  { re: /^retail sales control group$/i, fr: 'Ventes au détail (groupe de contrôle)' },
  { re: /^retail sales ex gas\/autos$/i, fr: 'Ventes au détail hors essence et automobile' },
  { re: /^electronic retail card spending$/i, fr: 'Dépenses par carte dans le commerce de détail' },
  { re: /^new motor vehicle sales$/i, fr: 'Ventes de véhicules neufs' },
  { re: /^visitor arrivals$/i, fr: 'Arrivées de visiteurs' },
  { re: /^consumer credit change$/i, fr: 'Variation du crédit à la consommation' },
  { re: /^bank lending$/i, fr: 'Prêts bancaires' },
  { re: /^bba mortgage rate$/i, fr: 'Taux hypothécaire (BBA)' },
  { re: /^lloyds (?:house price index|hpi)$/i, fr: 'Indice des prix immobiliers (Lloyds)' },
  { re: /^rics house price balance$/i, fr: 'Solde d’opinion sur les prix immobiliers (RICS)' },
  { re: /^nahb housing market index$/i, fr: 'Indice du marché immobilier (NAHB)' },
  // Échanges, flux, budget
  { re: /^goods trade balance non-eu$/i, fr: 'Balance commerciale des biens hors UE' },
  { re: /^foreign (?:exchange|currency) reserves$/i, fr: 'Réserves de change' },
  { re: /^foreign securities purchases by canadians$/i, fr: 'Achats de titres étrangers par les Canadiens' },
  { re: /^foreign securities purchases$/i, fr: 'Achats de titres canadiens par les étrangers' },
  { re: /^net long-term tic flows$/i, fr: 'Flux nets de capitaux à long terme (TIC)' },
  { re: /^overall net capital flows$/i, fr: 'Flux nets de capitaux (TIC)' },
  { re: /^monthly budget statement$/i, fr: 'Solde budgétaire mensuel' },
  // Banques centrales, réunions, rapports
  { re: /^ecb monetary policy meeting accounts$/i, fr: 'Compte rendu de politique monétaire de la BCE' },
  { re: /^(rba|boj|boc|rbnz|snb|boe) meeting minutes$/i, fr: m => econBankOf(m[1]) ? `Compte rendu de politique monétaire ${econBankOf(m[1])}` : null },
  { re: /^ecofin meetings?$/i, fr: 'Réunion de l’Ecofin' },
  { re: /^eurogroup meetings?$/i, fr: 'Réunion de l’Eurogroupe' },
  { re: /^eia short-term energy outlook$/i, fr: 'Perspectives énergétiques à court terme (EIA)' },
  { re: /^iea oil market report$/i, fr: 'Rapport sur le marché pétrolier (AIE)' },
  { re: /^nopa crush report$/i, fr: 'Trituration de soja (NOPA)' },
  { re: /^wasde report$/i, fr: 'Rapport WASDE sur l’offre et la demande agricoles' },
  { re: /^reserve balances with fed banks$/i, fr: 'Réserves des banques auprès de la Fed' },
  { re: /^halifax (?:house price index|hpi)$/i, fr: 'Indice des prix immobiliers (Halifax)' },
  { re: /^boe credit conditions survey$/i, fr: 'Enquête sur les conditions de crédit (BoE)' },
  { re: /^anz commodity prices$/i, fr: 'Prix des matières premières (ANZ)' },
  { re: /^sentix investor confidence$/i, fr: 'Confiance des investisseurs (Sentix)' },
  { re: /^westpac consumer sentiment$/i, fr: 'Moral des consommateurs (Westpac)' },
  { re: /^anz job advertisements$/i, fr: 'Offres d’emploi (ANZ-Indeed)' },
  { re: /^housing equity withdrawal$/i, fr: 'Retraits de capital immobilier' },
  { re: /^leading indicators$/i, fr: 'Indicateur avancé' },
  { re: /^mi inflation expectations$/i, fr: 'Anticipations d’inflation (Melbourne Institute)' },
  { re: /^seco consumer climate$/i, fr: 'Climat de consommation (SECO)' },
  { re: /^opec-jmmc meetings?$/i, fr: 'Réunion du comité de suivi de l’OPEP+ (JMMC)' },
  { re: /^opec meetings?$/i, fr: 'Réunion de l’OPEP' },
  { re: /^opec\+ meetings?$/i, fr: 'Réunion de l’OPEP+' },
  { re: /^(g7|g20) meetings?$/i, fr: m => `Réunion du ${m[1].toUpperCase()}` },
  { re: /^imf meetings?$/i, fr: 'Réunions du FMI' },
  { re: /^wef annual meetings?$/i, fr: 'Forum économique mondial (Davos)' },
];

// « Fed Goolsbee Speech » → « Discours de Goolsbee (Fed) » ;
// « ECB President Lagarde Speech » → « Discours de Lagarde (BCE) » ;
// « MPC Member Mann Speaks » → « Discours de Mann (BoE) » ;
// « Gov Council Member Rogers Speaks » → « Discours de Rogers (BoC) ».
//
// Garde commune à toutes les formes : ce qui reste après la fonction doit être
// un NOM. S'il compte plus de trois mots ou contient encore un mot de fonction
// (« Treasury Sec Bessent », « Chair Nominee Warsh »), la fonction n'a pas été
// reconnue : on rend null et le titre d'origine s'affiche en entier, plutôt
// qu'une demi-traduction (« Discours de Treasury Sec Bessent »).
const ECON_SPEECH_ROLE_WORD = /(?:^|\s)(?:gov|governor|member|board|council|committee|chair|chairman|chairwoman|vice|sec|secretary|treasury|deputy|senior|assist|asst|assistant|president|minister|chief|economist|head|director|managing|external|executive|supervisory|nominee|mpc|fomc|for|of|the)(?:\s|$)/i;
function econSpeechFr(raw) {
  const kindOf = (w) => /^spe/i.test(w) ? 'Discours' : 'Audition';
  const ok = (name) => name.trim().split(/\s+/).length <= 3 && !ECON_SPEECH_ROLE_WORD.test(name);
  let m = raw.match(/^(Fed|FOMC|ECB|BoE|MPC|BoJ|RBA|RBNZ|SNB|BoC|Bundesbank|Buba|PBoC|Riksbank)\s+(?:(?:Supervisory Board Member|Executive Board Member|Governing Council Member|Gov Council Member|Board Member|MPC Member|External Member|Member|Vice President|President|Senior Deputy Governor|Senior Deputy Gov|Deputy Governor|Deputy Gov|Assistant Governor|Assist Gov|Asst Gov|Governor|Gov|Vice Chair for Supervision|Vice Chairman|Vice Chair|Chairman|Chairwoman|Chair|Chief Economist)\s+)*(.+?)\s+(Speech|Speaks|Testimony|Testifies)$/i);
  if (m) return ok(m[2]) ? `${kindOf(m[3])} ${econDe(m[2])} (${ECON_BANK_FR[m[1].toLowerCase()]})` : null;
  // Intitulés ForexFactory sans sigle : conseil de direction de la BoC
  // (« Gov Council ») et de la BNS (« Gov Board »).
  m = raw.match(/^Gov (Council|Board) Member\s+(.+?)\s+(Speech|Speaks|Testimony|Testifies)$/i);
  if (m) return ok(m[2]) ? `${kindOf(m[3])} ${econDe(m[2])} (${/^council$/i.test(m[1]) ? 'BoC' : 'BNS'})` : null;
  m = raw.match(/^President\s+(.+?)\s+(Speech|Speaks)$/i);
  if (m) return ok(m[1]) ? `Discours du président ${m[1]}` : null;
  // Responsables politiques : « Chancellor John Healey Speech » →
  // « Discours de John Healey (chancelier) ».
  m = raw.match(/^(Chancellor|Treasury Secretary|Treasury Sec|Finance Minister|Prime Minister|PM)\s+(.+?)\s+(Speech|Speaks|Testimony|Testifies)$/i);
  if (m) return ok(m[2]) ? `${kindOf(m[3])} ${econDe(m[2])} (${ECON_ROLE_FR[m[1].toLowerCase()]})` : null;
  // Repli : aucun « Speech » ne reste en anglais (« Healey Speaks » →
  // « Discours de Healey »). Un intervenant précédé d'un sigle inconnu
  // (« ECB's Lane », « IMF Georgieva ») n'est pas recomposé : titre d'origine.
  m = raw.match(/^([A-Z][\p{L}.\-]*(?:\s+[A-Z][\p{L}.\-]*){0,3})\s+(Speech|Speaks|Testimony|Testifies)$/u);
  if (m && !/['’]/.test(m[1]) && !/^[A-Z]{2,}\b/.test(m[1]) && ok(m[1])) return `${kindOf(m[2])} ${econDe(m[1])}`;
  return null;
}
const ECON_ROLE_FR = {
  chancellor: 'chancelier', 'treasury secretary': 'secrétaire au Trésor', 'treasury sec': 'secrétaire au Trésor',
  'finance minister': 'ministre des Finances', 'prime minister': 'Premier ministre', pm: 'Premier ministre',
};

// « 3-Month Bill Auction » → « Adjudication de bons du Trésor à 3 mois » ;
// « 2036 OAT Auction » → « Adjudication d’OAT échéance 2036 ».
function econAuctionFr(raw) {
  const inst = (s) => ECON_INSTR_FR[String(s).toLowerCase()] || null;
  let m = raw.match(/^(\d+)-(Month|Week|Year|Day|y)\s+(.+?)\s+Auction$/i);
  if (m && inst(m[3])) {
    const n = Number(m[1]);
    const u = m[2].toLowerCase();
    const unitFr = u === 'month' ? 'mois'
      : u === 'week' ? (n > 1 ? 'semaines' : 'semaine')
        : u === 'day' ? (n > 1 ? 'jours' : 'jour')
          : (n > 1 ? 'ans' : 'an');
    return `Adjudication ${econDe(inst(m[3]))} à ${n} ${unitFr}`;
  }
  m = raw.match(/^(?:Treasury\s+)?(.+?)\s+(\d{4})\s+Auction$/i);
  if (m && inst(m[1])) return `Adjudication ${econDe(inst(m[1]))} échéance ${m[2]}`;
  m = raw.match(/^(\d{4})\s+(.+?)\s+Auction$/i);
  if (m && inst(m[2])) return `Adjudication ${econDe(inst(m[2]))} échéance ${m[1]}`;
  // Plusieurs échéances : « 15/20/30-Year Bund Auction » →
  // « Adjudication de Bund à 15, 20 et 30 ans ».
  m = raw.match(/^(\d+(?:\/\d+)+)-(?:Year|y)\s+(.+?)\s+Auction$/i);
  if (m && inst(m[2])) {
    const ns = m[1].split('/');
    return `Adjudication ${econDe(inst(m[2]))} à ${ns.slice(0, -1).join(', ')} et ${ns[ns.length - 1]} ans`;
  }
  // Sans échéance : « OAT Auction », « Bond Auction ».
  m = raw.match(/^(.+?)\s+Auction$/i);
  if (m && inst(m[1])) return `Adjudication ${econDe(inst(m[1]))}`;
  // Gilts britanniques hors adjudication classique.
  m = raw.match(/^New\s+(.+?)\s+(\d{4})$/i);
  if (m && inst(m[1])) return `Émission ${econDe(inst(m[1]))} échéance ${m[2]}`;
  m = raw.match(/^(.+?)\s+Tender$/i);
  if (m && inst(m[1])) return `Appel d’offres ${econDe(inst(m[1]))}`;
  return null;
}

// Recompose « base (étiquettes) » + périodicité : la périodicité s'insère
// avant la parenthèse finale, les étiquettes s'y ajoutent.
// « Inflation (IPC) » + « sur un an » + « flash » → « Inflation sur un an (IPC, flash) ».
function econTitleCompose(fr, per, extra) {
  const pm = fr.match(/^(.*\S)\s*\(([^()]*)\)$/);
  let head = pm ? pm[1] : fr;
  const tags = pm ? [pm[2]] : [];
  extra.forEach(x => { if (x) tags.push(x); });
  if (per) head += ' ' + per;
  return tags.length ? `${head} (${tags.join(', ')})` : head;
}

// Premier motif de ECON_TITLE_FR qui reconnaît le titre (ou null).
function econTitleLookup(t) {
  for (const r of ECON_TITLE_FR) {
    const mm = t.match(r.re);
    if (!mm) continue;
    const fr = typeof r.fr === 'function' ? r.fr(mm) : r.fr;
    if (fr) return fr;
  }
  return null;
}

function econTitleFrRaw(raw) {
  // Titre complet d'abord : « German Unity Day » ne doit pas perdre son
  // « German » avant d'être reconnu.
  const direct = econTitleLookup(raw);
  if (direct) return direct;
  let t = raw, per = '', status = '', country = '', m;
  // Préfixes ForexFactory : pays (« German ») puis état (« Prelim »).
  for (let guard = 0; guard < 4; guard++) {
    m = t.match(/^(German|French|Italian|Spanish)\s+(.+)$/i);
    if (m && !country) { country = ECON_TITLE_COUNTRY[m[1].toLowerCase()]; t = m[2]; continue; }
    m = t.match(/^(Prelim|Flash|Final|Revised|Advance)\s+(.+)$/i);
    if (m && !status) { status = ECON_TITLE_STATUS[m[1].toLowerCase()]; t = m[2]; continue; }
    break;
  }
  // Discours : la banque centrale suffit (« German Buba President Nagel
  // Speaks » → « Discours de Nagel (Bundesbank) »).
  const sp = econSpeechFr(t);
  if (sp) return sp;
  // Adjudications : le pays émetteur passe en étiquette
  // (« Italian 10-y Bond Auction » → « Adjudication d’obligations à 10 ans (Italie) »).
  const au = econAuctionFr(t);
  if (au) return econTitleCompose(au, '', [country]);
  // Suffixes : périodicité (MoM, y/y…) et état (Final, Prel, Flash, Adv).
  for (let guard = 0; guard < 4; guard++) {
    m = t.match(/^(.+?)\s+(MoM|YoY|QoQ|m\/m|y\/y|q\/q|Final|Prel|Prelim|Flash Estimate|Flash|Adv|Advance|Revised)$/i);
    if (!m) break;
    const k = m[2].toLowerCase();
    if (ECON_TITLE_PER[k]) { if (per) break; per = ECON_TITLE_PER[k]; }
    else { if (status) break; status = ECON_TITLE_STATUS[k]; }
    t = m[1];
  }
  const fr = econTitleLookup(t);
  if (fr) return econTitleCompose(fr, per, [country, status]);
  return raw; // non reconnu : titre d'origine, jamais à moitié traduit
}

const ECON_TITLE_CACHE = new Map();
function econTitleFr(title) {
  const raw = String(title == null ? '' : title).trim();
  if (!raw) return '—';
  if (ECON_TITLE_CACHE.has(raw)) return ECON_TITLE_CACHE.get(raw);
  let out = raw;
  try { out = econTitleFrRaw(raw) || raw; } catch (err) { out = raw; }
  ECON_TITLE_CACHE.set(raw, out);
  return out;
}

// Pays affiché après le titre d'un événement en euro : la même publication
// existe pour la France, l'Allemagne, l'Italie… et la zone euro (PMI flash,
// inflation préliminaire), avec la même pastille EUR. Sans le pays, les lignes
// seraient indiscernables. Rien pour les autres devises (un seul pays), ni
// quand le titre nomme déjà le pays ou la BCE (discours, décision de taux).
function econCountrySuffix(e, fr) {
  if (!e || e.cur !== 'EUR') return '';
  const name = COUNTRY_NAMES[e.flag];
  if (!name) return '';
  const low = String(fr || '').toLowerCase();
  if (low.includes(name.toLowerCase())) return '';
  if (Object.values(ECON_TITLE_COUNTRY).some(c => low.includes(c.toLowerCase()))) return '';
  if (e.flag === 'EU' && (e.isSpeech || /\bbce\b|\becb\b|\bue\b|l’ue\b/i.test(low))) return '';
  return name;
}

// Titre affiché + info-bulle (titre d'origine) quand il a été traduit.
function EconTitle({ e }) {
  const fr = econTitleFr(e.title);
  const country = econCountrySuffix(e, fr);
  return (
    <span title={fr !== e.title ? e.title : undefined}>
      {e.isSpeech && <EconSpeechIco/>}
      {fr}
      {country && <span style={{ color: 'var(--fg-3)', fontWeight: 500 }}>{' · ' + country}</span>}
    </span>
  );
}

// Icône « discours » (micro), à la place de l'ancien emoji.
function EconSpeechIco({ size = 13 }) {
  return (
    <svg className="ico" role="img" aria-label="Discours" width={size} height={size} viewBox="0 0 20 20"
      style={{ display: 'inline-block', verticalAlign: '-2px', marginRight: 6, flexShrink: 0, opacity: .75 }}>
      <title>Discours</title>
      <rect x="7.5" y="2.5" width="5" height="9" rx="2.5"/>
      <path d="M4.5 9.5a5.5 5.5 0 0011 0M10 15v2.5M7 17.5h6"/>
    </svg>
  );
}

// Période couverte : « Sep » → « sept. », « Sep/25 » → « semaine au 25 sept. »,
// « Q2 » → « T2 ». La table anglaise ne sert qu'à LIRE la source.
const ECON_EN_MONTH_IDX = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 };
function econPeriodFr(p) {
  const s = String(p || '').trim();
  if (!s) return '';
  let m = s.match(/^([A-Za-z]{3})[A-Za-z]*\.?(?:\/(\d{1,2}))?$/);
  if (m && ECON_EN_MONTH_IDX[m[1].toLowerCase()] != null) {
    const mo = FR_MONTHS_SHORT[ECON_EN_MONTH_IDX[m[1].toLowerCase()]];
    return m[2] ? `semaine au ${Number(m[2])} ${mo}` : mo;
  }
  m = s.match(/^Q([1-4])$/i);
  if (m) return `T${m[1]}`;
  m = s.match(/^H([12])$/i);
  if (m) return m[1] === '1' ? '1er semestre' : '2e semestre';
  return s;
}

// Explication courte par TYPE d'événement (détection par mots-clés sur le titre
// anglais d'origine). Tombe sur un libellé générique si rien ne matche.
// 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 et PCE sous-jacent : 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 l’inflation brute. 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 des prix à la consommation." },
  { 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 aux États-Unis, 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 américain 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|retail sales ex autos/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 et ISM : enquêtes d’activité (au-dessus de 50 = expansion, en dessous = 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 ou 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ée, 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 pour la devise, restrictif = haussier) plus que tout chiffre." },
  { re: /fomc|meeting minutes|monetary policy|account of the/i, txt: "Compte rendu ou réunion de banque centrale : le marché y cherche des indices sur la suite des taux." },
  // ── Externe / sentiment / divers ──
  { re: /trade balance|balance of trade|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 et 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é|respect for the aged|equinox day|unity day|national day|thanksgiving|independence day|labou?r day|christmas day|new year'?s day|good friday|easter monday/i, txt: "Jour férié : 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.";
  // Événement mondial (source de secours, sans devise) : pas de « la devise ».
  if (!e.cur) {
    const world = e.impact === 'high'
      ? "Impact fort : volatilité forte attendue sur l’ensemble des marchés, prudence 5 min avant à 10 min après."
      : e.impact === 'medium'
        ? "Impact modéré : les marchés peuvent réagir si l’issue s’écarte de ce qui est attendu."
        : "Impact faible : effet généralement limité sur les marchés.";
    return `Événement mondial, suivi par tous les marchés. ${world}`;
  }
  return `Événement suivi par les marchés (${e.cur}). ${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é par rapport à la prévision.";
  return "Impact faible — peu de mouvement attendu.";
}

// Écart entre deux valeurs d'un même indicateur (nombres, même échelle).
// `dir` = sens de l'indicateur (econDirection) : +1 plus haut = meilleur,
// -1 plus bas = meilleur (chômage, inscriptions), 0 = ambigu (inflation…).
// La couleur suit le SENS, pas le signe brut : un CPI au-dessus du consensus
// n'est pas « vert », et un taux de chômage en hausse est « rouge ». Sans dir
// (ou dir 0), couleur neutre — le signe de l'écart reste affiché.
// Libellé : « −0,3 pt », « +16 pts » pour un indicateur en %, sinon la valeur
// signée avec son échelle et son unité (« +12 k », « −1,2 Md £ »).
function deltaInfo(a, ref, dir, unit, scale, dec) {
  if (a == null || ref == null) return null;
  const d = dec == null ? Math.max(econDec(a), econDec(ref)) : dec;
  const diff = a - ref;
  const p = Math.pow(10, d);
  const r = Math.round(Math.abs(diff) * p) / p;
  if (r === 0) return { color: 'var(--fg-3)', label: 'identique' };
  const label = unit === '%'
    ? fmtNum(diff, d, true) + ' ' + (r >= 2 ? 'pts' : 'pt')
    : econFmtVal(diff, unit, scale, true, d);
  if (!dir) return { color: 'var(--fg-2)', label };
  const better = dir > 0 ? diff > 0 : diff < 0;
  return { color: better ? 'var(--green)' : 'var(--red)', label };
}

// « lundi 21 septembre · 08:30 (heure de New York) », dans le fuseau du profil.
function exactLocalDateTime(e) {
  if (!e.ts) return e.time || '—';
  const tz = econTz();
  const dt = new Date(e.ts);
  return `${fmtDayLong(localDayInTz(dt, tz), tz)} · ${tzTimeIn(dt, tz)} (${tzHourLabel(tz)})`;
}

// Titre de la page : table unique NAV_LABELS (core.jsx), la même sur
// ordinateur et sur mobile.
function econPageTitle() {
  const n = (typeof NAV_LABELS !== 'undefined' && NAV_LABELS && NAV_LABELS.econ) || null;
  return (n && n.titre) || 'Calendrier économique';
}

function econCap(s) {
  return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
}

// En-tête de jour, identique sur ordinateur et sur mobile :
// « Aujourd’hui · dimanche 27 septembre », « Demain · lundi 28 septembre »,
// « Mardi 29 septembre ».
function econDayLabel(d, today, tomorrow) {
  if (!d) return '—';
  const long = fmtDayLong(d, econTz());
  if (d === today) return `Aujourd’hui · ${long}`;
  if (d === tomorrow) return `Demain · ${long}`;
  return econCap(long);
}

// ── Notes « Mon plan » 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 false;
    const all = readEconNotes();
    if (text && text.trim()) all[id] = text.trim(); else delete all[id];
    localStorage.setItem(ECON_NOTES_KEY, JSON.stringify(all));
    return true;
  } catch (e) {
    console.warn('[Tempo] econ : note non enregistrée', e && e.message);
    return false;
  }
}

// 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 h 20 » / « il y a 2 j 5 h »
// pour le tampon de dernière mise à jour (durées : fmtDuration, charte §4).
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`;
  return `il y a ${fmtDuration(Math.floor(s / 60))}`;
}

// Reprise des notes écrites avant le passage à l'identifiant indépendant du
// fuseau. L'ancienne clé portait le jour et l'heure LOCAUX de l'événement :
//   « 2026-10-07-14:30-USD-unemployment-rate[~n] »
// On ne sait pas dans quel fuseau elle a été écrite (il a pu changer depuis :
// c'est justement ce qui rendait les notes orphelines). On lit donc la clé
// elle-même : même devise, même titre, et un instant compatible avec cette
// heure locale dans n'importe quel fuseau (décalage de −12 h à +14 h).
// - un seul instant candidat : la note y est déplacée ;
// - plusieurs (même intitulé deux fois en moins de 26 h) : seulement si la clé
//   se recalcule à l'identique dans le fuseau actuel, sinon on ne devine pas ;
// - une note déjà présente sous le nouvel identifiant n'est jamais écrasée.
// Une ancienne clé sans événement correspondant reste en place : purgeEconNotes
// l'efface au bout de 7 jours, comme toute note orpheline. Appelée à chaque
// chargement réussi, avant l'affichage ; sans effet dès qu'il n'y a plus
// d'ancienne clé.
const ECON_LEGACY_NOTE_RE = /^(\d{4})-(\d{2})-(\d{2})-(\d{2}):(\d{2})-(.+)$/;
function migrateLegacyEconNotes(events) {
  try {
    if (typeof localStorage === 'undefined') return;
    const notes = readEconNotes();
    const olds = Object.keys(notes).filter(k => ECON_LEGACY_NOTE_RE.test(k));
    if (!olds.length) return;
    const byTail = new Map();
    for (const e of events) {
      if (!e || !e.ts) continue;
      const tail = `${e.global ? 'ALL' : e.cur}-${econSlug(e.title)}`;
      if (!byTail.has(tail)) byTail.set(tail, []);
      byTail.get(tail).push(e);
    }
    const tz = econTz();
    let changed = false;
    for (const k of olds) {
      const m = k.match(ECON_LEGACY_NOTE_RE);
      let tail = m[6], dup = 0;
      const dm = tail.match(/^(.*)~(\d+)$/);
      if (dm) { tail = dm[1]; dup = Number(dm[2]); }
      const asUtc = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]);
      const cands = (byTail.get(tail) || []).filter(e => asUtc - e.ts >= -12 * 3600e3 && asUtc - e.ts <= 14 * 3600e3);
      if (!cands.length) continue;
      const times = [...new Set(cands.map(e => e.ts))];
      let ts = times.length === 1 ? times[0] : null;
      if (ts == null) {
        const hit = cands.find(e => `${localDayInTz(new Date(e.ts), tz)}-${tzTimeIn(new Date(e.ts), tz)}` === k.slice(0, 16));
        if (hit) ts = hit.ts;
      }
      if (ts == null) continue;
      const target = cands.filter(e => e.ts === ts)[dup];
      if (!target || notes[target.id]) continue;
      notes[target.id] = notes[k];
      delete notes[k];
      changed = true;
    }
    if (changed) localStorage.setItem(ECON_NOTES_KEY, JSON.stringify(notes));
  } catch (e) {}
}
// Changement de source (principale ⇄ secours) : les deux flux n'intitulent pas
// un même événement de la même façon (« Non Farm Payrolls » contre « Non-Farm
// Employment Change »), donc son identifiant change. Avant d'afficher le
// nouveau flux, chaque note d'un événement affiché jusque-là et absent du
// nouveau flux est déplacée vers l'événement de même instant, même devise et
// même libellé affiché (titre français + pays, sans accents). Jamais
// d'écrasement d'une note existante ; fonctionne dans les deux sens. Un
// événement que l'autre source nomme ou date autrement n'est pas reconnu : sa
// note reste sous son identifiant et réapparaît au retour de sa source (dans
// la limite des 7 jours de purgeEconNotes).
// L'identifiant ne repose volontairement PAS sur le titre français : chaque
// retouche d'un libellé de ECON_TITLE_FR rendrait des notes orphelines.
function econMatchKey(e) {
  const fr = econTitleFr(e.title);
  const cur = (e.global || e.cur === 'ALL') ? '' : e.cur;
  return `${e.ts}|${cur}|${econSlug(String(fr + ' ' + econCountrySuffix(e, fr)).normalize('NFD').replace(/[\u0300-\u036f]/g, ''))}`;
}
// Table { ancien identifiant → nouvel identifiant } de TOUS les événements
// affichés jusque-là, absents du nouveau flux sous leur identifiant, et
// reconnus dans ce flux (même instant, même devise, même libellé affiché).
// Deux événements de même clé (deux discours du même intervenant au même
// instant) sont appariés par rang, entre identifiants disparus et apparus.
// Sert aux notes (reattachEconNotes) et à la ligne ouverte, qui doit rester
// ouverte même sans note. Ne lève jamais ; table vide quand aucun identifiant
// ne change (cas de presque tous les chargements).
function econIdRemap(prevEvents, events) {
  const map = {};
  try {
    const next = (events || []).filter(e => e && e.ts);
    const prev = (prevEvents || []).filter(p => p && p.ts);
    const nextIds = new Set(next.map(e => e.id));
    const prevIds = new Set(prev.map(p => p.id));
    // Seuls les identifiants disparus cherchent un repreneur, et seulement
    // parmi les identifiants apparus : un événement resté sous le même
    // identifiant n'est ni déplacé ni pris pour cible.
    const gone = prev.filter(p => !nextIds.has(p.id));
    if (!gone.length) return map;
    const group = (arr) => {
      const m = new Map();
      for (const e of arr) { const k = econMatchKey(e); if (!m.has(k)) m.set(k, []); m.get(k).push(e); }
      return m;
    };
    const goneBy = group(gone), freshBy = group(next.filter(e => !prevIds.has(e.id)));
    for (const [k, olds] of goneBy) {
      const news = freshBy.get(k);
      if (!news) continue;
      olds.forEach((p, i) => { if (news[i]) map[p.id] = news[i].id; });
    }
  } catch (e) {}
  return map;
}
// `remap` (facultatif) : table déjà calculée par econIdRemap pour ces deux listes.
function reattachEconNotes(prevEvents, events, remap) {
  try {
    if (typeof localStorage === 'undefined') return;
    const notes = readEconNotes();
    const map = remap || econIdRemap(prevEvents, events);
    let changed = false;
    for (const from of Object.keys(map)) {
      const to = map[from];
      if (!notes[from] || notes[to]) continue;
      notes[to] = notes[from];
      delete notes[from];
      changed = true;
    }
    if (changed) localStorage.setItem(ECON_NOTES_KEY, JSON.stringify(notes));
  } catch (e) {}
}

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: () => {} };
}

// Route / HISTORIQUE (repli FairEconomy) : 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();
  }
}

// Route /cal2 (nouveau worker → calendrier TradingView, 2 semaines, avec
// actual). Accepte un tableau nu ou une enveloppe { result: [...] } /
// { events: [...] } — le contrat exact reste tolérant.
async function fetchCal2() {
  const url = getEconProxyURL() + '/cal2';
  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);
    const body = await res.json();
    const data = Array.isArray(body) ? body
      : (body && Array.isArray(body.result)) ? body.result
        : (body && Array.isArray(body.events)) ? body.events
          : null;
    if (!data) throw new Error('Réponse invalide');
    // Enveloppe du nouveau worker : { updatedAt, source, events } — `source`
    // ('tradingview' | 'faireconomy') fait foi quand elle est présente.
    const envSrc = (body && !Array.isArray(body) && typeof body.source === 'string') ? body.source : null;
    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, envSrc };
  } finally {
    t.done();
  }
}

// Stratégie de source : /cal2 d'abord, repli automatique sur la route /
// historique (comportement v34 à l'identique). ATTENTION : l'ANCIEN worker
// déployé répond 200 à /cal2 avec la MÊME payload FairEconomy que / (il ignore
// le chemin) — le repli se décide donc par la FORME des items (importance =
// TradingView), jamais par le code HTTP. `src` : 'tradingview' | 'faireconomy'.
async function fetchEconAny() {
  try {
    const r = await fetchCal2();
    if (r.data.length) {
      // Nouveau worker : l'enveloppe { source, events } fait foi.
      // 'faireconomy' = repli SERVEUR (fenêtre semaine FF, sans actual) →
      // mêmes limites d'affichage que le repli client (onglet « Semaine
      // prochaine » masqué, libellé « non fournie par la source »).
      if (r.envSrc === 'tradingview') return { ...r, src: 'tradingview' };
      if (r.envSrc === 'faireconomy') return { ...r, src: 'faireconomy' };
      // Sans enveloppe : détection par la forme des items (TV brut normalisé
      // par le client, ou payload /cal2 déjà normalisée sans enveloppe).
      if (r.data.some(x => isTVShape(x) || isCal2Shape(x))) return { ...r, src: 'tradingview' };
      // Ancien worker (payload FairEconomy servie sur /cal2) : mêmes données
      // que la route / — inutile de refaire un second fetch.
      return { ...r, src: 'faireconomy' };
    }
    // 200 [] : suspect (fenêtre 2 semaines jamais vide) → repli route /.
  } catch (e) {
    console.warn('[Tempo] econ : /cal2 indisponible, repli sur la source de secours', e && e.message);
  }
  const r = await fetchEcon();
  return { ...r, src: 'faireconomy' };
}

// 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; }
}

// Cache V2 : mêmes garanties que le v1 + champ `src`. Migration douce : si la
// clé v2 est absente (première ouverture après mise à jour du client), on lit
// l'ancienne clé v1 telle quelle (events FairEconomy, src 'faireconomy') pour
// garder le rendu instantané — la clé v1 n'est plus écrite et expirera seule
// (rétention 7 jours), ce qui préserve aussi un éventuel retour arrière du client.
// Événements relus du cache : un cache écrit avant la correction de la source
// de secours range encore les événements mondiaux sous une pseudo-devise
// « ALL ». On les remet en forme à la lecture (l'identifiant ne change pas,
// leurs notes restent donc attachées) : pas de puce « ALL » en attendant le
// prochain chargement réussi.
// Un cache local abîmé (élément null, sans identifiant, sans titre…) ne doit
// jamais atteindre l'écran : seuls les événements bien formés sont gardés
// (même contrôle que pour le flux, econValidEvent).
function econCachedEvents(list) {
  return list.filter(econValidEvent).map(e => (e.cur === 'ALL') ? { ...e, cur: '', flag: '', global: true } : e);
}
// Événements d'un cache relu, ou null quand le cache contenait des éléments
// mais qu'aucun n'est lisible : il est alors ignoré (chargement visible), au
// lieu d'afficher un calendrier vide « à jour ». Un cache réellement vide
// (liste vide enregistrée telle quelle) reste un cache valide.
function econCacheEventsOrNull(list) {
  const events = econCachedEvents(list);
  return (list.length && !events.length) ? null : events;
}

function readEconCacheV2() {
  try {
    const raw = (typeof localStorage !== 'undefined') ? localStorage.getItem(ECON_CACHE_V2_KEY) : null;
    if (!raw) {
      const v1 = readEconCache();
      // Repli v1 : rendu instantané conservé, mais marqué périmé pour que le
      // fetch silencieux /cal2 parte immédiatement — sinon un cache v1 frais
      // (<TTL) bloquerait la colonne Publié jusqu'à 10 min après la mise à jour.
      const v1Events = v1 ? econCacheEventsOrNull(v1.events) : null;
      return v1Events ? { ...v1, events: v1Events, src: 'faireconomy', stale: true } : 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;
    const events = econCacheEventsOrNull(obj.events);
    if (!events) return null;
    return {
      ts: obj.ts,
      at: Number.isFinite(obj.at) ? obj.at : obj.ts,
      srvStale: !!obj.srvStale,
      src: obj.src === 'tradingview' ? 'tradingview' : 'faireconomy',
      events,
      stale: age > ECON_CACHE_TTL_MS,
    };
  } catch (e) { return null; }
}

function writeEconCacheV2(events, cachedAt, srvStale, src) {
  try {
    if (typeof localStorage === 'undefined') return;
    localStorage.setItem(ECON_CACHE_V2_KEY, JSON.stringify({
      ts: Date.now(),
      at: Number.isFinite(cachedAt) ? cachedAt : Date.now(),
      srvStale: !!srvStale,
      src: src === 'tradingview' ? 'tradingview' : 'faireconomy',
      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.
// `onIdRemap` (facultatif) : appelé juste avant l'affichage d'un nouveau flux
// avec la table { ancien identifiant → nouvel identifiant } des événements
// reconnus sous un autre identifiant (changement de source), dans le même lot
// de mises à jour que les événements : l'écran peut faire suivre ce qui est
// rangé par identifiant (la ligne ouverte) sans rendu intermédiaire.
function useEconomicEvents(now, onIdRemap) {
  // 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.
  // Sans cache : liste vide + « Chargement du calendrier… ».
  const [events, setEvents] = React.useState(() => readEconCacheV2()?.events || []);
  // Source réellement servie : 'tradingview' (/cal2, avec actual) ou
  // 'faireconomy' (repli v34, sans actual).
  const [src, setSrc] = React.useState(() => readEconCacheV2()?.src || 'faireconomy');
  // 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 = readEconCacheV2();
    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 = readEconCacheV2();
    return !!(c && c.srvStale);
  });
  const [loading, setLoading] = React.useState(() => !readEconCacheV2());
  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);
  // Miroirs events/src pour la fenêtre « publication imminente » (lus dans
  // l'effet d'horloge sans re-souscrire à chaque refresh).
  const eventsRef = React.useRef(events);
  const srcRef = React.useRef(src);
  const onIdRemapRef = React.useRef(onIdRemap);
  onIdRemapRef.current = onIdRemap;

  // Un fetch (/cal2 avec repli automatique). `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(''); }
    // Échec (source injoignable, ou réponse dont aucun élément n'est lisible).
    // On NE vide NI les events NI le cache — les dernières données connues
    // restent affichées — et la prochaine tentative silencieuse est espacée.
    const fail = (why) => {
      inFlightRef.current = false;
      failsRef.current += 1;
      if (!mountedRef.current) return false;
      console.warn('[Tempo] econ : source indisponible, cache conservé', why);
      if (!silent) { setError('unavailable'); setLoading(false); }
      return false;
    };
    let res;
    try {
      res = await fetchEconAny();
    } catch (e) {
      // Échec total (ex. 429 amont, timeout).
      return fail(e && e.message);
    }
    // Conversion protégée : un élément illisible est écarté seul. Une réponse
    // qui contenait des éléments mais dont aucun n'est lisible est une panne ;
    // une réponse réellement vide reste un succès (semaine sans événement).
    const { events: out, dropped } = econConvertFeed(res.data);
    if (dropped && !out.length) return fail('réponse illisible');
    inFlightRef.current = false;
    failsRef.current = 0;
    if (!mountedRef.current) return false;
    if (dropped) console.warn(`[Tempo] econ : ${dropped} événement(s) illisible(s) écarté(s)`);
    // Une note « Mon plan » en cours de frappe est écrite tout de suite : elle
    // doit exister AVANT que les notes ne suivent leur événement, sinon les
    // derniers mots resteraient rangés sous l'ancien identifiant.
    econFlushPendingNotes();
    migrateLegacyEconNotes(out);
    const remap = econIdRemap(eventsRef.current, out);
    reattachEconNotes(eventsRef.current, out, remap);
    if (onIdRemapRef.current && Object.keys(remap).length) onIdRemapRef.current(remap);
    setEvents(out);
    eventsRef.current = out;
    setSrc(res.src);
    srcRef.current = res.src;
    const upd = new Date(res.cachedAt || Date.now());
    setLastUpdate(upd);
    lastUpdateRef.current = upd.getTime();
    setStale(!!res.stale);
    setError('');
    if (!silent) setLoading(false);
    writeEconCacheV2(out, res.cachedAt, res.stale, res.src);
    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 (tick === 0) {
      const c = readEconCacheV2();
      if (c && !c.stale) return;
      load(!!c);
      return;
    }
    load(false);
  }, [tick, 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).
  //
  // Fenêtre « publication imminente » (source TradingView uniquement — le repli
  // FairEconomy n'aura jamais d'actual) : un événement Élevé/Moyen vient de
  // passer (< 45 min) et sa valeur publiée manque encore → TTL abaissé à 5 min
  // le temps de l'attraper. Anti-boucle : mêmes gardes inFlight / lastTry /
  // backoff que la cadence normale, et la fenêtre se referme seule (45 min
  // écoulées OU valeur arrivée) — retour automatique à la cadence 10 min.
  React.useEffect(() => {
    if (!now) return;
    const nowMs = now.getTime();
    const fast = srcRef.current === 'tradingview' && (eventsRef.current || []).some(e =>
      e && e.ts && (e.impact === 'high' || e.impact === 'medium') &&
      econCell(e, 'actual') == null && nowMs - e.ts > 0 && nowMs - e.ts <= ECON_FAST_WINDOW_MS
    );
    const ttl = fast ? ECON_FAST_TTL_MS : ECON_CACHE_TTL_MS;
    if (nowMs - (lastUpdateRef.current || 0) <= ttl) return;
    if (inFlightRef.current) return;
    const wait = failsRef.current > 0
      ? Math.min(15 * 60e3, 60e3 * Math.pow(2, failsRef.current - 1))
      : ttl;
    if (nowMs - (lastTryRef.current || 0) < wait) return;
    load(true);
  }, [now, 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 (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);
  }, [load]);

  // Jour et heure toujours re-projetés dans le fuseau ACTUEL du profil (un
  // changement de fuseau dans Réglages s'applique sans attendre un refresh).
  // L'objet d'origine est conservé quand rien ne change (lignes mémoïsées).
  const tz = econTz();
  const shown = React.useMemo(() => events.map(e => {
    if (!e || !e.ts) return e;
    const dt = new Date(e.ts);
    const d = localDayInTz(dt, tz), time = tzTimeIn(dt, tz);
    return (d === e.d && time === e.time) ? e : { ...e, d, time };
  }), [events, tz]);

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

  return { events: shown, src, lastUpdate, loading, error, stale, refresh };
}

function ImpactDot({ impact, size = 8 }) {
  const c = IMPACT_COLORS[impact] || IMPACT_COLORS.low;
  const label = `Impact ${(IMPACT_LABEL[impact] || IMPACT_LABEL.low).toLowerCase()}`;
  return (
    <span role="img" aria-label={label} title={label} style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: size + 6, height: size + 6, borderRadius: '50%', background: c.soft, flexShrink: 0 }}>
      <span style={{ width: size, height: size, borderRadius: '50%', background: c.dot }}></span>
    </span>
  );
}

// Code devise (« JPY »). `onDark` : variante pour le bandeau toujours sombre.
function CurrencyChip({ cur, onDark }) {
  return (
    <span className="mono" style={{
      display: 'inline-flex', alignItems: 'center', padding: '3px 8px', borderRadius: 999,
      fontSize: 10.5, fontWeight: 600, lineHeight: 1.3, whiteSpace: 'nowrap', flexShrink: 0,
      color: onDark ? 'var(--on-accent, #fff)' : 'var(--fg)',
      background: onDark ? 'rgba(255,255,255,.12)' : 'var(--bg-soft)',
      border: onDark ? '1px solid rgba(255,255,255,.22)' : '1px solid var(--line)',
    }}>{cur || 'Monde'}</span>
  );
}

// Colonne « Publié ». Avec la source TradingView (/cal2), la valeur publiée
// EXISTE : affichée dès parution, colorée par rapport à la prévision
// UNIQUEMENT quand le sens de l'indicateur est sans ambiguïté (voir
// econDirection — inflation, taux, balances… restent neutres). Sans valeur :
// « — » avec une infobulle honnête selon le cas (pas encore publiée / à venir /
// source de secours FairEconomy qui ne fournit pas de valeur publiée).
// Délai au-delà duquel une valeur toujours absente n'est plus « en attente » :
// la source ne la communiquera pas (ne pas suggérer une parution imminente).
const ECON_ACTUAL_GRACE_MS = 2 * 3600e3;
function econActualOverdue(e) {
  return !!(e && e.ts && Date.now() - e.ts > ECON_ACTUAL_GRACE_MS);
}
function ActualVsForecast({ e, isPast, live, size = 12.5, role }) {
  const shown = econCell(e, 'actual');
  if (shown == null) {
    const tip = live
      ? (isPast ? (econActualOverdue(e) ? 'Valeur non communiquée par la source' : 'Pas encore publiée') : 'Publication à venir')
      : 'Valeur publiée non fournie par la source';
    return <EconVal text={null} size={size} title={tip} role={role}/>;
  }
  const v = econNums(e);
  const fShown = econCell(e, 'forecast');
  const dir = econDirection(e.title);
  let color = 'var(--fg)';
  let tip = fShown != null ? `Publié : ${shown} · Prévision : ${fShown}` : `Publié : ${shown}`;
  if (v.a != null && v.f != null && dir !== 0 && !econSame(v.a, v.f, v.dec)) {
    const better = dir > 0 ? v.a > v.f : v.a < v.f;
    color = better ? 'var(--green)' : 'var(--red)';
    tip = better
      ? `Meilleur que la prévision (${fShown})`
      : `Moins bon que la prévision (${fShown})`;
  }
  return <span role={role} className="mono" title={tip} style={{ fontSize: size, fontWeight: 600, color, letterSpacing: '-.005em', whiteSpace: 'nowrap' }}>{shown}</span>;
}

// Valeur d'une colonne chiffrée (Prévision, Précédent…). Absente : « — »
// discret, rendu identique dans toutes les colonnes, sur ordinateur et mobile.
// `role` (facultatif) : "cell" quand la valeur est une cellule du tableau
// ordinateur (voir EventRow) ; absent partout ailleurs.
function EconVal({ text, color = 'var(--fg-2)', weight = 400, size = 12.5, title, role }) {
  const has = text != null && text !== '';
  return (
    <span role={role} className="mono" title={title} style={{ fontSize: size, color: has ? color : 'var(--fg-3)', fontWeight: has ? weight : 400, whiteSpace: 'nowrap' }}>
      {has ? text : '—'}
    </span>
  );
}

// Bandeau « Prochain événement » (ordinateur et mobile, même composant) :
// prochain événement Élevé/Moyen à venir, compte à rebours (fmtDuration) et
// date + heure dans le fuseau du profil.
function NextEventBanner({ events, now, mobile }) {
  const nowMs = now.getTime();
  let next = null;
  for (const e of events) {
    if (e && e.ts && e.ts > nowMs && (e.impact === 'high' || e.impact === 'medium') && (!next || e.ts < next.ts)) next = e;
  }
  if (!next) return null;
  const tz = econTz();
  const mins = Math.floor((next.ts - nowMs) / 60000);
  // Moins d'une minute : fmtDuration donne « < 1 min » pour toute durée qui
  // s'arrondit à 0 (0,4 min), jamais « 1 min » par excès.
  const countdown = fmtDuration(mins >= 1 ? mins : 0.4);
  const when = `${econCap(fmtWeekdayShort(next.d, tz))} ${fmtDayShort(next.d, tz)} · ${next.time}`;
  const soft = 'var(--on-accent-soft, rgba(255,255,255,0.55))';
  const kicker = { fontSize: mobile ? 10.5 : 11, fontWeight: 500, color: soft, letterSpacing: '.06em', textTransform: 'uppercase' };
  const title = (
    <div style={{ fontSize: mobile ? 14.5 : 16, fontWeight: 600, letterSpacing: '-.01em', lineHeight: 1.3, minWidth: 0 }}>
      <EconTitle e={next}/>
    </div>
  );
  const clock = (
    <div style={{ display: 'flex', flexDirection: 'column', alignItems: mobile ? 'flex-start' : 'flex-end', gap: 2 }}>
      <span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 8 }}>
        <span style={{ ...kicker, letterSpacing: '.04em' }}>Dans</span>
        <span className="mono" style={{ fontSize: mobile ? 20 : 22, fontWeight: 700, letterSpacing: '-.018em', color: 'var(--on-accent-green, #86efac)', whiteSpace: 'nowrap' }}>{countdown}</span>
      </span>
      <span style={{ fontSize: 11.5, color: 'rgba(255,255,255,.78)', whiteSpace: 'nowrap' }}>{when}</span>
    </div>
  );
  // .on-dark : contour de focus clair sur ce bandeau toujours sombre (charte §8).
  return (
    <div className="on-dark" style={{
      position: 'relative', overflow: 'hidden',
      background: 'var(--hero-grad, linear-gradient(135deg, #0f172a 0%, #1e293b 60%, #0c4a6e 100%))',
      borderRadius: 14, padding: mobile ? '14px 16px' : '16px 22px', color: 'var(--on-accent, #fff)',
      ...(mobile ? {} : { 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>
      {mobile ? (
        <React.Fragment>
          <div style={{ ...kicker, marginBottom: 6 }}>Prochain événement</div>
          <div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
            <span style={{ marginTop: 2, flexShrink: 0, display: 'inline-flex' }}><ImpactDot impact={next.impact} size={9}/></span>
            <div style={{ flex: 1, minWidth: 0 }}>{title}</div>
          </div>
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginTop: 10 }}>
            {clock}
            <CurrencyChip cur={next.cur} onDark/>
          </div>
        </React.Fragment>
      ) : (
        <React.Fragment>
          <div style={{ display: 'flex', alignItems: 'center', gap: 14, minWidth: 0 }}>
            <ImpactDot impact={next.impact} size={10}/>
            <div style={{ minWidth: 0 }}>
              <div style={{ ...kicker, marginBottom: 4 }}>Prochain événement</div>
              {title}
            </div>
          </div>
          <div style={{ display: 'flex', alignItems: 'center', gap: 22, flexShrink: 0 }}>
            {clock}
            <CurrencyChip cur={next.cur} onDark/>
          </div>
        </React.Fragment>
      )}
    </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 « Détails » (ordinateur et mobile) : quand, pays, période, valeurs
// publiée / prévision / précédente et écarts.
function EconFacts({ e, isPast, live }) {
  const v = econNums(e);
  const dir = econDirection(e.title);
  const dF = deltaInfo(v.a, v.f, dir, v.unit, v.scale, v.dec);
  const dP = deltaInfo(v.a, v.p, dir, v.unit, v.scale, v.dec);
  const forecast = econCell(e, 'forecast');
  const prev = econCell(e, 'prev');
  const period = econPeriodFr(e.period);
  return (
    <React.Fragment>
      <FactRow label="Quand">
        <span style={{ color: isPast ? 'var(--fg-3)' : 'var(--fg)' }}>{exactLocalDateTime(e)}</span>
      </FactRow>
      <FactRow label="Pays / devise">
        {e.cur ? <React.Fragment>{(COUNTRY_NAMES[e.flag] || CUR_TO_COUNTRY[e.cur] || e.cur)} <span className="mono" style={{ color: 'var(--fg-3)', fontSize: 11.5 }}>({e.cur})</span></React.Fragment> : 'Monde'}
      </FactRow>
      {period ? <FactRow label="Période couverte">{period}</FactRow> : null}
      <FactRow label="Publié">
        {econCell(e, 'actual') != null
          ? <ActualVsForecast e={e} isPast={isPast} live={live}/>
          : (live
            ? (isPast && econActualOverdue(e)
              ? <span title="La source n’a pas communiqué la valeur publiée" style={{ color: 'var(--fg-3)', fontSize: 11.5 }}>non communiquée par la source</span>
              : <span title={isPast ? 'La valeur publiée apparaîtra ici dès sa parution' : 'La valeur publiée apparaîtra ici après l’annonce'} style={{ color: 'var(--fg-3)', fontSize: 11.5 }}>{isPast ? 'pas encore publiée' : 'à venir'}</span>)
            : <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">
        <EconVal text={forecast} weight={500}/>
      </FactRow>
      <FactRow label="Précédent">
        <EconVal text={prev} color="var(--fg-3)"/>
      </FactRow>
      {econAgencyFr(e.agency, e.flag) ? <FactRow label="Source officielle"><span title={e.agency}>{econAgencyFr(e.agency, e.flag)}</span></FactRow> : null}
      {(dF || dP) && (
        <div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 6 }}>
          {dF && <div style={{ fontSize: 11.5, color: 'var(--fg-3)' }}>{'Par rapport à la prévision : '}<span className="mono" style={{ color: dF.color, fontWeight: 600, whiteSpace: 'nowrap' }}>{dF.label}</span></div>}
          {dP && <div style={{ fontSize: 11.5, color: 'var(--fg-3)' }}>{'Par rapport au précédent : '}<span className="mono" style={{ color: dP.color, fontWeight: 600, whiteSpace: 'nowrap' }}>{dP.label}</span></div>}
        </div>
      )}
    </React.Fragment>
  );
}

// Champs « Mon plan » ouverts : chacun inscrit ici de quoi écrire sans attendre
// sa saisie en cours. useEconomicEvents s'en sert juste avant de faire suivre
// les notes vers un nouveau flux (changement de source).
const ECON_NOTE_FLUSHERS = new Set();
function econFlushPendingNotes() {
  ECON_NOTE_FLUSHERS.forEach(fn => { try { fn(); } catch (e) {} });
}

// Encart « Mon plan » : note d'intention locale, persistée en localStorage.
// Sauvegarde automatique : écriture 500 ms après la dernière frappe, et
// IMMÉDIATE au démontage (ligne repliée, autre ligne ouverte, filtre changé) —
// plus aucune saisie perdue. Suppression avec confirmation en ligne.
// Si l'appareil refuse l'écriture, l'état passe à 'error' (message rouge).
// `focusSel` (facultatif) : [début, fin] de la sélection à rétablir avec le
// focus, quand le champ est recréé sous un nouvel identifiant en pleine saisie.
function TradePlanNote({ e, autoFocus, focusSel, onSaved }) {
  const [text, setText] = React.useState(() => readEconNotes()[e.id] || '');
  const [status, setStatus] = React.useState('idle'); // 'idle' | 'saving' | 'saved' | 'error'
  const [confirmDel, setConfirmDel] = React.useState(false);
  const ref = React.useRef(null);
  const idRef = React.useRef(e.id);
  const textRef = React.useRef(text);   // dernier texte saisi
  const savedRef = React.useRef(text);  // dernier texte écrit
  const timerRef = React.useRef(null);
  const aliveRef = React.useRef(true);
  const onSavedRef = React.useRef(onSaved);
  onSavedRef.current = onSaved;

  // Écrit la note si elle a changé. Rend false (rien à écrire), 'saved' ou
  // 'error'. En cas d'échec (stockage plein ou désactivé), savedRef n'avance
  // pas : la frappe suivante et la sortie du champ retentent l'écriture, et
  // l'écran dit « Non enregistré » au lieu d'affirmer « Enregistré ».
  const flush = () => {
    if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; }
    if (textRef.current === savedRef.current) return false;
    if (!writeEconNote(idRef.current, textRef.current)) return 'error';
    savedRef.current = textRef.current;
    if (onSavedRef.current) onSavedRef.current();
    return 'saved';
  };

  // État affiché après un passage par flush() : son résultat s'il a écrit
  // ('saved') ou échoué ('error'). S'il n'y avait rien à écrire (retour au
  // texte déjà enregistré), « Enregistrement… » ne doit pas rester affiché :
  // « Enregistré » si une note existe, sinon l'état de repos. Un état 'error'
  // ou 'saved' déjà affiché n'est pas touché.
  const settle = (r) => {
    if (!aliveRef.current) return;
    setStatus(s => r || (s === 'saving' ? (savedRef.current.trim() ? 'saved' : 'idle') : s));
  };

  React.useEffect(() => {
    const el = ref.current;
    if (!autoFocus || !el) return;
    el.focus();
    if (Array.isArray(focusSel)) {
      try { el.setSelectionRange(focusSel[0], focusSel[1]); } catch (err) {}
    }
  }, [autoFocus]);
  // Démontage : on écrit tout de suite ce qui n'a pas encore été enregistré.
  // Tant que le champ est ouvert, il peut aussi être écrit à la demande
  // (econFlushPendingNotes).
  React.useEffect(() => {
    aliveRef.current = true;
    const flushNow = () => settle(flush());
    ECON_NOTE_FLUSHERS.add(flushNow);
    return () => { ECON_NOTE_FLUSHERS.delete(flushNow); aliveRef.current = false; flush(); };
  }, []);

  const onChange = (value) => {
    textRef.current = value;
    setText(value);
    setStatus('saving');
    setConfirmDel(false);
    if (timerRef.current) clearTimeout(timerRef.current);
    timerRef.current = setTimeout(() => {
      timerRef.current = null;
      settle(flush());
    }, 500);
  };

  const remove = () => {
    if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; }
    setConfirmDel(false);
    // Suppression non écrite : le texte reste à l'écran, avec l'erreur.
    if (!writeEconNote(idRef.current, '')) { setStatus('error'); return; }
    textRef.current = '';
    savedRef.current = '';
    setText('');
    setStatus('idle');
    if (onSavedRef.current) onSavedRef.current();
  };

  const fr = econTitleFr(e.title);
  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}
        aria-label="Mon plan pour cet événement"
        onChange={ev => onChange(ev.target.value)}
        onBlur={() => settle(flush())}
        data-econ-note={e.id}
        onClick={ev => ev.stopPropagation()}
        placeholder="Biais, niveaux à surveiller, scénario si surprise haussière ou baissière…"
        className="input"
        style={{ minHeight: 64, resize: 'vertical', lineHeight: 1.5 }}
      />
      {confirmDel ? (
        <div role="alertdialog" aria-label="Supprimer ton plan" style={{ marginTop: 10, padding: '10px 12px', borderRadius: 9, background: 'var(--red-soft)', border: '1px solid var(--line)' }}>
          <div style={{ fontSize: 12.5, color: 'var(--fg)', lineHeight: 1.45, marginBottom: 8 }}>
            {`Supprimer ton plan pour « ${fr} » ? La note sera effacée.`}
          </div>
          <div style={{ display: 'flex', gap: 8 }}>
            <button type="button" onClick={ev => { ev.stopPropagation(); remove(); }} className="btn tap" style={{ fontSize: 12, background: 'var(--sell-bg, #dc2626)', borderColor: 'var(--sell-bg, #dc2626)', color: '#fff' }}>
              Supprimer
            </button>
            <button type="button" onClick={ev => { ev.stopPropagation(); setConfirmDel(false); }} className="btn tap" style={{ fontSize: 12, color: 'var(--fg)' }}>
              Annuler
            </button>
          </div>
        </div>
      ) : (
        <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 8, minHeight: 28 }}>
          <span aria-live="polite" style={{ fontSize: 11.5, display: 'inline-flex', alignItems: 'center', gap: 4, color: status === 'saved' ? 'var(--green-text)' : status === 'error' ? 'var(--red-text)' : 'var(--fg-3)', fontWeight: (status === 'saved' || status === 'error') ? 500 : 400 }}>
            {status === 'error' && 'Non enregistré : le stockage de cet appareil est plein ou désactivé.'}
            {status === 'saving' && 'Enregistrement…'}
            {status === 'saved' && <React.Fragment><span aria-hidden="true" style={{ display: 'inline-flex' }}>{React.cloneElement(Ico.check, { width: 13, height: 13 })}</span>Enregistré</React.Fragment>}
            {status === 'idle' && 'Sauvegarde automatique'}
          </span>
          <span style={{ flex: 1 }}></span>
          {(text.trim() || savedRef.current.trim()) && (
            <button type="button" onClick={ev => { ev.stopPropagation(); setConfirmDel(true); }} className="btn tap" style={{ fontSize: 12, color: 'var(--red-text)' }}>
              Supprimer
            </button>
          )}
        </div>
      )}
    </div>
  );
}

// Bouton « Mon plan » (ordinateur et mobile). Coché quand une note existe.
function EconPlanBtn({ hasNote, onClick }) {
  return (
    <button
      type="button"
      onClick={ev => { ev.stopPropagation(); onClick(); }}
      className="tap"
      title={hasNote ? 'Voir ou modifier mon plan pour cet événement' : 'Noter mon plan pour cet événement'}
      aria-label={hasNote ? 'Mon plan, déjà noté' : 'Mon plan'}
      style={{
        display: 'inline-flex', alignItems: 'center', gap: 4,
        background: hasNote ? 'var(--blue-soft)' : 'transparent', color: 'var(--blue-600)',
        border: '1px solid var(--blue-border)', borderRadius: 7,
        padding: '3px 9px', fontSize: 11, fontWeight: 500, flexShrink: 0,
        cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap',
      }}>
      {hasNote && <span aria-hidden="true" style={{ display: 'inline-flex' }}>{React.cloneElement(Ico.check, { width: 12, height: 12 })}</span>}
      Mon plan
    </button>
  );
}

// Chevron d'ouverture du détail : vrai bouton (atteignable avec Tab, état
// ouvert ou fermé annoncé). Volontairement sans onClick : le clic, donc aussi
// Entrée et Espace, remonte à la ligne, qui ouvre ou replie une seule fois.
// La ligne elle-même ne reçoit pas role="button" : elle contient déjà le
// bouton « Mon plan », et un bouton dans un bouton est invalide.
function EconChevBtn({ e, open }) {
  const fr = econTitleFr(e.title);
  const country = econCountrySuffix(e, fr);
  return (
    <button type="button" className="tap" aria-expanded={!!open} aria-label={`Détail : ${fr}${country ? ' · ' + country : ''}`} style={{
      display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
      background: 'transparent', border: 0, padding: 0, margin: 0, borderRadius: 6, cursor: 'pointer',
      color: 'var(--fg-3)', fontFamily: 'inherit',
      transform: open ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform .2s var(--ease)',
    }}>{Ico.chev}</button>
  );
}

// Explication + conseil + pastille d'impact (en-tête du détail).
// Les teintes douces sont translucides : posées telles quelles, leur contraste
// dépendrait de la surface du dessous (le détail est sur --bg-soft, où « Impact
// moyen » tombait à 4,46:1). La pastille pose donc sa teinte sur le fond de
// carte : même rendu, et un contraste d'au moins 4,5:1 où qu'elle soit placée.
function EconImpactPill({ impact }) {
  const c = IMPACT_COLORS[impact] || IMPACT_COLORS.low;
  return (
    <span className="pill" style={{ fontSize: 10.5, padding: '2px 8px', background: `linear-gradient(${c.soft}, ${c.soft}) var(--bg-card)`, color: c.text }}>
      {`Impact ${(IMPACT_LABEL[impact] || IMPACT_LABEL.low).toLowerCase()}`}
    </span>
  );
}

function EconExplain({ e, mb }) {
  return (
    <React.Fragment>
      <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 ${mb}px` }}>{impactAdvice(e.impact)}</p>
    </React.Fragment>
  );
}

function EconFFLink() {
  return (
    <a href={FOREXFACTORY_CAL_URL} target="_blank" rel="noopener noreferrer" onClick={ev => ev.stopPropagation()} className="btn tap" style={{ fontSize: 12, textDecoration: 'none', color: 'var(--fg)' }}>
      Voir sur ForexFactory ↗
    </a>
  );
}

// Grille commune de la liste ordinateur (en-tête + lignes).
const ECON_GRID = '64px 22px 72px minmax(0, 1fr) 100px 100px 100px 96px 22px';
// Colonnes de cette grille, dans l'ordre. `h` = en-tête affiché ; `name` = nom
// lu par un lecteur d'écran quand l'en-tête n'affiche rien (pastille d'impact,
// chevron). La liste ordinateur est un vrai tableau pour les lecteurs d'écran
// (role table / row / columnheader / cell) : « 4,3 % 4,2 % 4,3 % » est annoncé
// avec sa colonne (Publié, Prévision, Précédent).
const ECON_COLS = [
  { h: 'Heure' }, { h: '', name: 'Impact' }, { h: 'Devise' }, { h: 'Événement' },
  { h: 'Publié' }, { h: 'Prévision' }, { h: 'Précédent' }, { h: 'Mon plan' }, { h: '', name: 'Détail' },
];

// Événement passé : heure et titre en teintes atténuées (contraste ≥ 4,5:1),
// jamais une opacité sur toute la ligne — la valeur publiée, l'information
// clé une fois l'événement passé, reste pleinement lisible (ordinateur et mobile).
const ECON_PAST = { time: 'var(--fg-3)', title: 'var(--fg-2)' };

// 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, planSel, live }) {
  // Re-lecture de la note pour rafraîchir le bouton « Mon plan » après écriture.
  const [noteVer, setNoteVer] = React.useState(0);
  const hasNote = React.useMemo(() => !!readEconNotes()[e.id], [e.id, noteVer, isExpanded]);
  const canPlan = ((e.impact === 'high' || e.impact === 'medium') && !isPast) || hasNote;
  const forecast = econCell(e, 'forecast');
  const prev = econCell(e, 'prev');

  return (
    <React.Fragment>
      <div role="row" onClick={() => onToggle('details')} className="tap" style={{
        display: 'grid', gridTemplateColumns: ECON_GRID,
        padding: '12px 20px', borderBottom: '1px solid var(--line)',
        alignItems: 'center', gap: 8, cursor: 'pointer',
        background: isNow ? 'var(--blue-soft-2, var(--blue-soft))' : 'var(--bg-card)',
        transition: 'background .4s var(--ease)',
        position: 'relative',
      }}>
        {isNow && <span aria-hidden="true" style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 3, background: 'var(--blue)' }}></span>}
        <span role="cell" className="mono" style={{ fontSize: 12.5, fontWeight: 500, color: isPast ? ECON_PAST.time : 'var(--fg-2)' }}>{e.time}</span>
        <span role="cell" style={{ display: 'flex', alignItems: 'center' }}><ImpactDot impact={e.impact}/></span>
        <span role="cell"><CurrencyChip cur={e.cur}/></span>
        <span role="cell" style={{ fontSize: 13, fontWeight: 500, letterSpacing: '-.005em', minWidth: 0, overflowWrap: 'anywhere', color: isPast ? ECON_PAST.title : 'var(--fg)' }}>
          <EconTitle e={e}/>
        </span>
        <ActualVsForecast e={e} isPast={isPast} live={live} role="cell"/>
        <EconVal text={forecast} weight={500} role="cell"/>
        <EconVal text={prev} color="var(--fg-3)" role="cell"/>
        <span role="cell">
          {canPlan && <EconPlanBtn hasNote={hasNote} onClick={() => onToggle('plan')}/>}
        </span>
        <span role="cell" style={{ display: 'flex', justifyContent: 'flex-end' }}><EconChevBtn e={e} open={isExpanded}/></span>
      </div>

      {/* Détail : une ligne du tableau à elle seule, sur toutes les colonnes. */}
      {isExpanded && (
        <div role="row" onClick={ev => ev.stopPropagation()} style={{ padding: '18px 40px 22px', background: 'var(--bg-soft)', borderBottom: '1px solid var(--line)' }}>
          <div role="cell" aria-colspan={ECON_COLS.length} style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 320px', gap: 28 }}>
            {/* Colonne gauche : explication + mon plan */}
            <div>
              <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
                {/* Titre du détail : sous-titre du jour (h2), donc h3. */}
                <h3 style={{ margin: 0, fontSize: 15, fontWeight: 600, letterSpacing: '-.01em', lineHeight: 'inherit' }}>
                  <EconTitle e={e}/>
                </h3>
                <EconImpactPill impact={e.impact}/>
              </div>
              <EconExplain e={e} mb={18}/>
              <TradePlanNote e={e} autoFocus={planFocus} focusSel={planSel} onSaved={() => setNoteVer(v => v + 1)}/>
              <div style={{ marginTop: 16 }}><EconFFLink/></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>
              <EconFacts e={e} isPast={isPast} live={live}/>
            </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 &&
  prev.planSel === next.planSel &&
  prev.live === next.live
);

// 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;
}

// ── Arithmétique de dates ISO (YYYY-MM-DD), sans dérive de fuseau (ancre midi UTC) ──
function addDaysISO(iso, n) {
  const [y, m, d] = iso.split('-').map(Number);
  const dt = new Date(Date.UTC(y, m - 1, d, 12));
  dt.setUTCDate(dt.getUTCDate() + n);
  return dt.toISOString().slice(0, 10);
}
// Lundi de la semaine civile contenant `iso` (semaine lundi → dimanche).
function mondayOfISO(iso) {
  const [y, m, d] = iso.split('-').map(Number);
  const dt = new Date(Date.UTC(y, m - 1, d, 12));
  dt.setUTCDate(dt.getUTCDate() - ((dt.getUTCDay() + 6) % 7));
  return dt.toISOString().slice(0, 10);
}

// ── État partagé ordinateur / mobile : filtres, bornes de dates, groupes ──
function useEconCalendar() {
  const now = useEconClock();
  const [impact, setImpact] = React.useState('all');
  const [currencies, setCurrencies] = React.useState([]);
  const [dateFilter, setDateFilter] = React.useState('today');
  // { id, mode, sel? } — mode 'plan' = ouvert via « Mon plan » (focus la note).
  const [expanded, setExpanded] = React.useState(null);
  // Changement de source : un même événement change d'identifiant. La ligne
  // ouverte suit son événement (elle ne se replie pas), dans le même rendu que
  // le nouveau flux. La ligne est recréée sous son nouvel identifiant : si l'on
  // était en train d'écrire dans « Mon plan », le champ reprend le focus et le
  // curseur retrouve sa place (`sel`). Sinon elle se rouvre sans prendre le
  // focus : un rechargement en arrière-plan ne déplace jamais le curseur de
  // quelqu'un qui est occupé ailleurs.
  const followIdRemap = React.useCallback((map) => {
    let focusedId = null, sel = null;
    try {
      const el = document.activeElement;
      focusedId = (el && el.getAttribute) ? el.getAttribute('data-econ-note') : null;
      if (focusedId) sel = [el.selectionStart, el.selectionEnd];
    } catch (e) {}
    setExpanded(prev => {
      if (!prev || !map[prev.id]) return prev;
      return focusedId === prev.id
        ? { id: map[prev.id], mode: 'plan', sel }
        : { id: map[prev.id], mode: 'details' };
    });
  }, []);
  const data = useEconomicEvents(now, followIdRemap);
  // Source TradingView (/cal2) : fenêtre 2 semaines + valeurs publiées.
  const live = data.src === 'tradingview';

  // Source de secours FairEconomy (semaine en cours uniquement) : l'onglet
  // « Semaine prochaine » disparaît — si l'utilisateur y était, retour sur
  // « Cette semaine ».
  React.useEffect(() => {
    if (!live && dateFilter === 'nextweek') setDateFilter('week');
  }, [live, dateFilter]);

  // 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 && (mode === 'details' || prev.mode === mode) ? null : { id, mode }
  );

  // Jours civils dans le fuseau du profil. « Demain » = jour civil suivant via
  // l'arithmétique ISO (ancre midi UTC), jamais +24 h absolues (changement d'heure).
  const today = localDayInTz(now, econTz());
  const tomorrow = addDaysISO(today, 1);
  // Bornes de semaines civiles (lundi → dimanche). Un dimanche soir, « Demain »
  // (lundi) tombe dans la SEMAINE PROCHAINE, couverte par la fenêtre /cal2.
  const weekStart = mondayOfISO(today);
  const nextWeekStart = addDaysISO(weekStart, 7);
  const nextWeekEnd = addDaysISO(weekStart, 14);

  // Seuls les événements datés sont rendus.
  const dated = data.events.filter(e => e && e.d);
  const realCount = dated.length;

  // Puces de devise : ordre fixe, uniquement celles présentes dans le flux
  // (et toujours celles déjà sélectionnées), puis les autres par ordre alpha.
  const present = new Set(dated.map(e => e.cur).filter(Boolean));
  const curList = [
    ...ECON_CUR_ORDER.filter(c => present.has(c) || currencies.includes(c)),
    ...[...present].filter(c => !ECON_CUR_ORDER.includes(c)).sort(),
  ];

  let filtered = dated;
  if (impact !== 'all') filtered = filtered.filter(e => e.impact === impact);
  if (currencies.length) filtered = filtered.filter(e => !e.cur || currencies.includes(e.cur));
  if (dateFilter === 'today') filtered = filtered.filter(e => e.d === today);
  if (dateFilter === 'tomorrow') filtered = filtered.filter(e => e.d === tomorrow);
  // « Cette semaine » : avec /cal2 (fenêtre 2 semaines), borne à la semaine
  // civile en cours ; en secours FairEconomy, aucune borne (le flux ne couvre
  // que la semaine en cours). Comparaison de chaînes ISO = ordre chronologique.
  if (dateFilter === 'week' && live) filtered = filtered.filter(e => e.d >= weekStart && e.d < nextWeekStart);
  if (dateFilter === 'nextweek') filtered = filtered.filter(e => e.d >= nextWeekStart && e.d < nextWeekEnd);

  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 filtersActive = impact !== 'all' || currencies.length > 0;
  const resetFilters = () => { setImpact('all'); setCurrencies([]); };

  const nowMs = now.getTime();
  const isPast = (e) => !!e.ts && e.ts < nowMs;
  // « 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 - nowMs) <= 5 * 60 * 1000;

  const dateTabs = [
    { id: 'today',    l: 'Aujourd’hui' },
    { id: 'tomorrow', l: 'Demain' },
    { id: 'week',     l: 'Cette semaine' },
    // « Semaine prochaine » : uniquement quand la source couvre réellement
    // 2 semaines (/cal2) — jamais un onglet vide en secours.
    ...(live ? [{ id: 'nextweek', l: 'Semaine prochaine' }] : []),
  ];

  return {
    ...data, now, live, impact, setImpact, currencies, setCurrencies, toggleCurrency, curList,
    dateFilter, setDateFilter, dateTabs, expanded, toggleRow, today, tomorrow,
    realCount, groupByDay, dayKeys, filtersActive, resetFilters, isPast, isNowEvent,
  };
}

// Puce de filtre (ordinateur et mobile) — jamais rétrécie dans une rangée
// scrollable (flexShrink 0 + nowrap).
function EconPillBtn({ on, onClick, color, mono, children, label }) {
  return (
    <button type="button" onClick={onClick} className="tap tap-44" aria-pressed={!!on} aria-label={label} style={{
      padding: '6px 13px', borderRadius: 999, flexShrink: 0, whiteSpace: 'nowrap',
      background: on ? 'var(--blue-soft)' : 'var(--bg-card)',
      border: '1px solid ' + (on ? 'var(--blue)' : 'var(--line)'),
      color: on ? 'var(--blue-600)' : 'var(--fg-2)',
      fontSize: 12, fontWeight: on ? 600 : 500, cursor: 'pointer',
      fontFamily: mono ? 'JetBrains Mono, monospace' : 'inherit',
      display: 'inline-flex', alignItems: 'center', gap: 6,
    }}>
      {color && <span aria-hidden="true" style={{ width: 7, height: 7, borderRadius: '50%', background: color }}></span>}
      {children}
    </button>
  );
}

const ECON_IMPACT_FILTERS = [
  { id: 'all',    l: 'Tous' },
  { id: 'high',   l: 'Élevé' },
  { id: 'medium', l: 'Moyen' },
  { id: 'low',    l: 'Faible' },
];

// Filtres Impact + Devises, même logique des deux côtés : une puce « Tous »
// ou « Toutes » active quand rien n'est restreint, puis les puces cochables.
function EconImpactChips({ s }) {
  return ECON_IMPACT_FILTERS.map(b => (
    <EconPillBtn key={b.id} on={s.impact === b.id} onClick={() => s.setImpact(b.id)}
      color={b.id === 'all' ? null : IMPACT_COLORS[b.id].dot}
      label={b.id === 'all' ? 'Tous les impacts' : `Impact ${b.l.toLowerCase()}`}>
      {b.l}
    </EconPillBtn>
  ));
}
function EconCurrencyChips({ s }) {
  return (
    <React.Fragment>
      <EconPillBtn on={s.currencies.length === 0} onClick={() => s.setCurrencies([])} label="Toutes les devises">Toutes</EconPillBtn>
      {s.curList.map(c => (
        <EconPillBtn key={c} on={s.currencies.includes(c)} onClick={() => s.toggleCurrency(c)} mono>{c}</EconPillBtn>
      ))}
    </React.Fragment>
  );
}

// Fuseau des heures + état de synchronisation + dernière mise à jour.
function EconSyncStatus({ s }) {
  const tz = econTz();
  const { loading, error, stale, live, lastUpdate, now } = s;
  const label = loading ? 'Synchronisation…'
    : error ? 'Non synchronisé'
      : stale ? 'Données datées'
        : (live ? 'Synchronisé · TradingView' : 'Synchronisé · ForexFactory');
  const tip = error
    ? 'La dernière synchronisation a échoué. Les derniers événements connus restent affichés.'
    : stale
      ? 'La source sert des données anciennes. Elles seront mises à jour automatiquement.'
      : (live
        ? 'Source : calendrier TradingView (valeurs publiées incluses)'
        : 'Source de secours : ForexFactory (sans valeurs publiées)');
  return (
    <React.Fragment>
      <span title={tzLabelWithOffset(tz)} style={{ fontSize: 10.5, color: 'var(--fg-3)', whiteSpace: 'nowrap' }}>
        {/* Même écriture du fuseau que la saisie des trades (« Heure de Paris »). */}
        {tzHourLabel(tz).charAt(0).toUpperCase() + tzHourLabel(tz).slice(1)}
      </span>
      <span
        className={'pill ' + (error ? 'pill-gray' : (stale ? '' : 'pill-green'))}
        title={tip}
        style={{
          fontSize: 10.5, padding: '3px 9px', whiteSpace: 'nowrap',
          ...((stale && !error) ? { background: 'var(--amber-soft)', color: 'var(--amber-text)', border: '1px solid var(--amber-border)' } : {}),
        }}>
        <span style={{ width: 5, height: 5, borderRadius: '50%', background: error ? 'var(--fg-3)' : (stale ? 'var(--orange)' : 'var(--green)'), animation: (error || stale || loading) ? 'none' : 'pulse-dot 2s var(--ease) infinite' }}></span>
        {label}
      </span>
      {lastUpdate && (
        <span
          style={{ fontSize: 10.5, color: 'var(--fg-3)', whiteSpace: 'nowrap' }}
          title={`Dernière synchronisation à ${tzTimeIn(lastUpdate, tz)}`}
        >
          Mis à jour {relativeAgo(lastUpdate, now)}
        </span>
      )}
    </React.Fragment>
  );
}

// Bouton désactivé pendant une synchronisation (repli si .btn:disabled absent).
function econDisabledStyle(off) {
  return off ? { opacity: .55, cursor: 'not-allowed' } : null;
}

function EconRefreshBtn({ s, pad }) {
  return (
    <button type="button" onClick={s.refresh} className="btn tap" disabled={s.loading} style={{ fontSize: 11, padding: pad, flexShrink: 0, color: 'var(--fg)', ...econDisabledStyle(s.loading) }} title="Recharger le calendrier">
      {Ico.refresh || null} Actualiser
    </button>
  );
}

// Bandeau d'erreur : les données déjà chargées restent affichées dessous.
function EconErrorBanner({ s }) {
  if (!s.error || s.realCount === 0) return null;
  return (
    <div role="status" style={{
      marginBottom: 14, padding: '10px 14px', borderRadius: 10,
      background: 'var(--amber-soft)', border: '1px solid var(--amber-border)',
      display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap',
    }}>
      <span style={{ fontSize: 12.5, color: 'var(--amber-text)', lineHeight: 1.5, flex: '1 1 240px' }}>
        Calendrier momentanément indisponible. Les derniers événements connus restent affichés.
      </span>
      <button type="button" onClick={s.refresh} disabled={s.loading} className="btn tap" style={{ fontSize: 12, padding: '5px 12px', color: 'var(--fg)', ...econDisabledStyle(s.loading) }}>
        Réessayer
      </button>
    </div>
  );
}

// État vide : une phrase + une action.
function EconEmptyState({ s }) {
  let msg, sub = null, action = null;
  if (s.loading && s.realCount === 0) {
    msg = 'Chargement du calendrier…';
  } else if (s.error && s.realCount === 0) {
    msg = 'Calendrier momentanément indisponible.';
    sub = 'Réessaie dans quelques minutes.';
    action = { l: 'Réessayer', fn: s.refresh };
  } else if (s.realCount === 0) {
    msg = 'Aucun événement disponible pour le moment.';
    action = { l: 'Actualiser', fn: s.refresh };
  } else if (s.filtersActive) {
    msg = s.dateFilter === 'today' ? 'Aucun événement aujourd’hui avec ces filtres.'
      : s.dateFilter === 'tomorrow' ? 'Aucun événement demain avec ces filtres.'
        : 'Aucun événement avec ces filtres.';
    action = { l: 'Afficher tous les événements', fn: s.resetFilters };
  } else if (s.dateFilter === 'tomorrow' && !s.live) {
    msg = 'Aucun événement demain dans les données disponibles.';
    sub = 'La source de secours ne couvre que la semaine en cours.';
    action = { l: 'Voir cette semaine', fn: () => s.setDateFilter('week') };
  } else if (s.dateFilter === 'today' || s.dateFilter === 'tomorrow') {
    msg = s.dateFilter === 'today' ? 'Aucun événement aujourd’hui.' : 'Aucun événement demain.';
    action = { l: 'Voir cette semaine', fn: () => s.setDateFilter('week') };
  } else {
    msg = 'Aucun événement sur cette période.';
    action = { l: 'Actualiser', fn: s.refresh };
  }
  return (
    <div style={{ padding: '48px 20px', textAlign: 'center', color: 'var(--fg-3)', lineHeight: 1.6, fontSize: 13 }}>
      <div style={{ color: 'var(--fg-2)' }}>{msg}</div>
      {sub && <div style={{ fontSize: 12 }}>{sub}</div>}
      {action && (
        <button type="button" onClick={action.fn} disabled={s.loading} className="btn tap" style={{ marginTop: 12, fontSize: 12, color: 'var(--fg)', ...econDisabledStyle(s.loading) }}>
          {action.l}
        </button>
      )}
    </div>
  );
}

// Compteurs d'impact d'un jour : « 0 à impact élevé · 4 à impact moyen ·
// 11 à impact faible » (forme invariable). `compact` (mobile) : pastille +
// nombre, libellé complet en info-bulle et pour les lecteurs d'écran.
function EconDayCounts({ list, compact }) {
  return (
    <span style={{ display: 'inline-flex', alignItems: 'center', gap: compact ? 10 : 14, flexWrap: 'wrap' }}>
      {['high', 'medium', 'low'].map(k => {
        const n = list.filter(e => e.impact === k).length;
        const full = `${n} à impact ${IMPACT_LABEL[k].toLowerCase()}`;
        return (
          <span key={k} title={full} role={compact ? 'img' : undefined} aria-label={compact ? full : undefined}
            style={{ display: 'inline-flex', alignItems: 'center', gap: compact ? 4 : 5, fontSize: compact ? 10.5 : 11, color: 'var(--fg-3)', whiteSpace: 'nowrap' }}>
            <span aria-hidden="true" style={{ width: compact ? 6 : 7, height: compact ? 6 : 7, borderRadius: '50%', background: IMPACT_COLORS[k].dot }}></span>
            {compact ? n : full}
          </span>
        );
      })}
    </span>
  );
}

function econCountLabel(n) {
  return `${n} ${n > 1 ? 'événements' : 'événement'}`;
}

// Pied de page : source des données (même texte sur ordinateur et mobile).
function EconFooter({ live }) {
  const ff = <a href={FOREXFACTORY_CAL_URL} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--blue-600)', textDecoration: 'none' }}>ForexFactory</a>;
  return (
    <p style={{ fontSize: 11, color: 'var(--fg-3)', marginTop: 14, textAlign: 'center', lineHeight: 1.5 }}>
      {live ? (
        <React.Fragment>{'Données : calendrier TradingView, semaine en cours et semaine prochaine. La colonne « Publié » affiche les valeurs dès leur parution (vérification possible sur '}{ff}).</React.Fragment>
      ) : (
        <React.Fragment>{'Données : '}{ff}{' (source de secours), semaine en cours. Cette source ne fournit pas les valeurs publiées : consulte ForexFactory pour les chiffres en direct.'}</React.Fragment>
      )}
    </p>
  );
}

// La variante ordinateur est choisie sur la largeur de la FENÊTRE (≥ 900 px),
// mais la liste n'occupe que ce que laissent le menu et les marges : à 1024 px
// il reste 744 px, dont 62 pour le titre une fois les colonnes fixes de
// ECON_GRID servies. On mesure donc la carte de liste elle-même : sous 920 px
// (titre à moins de 240 px), chaque événement est rendu avec la carte du
// calendrier mobile (titre sur toute la largeur, valeurs dessous) et la rangée
// d'en-têtes de colonnes disparaît. Le reste de l'écran ne change pas.
// (Même principe que usePgWidth de pages.jsx, recopié ici pour ne pas dépendre
// de l'ordre de chargement des fichiers.)
const ECON_GRID_MIN_W = 920;
function useEconWidth() {
  const ref = React.useRef(null);
  const [width, setWidth] = React.useState(0);
  React.useLayoutEffect(() => {
    const el = ref.current;
    if (!el) return undefined;
    const read = () => setWidth(Math.round(el.getBoundingClientRect().width));
    read();
    if (typeof ResizeObserver !== 'function') return undefined;
    const ro = new ResizeObserver(read);
    ro.observe(el);
    return () => ro.disconnect();
  }, []);
  return [ref, width];
}
function EconomicCalendarDesktop({ nav }) {
  const s = useEconCalendar();
  const [listRef, listW] = useEconWidth();
  const compact = listW > 0 && listW < ECON_GRID_MIN_W;
  const Row = compact ? EconEventCardM : EventRow;
  const { dateFilter, setDateFilter, expanded, groupByDay, dayKeys } = s;
  const headStyle = { fontSize: 10, fontWeight: 600, color: 'var(--fg-3)', letterSpacing: '.06em', textTransform: 'uppercase', whiteSpace: 'nowrap' };

  return (
    <div className="scroll" style={{ width: '100%', height: '100%', overflow: 'auto' }}>
      <PageHeader title={econPageTitle()}/>
      <div style={{ ...PAGE_BODY, padding: '20px 24px 32px' }}>
        <div style={{ marginBottom: 16 }}>
          <NextEventBanner events={s.events} now={s.now}/>
        </div>

        {/* Barre d'outils : période à gauche, fuseau + synchronisation à droite */}
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 10, flexWrap: 'wrap' }}>
          <SegmentedTabs ariaLabel="Période" kind="choice" size="sm" value={dateFilter} onChange={setDateFilter}
            options={s.dateTabs.map(t => ({ id: t.id, label: t.l }))} />
          <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginLeft: 'auto', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
            <EconSyncStatus s={s}/>
            <EconRefreshBtn s={s} pad="4px 10px"/>
          </div>
        </div>

        {/* Filtres : impact puis devises */}
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14, flexWrap: 'wrap' }}>
          <span style={{ fontSize: 11, color: 'var(--fg-3)' }}>Impact</span>
          <EconImpactChips s={s}/>
          <span style={{ width: 1, height: 20, background: 'var(--line)', margin: '0 6px' }}></span>
          <span style={{ fontSize: 11, color: 'var(--fg-3)' }}>Devises</span>
          <EconCurrencyChips s={s}/>
        </div>

        <EconErrorBanner s={s}/>

        <div ref={listRef} className="card" style={{ padding: 0, overflow: 'hidden' }}>
          {dayKeys.length === 0 && <EconEmptyState s={s}/>}
          {dayKeys.map((dk, di) => {
            const list = groupByDay[dk];
            const rows = list.map(e => (
              <Row
                key={e.id}
                e={e}
                isPast={s.isPast(e)}
                isNow={s.isNowEvent(e)}
                isExpanded={!!expanded && expanded.id === e.id}
                planFocus={!!expanded && expanded.id === e.id && expanded.mode === 'plan'}
                planSel={(expanded && expanded.id === e.id && expanded.sel) || null}
                onToggle={(mode) => s.toggleRow(e.id, mode)}
                live={s.live}
              />
            ));
            return (
              <div key={dk}>
                <div style={{
                  padding: compact ? '12px 16px' : '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', gap: 12, flexWrap: 'wrap',
                }}>
                  <div style={{ display: 'flex', alignItems: 'baseline', gap: 12 }}>
                    <h2 style={{ margin: 0, fontSize: 13, fontWeight: 600, lineHeight: 'inherit' }}>{econDayLabel(dk, s.today, s.tomorrow)}</h2>
                    <span style={{ fontSize: 11, color: 'var(--fg-3)' }}>{econCountLabel(list.length)}</span>
                  </div>
                  <EconDayCounts list={list}/>
                </div>

                {/* Grille large : un vrai tableau par jour (en-têtes reliés aux
                    valeurs). Grille étroite : cartes, sans rôle de tableau. */}
                {compact ? rows : (
                  <div role="table" aria-label={`Événements, ${econDayLabel(dk, s.today, s.tomorrow)}`}>
                    <div role="row" style={{ display: 'grid', gridTemplateColumns: ECON_GRID, padding: '10px 20px', borderBottom: '1px solid var(--line)', background: 'var(--bg-card)', gap: 8 }}>
                      {ECON_COLS.map((c, i) => (
                        <span key={i} role="columnheader" aria-label={c.name || undefined} style={headStyle}>{c.h}</span>
                      ))}
                    </div>
                    {rows}
                  </div>
                )}
              </div>
            );
          })}
        </div>

        {s.realCount > 0 && <EconFooter live={s.live}/>}
      </div>
    </div>
  );
}

// ═════════════════════════════════════════════════════════════════════════
//  VARIANTE MOBILE — même contenu que le desktop (fenêtre 2 semaines,
//  colonne Publié, filtres impact/devise/période, mon plan, détails),
//  adapté au téléphone : cartes 2-3 lignes, filtres en pills scrollables,
//  détail en accordéon sous la carte. Réutilise useEconCalendar, les
//  bandeaux, puces, compteurs, TradePlanNote et EconFacts — AUCUNE nouvelle
//  source de données.
// ═════════════════════════════════════════════════════════════════════════

// Même point de bascule que le shell de l'app (app.jsx useIsMobile : < 900 px)
// → la sélection interne rend la variante mobile sans dépendre d'un câblage
// externe, et le desktop (≥ 900 px) reste rendu à l'identique.
function useEconIsMobile() {
  const [m, setM] = React.useState(() => typeof window !== 'undefined' && window.innerWidth < 900);
  React.useEffect(() => {
    const fit = () => setM(window.innerWidth < 900);
    window.addEventListener('resize', fit);
    return () => window.removeEventListener('resize', fit);
  }, []);
  return m;
}

// Rangée horizontale scrollable pleine largeur (déborde des paddings 16px de
// la page pour que le scroll aille bord à bord).
function EconScrollRow({ children, style }) {
  return (
    <div className="scroll" style={{
      display: 'flex', gap: 6, overflowX: 'auto', WebkitOverflowScrolling: 'touch',
      margin: '0 -16px', padding: '2px 16px',
      ...style,
    }}>
      {children}
    </div>
  );
}

// Cellule « libellé + valeur » de la ligne chiffres d'une carte événement.
// onTint : la carte est sur le fond teinté de l'événement en cours ; le libellé
// passe en --fg-2 pour garder un contraste d'au moins 4,5:1 (charte §9).
function EconValM({ label, onTint, children }) {
  return (
    <span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 5 }}>
      <span style={{ fontSize: 9.5, fontWeight: 600, color: onTint ? 'var(--fg-2)' : 'var(--fg-3)', letterSpacing: '.06em', textTransform: 'uppercase' }}>{label}</span>
      {children}
    </span>
  );
}

// Carte événement mobile mémoïsée (même contrat/comparateur que EventRow
// desktop : seule la ligne concernée re-rend sur le tick 30 s).
const EconEventCardM = React.memo(function EconEventCardM({ e, isPast, isNow, isExpanded, onToggle, planFocus, planSel, live }) {
  const [noteVer, setNoteVer] = React.useState(0);
  const hasNote = React.useMemo(() => !!readEconNotes()[e.id], [e.id, noteVer, isExpanded]);
  const canPlan = ((e.impact === 'high' || e.impact === 'medium') && !isPast) || hasNote;
  const forecast = econCell(e, 'forecast');
  const prev = econCell(e, 'prev');

  return (
    <div style={{
      borderBottom: '1px solid var(--line)', position: 'relative',
      background: isNow ? 'var(--blue-soft-2, var(--blue-soft))' : 'var(--bg-card)',
      transition: 'background .4s var(--ease)',
    }}>
      {isNow && <span style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 3, background: 'var(--blue)', zIndex: 1 }}></span>}

      <div onClick={() => onToggle('details')} className="tap" style={{ padding: '12px 16px', cursor: 'pointer' }}>
        {/* Ligne 1 : heure + impact + devise + Mon plan + chevron */}
        <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
          <span className="mono" style={{ fontSize: 12.5, fontWeight: 500, color: isPast ? ECON_PAST.time : 'var(--fg-2)' }}>{e.time || '—'}</span>
          <ImpactDot impact={e.impact} size={7}/>
          <CurrencyChip cur={e.cur}/>
          <span style={{ flex: 1 }}></span>
          {canPlan && <EconPlanBtn hasNote={hasNote} onClick={() => onToggle('plan')}/>}
          <EconChevBtn e={e} open={isExpanded}/>
        </div>

        {/* Ligne 2 : titre */}
        <div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-.005em', lineHeight: 1.35, marginTop: 7, color: isPast ? ECON_PAST.title : 'var(--fg)' }}>
          <EconTitle e={e}/>
        </div>

        {/* Ligne 3 : Publié / Prévision / Précédent */}
        <div style={{ display: 'flex', alignItems: 'baseline', gap: 14, marginTop: 7, flexWrap: 'wrap' }}>
          <EconValM label="Publié" onTint={isNow}><ActualVsForecast e={e} isPast={isPast} live={live} size={12}/></EconValM>
          <EconValM label="Prév." onTint={isNow}><EconVal text={forecast} weight={500} size={12}/></EconValM>
          <EconValM label="Préc." onTint={isNow}><EconVal text={prev} color={isNow ? 'var(--fg-2)' : 'var(--fg-3)'} size={12}/></EconValM>
        </div>
      </div>

      {/* Détail en accordéon — mêmes informations que l'encart desktop */}
      {isExpanded && (
        <div onClick={ev => ev.stopPropagation()} style={{ padding: '14px 16px 18px', background: 'var(--bg-soft)', borderTop: '1px solid var(--line)' }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
            <EconImpactPill impact={e.impact}/>
          </div>

          <EconExplain e={e} mb={14}/>

          <div style={{ background: 'var(--bg-card)', border: '1px solid var(--line)', borderRadius: 10, padding: '12px 14px', marginBottom: 16 }}>
            <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--fg-3)', letterSpacing: '.06em', textTransform: 'uppercase', marginBottom: 6 }}>Détails</div>
            <EconFacts e={e} isPast={isPast} live={live}/>
          </div>

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

          <div style={{ marginTop: 14 }}><EconFFLink/></div>
        </div>
      )}
    </div>
  );
}, (prev, next) =>
  prev.e === next.e &&
  prev.isPast === next.isPast &&
  prev.isNow === next.isNow &&
  prev.isExpanded === next.isExpanded &&
  prev.planFocus === next.planFocus &&
  prev.planSel === next.planSel &&
  prev.live === next.live
);

function EconomicCalendarMobile({ nav }) {
  const s = useEconCalendar();
  const { dateFilter, setDateFilter, expanded, groupByDay, dayKeys } = s;

  return (
    <div style={{ padding: '14px 16px', paddingBottom: 'calc(28px + env(safe-area-inset-bottom, 0px))' }}>
      {/* En-tête mobile sobre : titre + actualiser */}
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, marginBottom: 6 }}>
        <h1 style={{ fontSize: 24, fontWeight: 700, letterSpacing: '-.025em', margin: 0 }}>{econPageTitle()}</h1>
        <EconRefreshBtn s={s} pad="5px 10px"/>
      </div>

      {/* Fuseau + état de synchronisation */}
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
        <EconSyncStatus s={s}/>
      </div>

      <div style={{ marginBottom: 14 }}>
        <NextEventBanner events={s.events} now={s.now} mobile/>
      </div>

      <EconErrorBanner s={s}/>

      {/* Filtre période */}
      <EconScrollRow style={{ marginBottom: 8 }}>
        {s.dateTabs.map(t => (
          <EconPillBtn key={t.id} on={dateFilter === t.id} onClick={() => setDateFilter(t.id)}>{t.l}</EconPillBtn>
        ))}
      </EconScrollRow>

      {/* Filtres impact + devises */}
      <EconScrollRow style={{ marginBottom: 14 }}>
        <EconImpactChips s={s}/>
        <span style={{ width: 1, alignSelf: 'stretch', background: 'var(--line)', flexShrink: 0, margin: '2px 4px' }}></span>
        <EconCurrencyChips s={s}/>
      </EconScrollRow>

      {/* Liste des événements par jour */}
      <div className="card" style={{ padding: 0, overflow: 'hidden' }}>
        {dayKeys.length === 0 && <EconEmptyState s={s}/>}
        {dayKeys.map((dk, di) => {
          const list = groupByDay[dk];
          return (
            <div key={dk}>
              <div style={{
                padding: '10px 16px', background: 'var(--bg-soft)',
                borderTop: di > 0 ? '1px solid var(--line)' : 'none',
                borderBottom: '1px solid var(--line)',
              }}>
                <h2 style={{ margin: 0, fontSize: 12.5, fontWeight: 600, lineHeight: 'inherit' }}>{econDayLabel(dk, s.today, s.tomorrow)}</h2>
                <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 3, flexWrap: 'wrap' }}>
                  <span style={{ fontSize: 10.5, color: 'var(--fg-3)' }}>{econCountLabel(list.length)}</span>
                  <EconDayCounts list={list} compact/>
                </div>
              </div>

              {list.map(e => (
                <EconEventCardM
                  key={e.id}
                  e={e}
                  isPast={s.isPast(e)}
                  isNow={s.isNowEvent(e)}
                  isExpanded={!!expanded && expanded.id === e.id}
                  planFocus={!!expanded && expanded.id === e.id && expanded.mode === 'plan'}
                  planSel={(expanded && expanded.id === e.id && expanded.sel) || null}
                  onToggle={(mode) => s.toggleRow(e.id, mode)}
                  live={s.live}
                />
              ))}
            </div>
          );
        })}
      </div>

      {s.realCount > 0 && <EconFooter live={s.live}/>}
    </div>
  );
}

// Sélection interne : la variante mobile est rendue sous 900 px (même seuil
// que le shell de l'app), le desktop reste inchangé au pixel près au-dessus.
function EconomicCalendar(props) {
  const mobile = useEconIsMobile();
  return mobile ? <EconomicCalendarMobile {...props}/> : <EconomicCalendarDesktop {...props}/>;
}

Object.assign(window, { EconomicCalendar, EconomicCalendarDesktop, EconomicCalendarMobile });
