// Tempo — Statistiques : analyse Trading + Habitudes (refonte)
// Composants exposés : StatsPage(props) (desktop) et StatsMobile(props) (mobile), avec
//   props = { state, initialView, period, openImport }
//   - initialView : 'trading' | 'habits' (sous-onglet d'ouverture)
//   - period      : 'week' | 'month' | 'year' | 'all' (période d'ouverture ; « Ce mois » par défaut)
//   - openImport  : fenêtre « Ajouter des trades » (app.jsx) ; l'état vide l'ouvre sur
//     l'onglet Saisie manuelle via openImport(null, 'manual') (« Ajouter un trade »).
// Aucune lib externe : tous les graphes sont du SVG inline fait main. Aucune couleur en dur — tout via CSS vars.
// Charte (docs/charte-ui.md) : montants via fmtMoney, nombres via fmtNum / fmtPct / fmtR / fmtPF,
// dates via fmtDayShort / fmtDayRange / fmtMonthYear, tout dans le fuseau du profil.
//
// Données :
//   - Trades : state.trades (déjà chargés) filtrés par période côté JS, sur le jour
//     civil de chaque trade dans le fuseau du profil.
//   - Habitudes : aucune requête ici. L'écran lit state.habitRows / state.habitCtx
//     (historique complet chargé par useAppState) et appelle le moteur de calcul
//     de design/state.jsx : une seule règle pour l'Accueil, Routines et Statistiques.
//
// Philosophie : « moins mais mieux ». Quelques visualisations vraiment décisionnelles,
// beaucoup d'air, des chiffres-clés discrets plutôt qu'un mur de graphiques.

// ─── Jours civils 'YYYY-MM-DD' (jamais projetés dans un fuseau) ──────
// Tous les calculs de calendrier passent par les fonctions de design/state.jsx
// (habitShiftDay, habitWeekdayNum, habitWeekStart), qui travaillent à midi UTC
// sur le jour civil : le résultat ne dépend jamais du fuseau du navigateur.

// Renvoie { fromISO, toISO } (bornes inclusives, jours civils du fuseau du profil)
// pour la période choisie. Périodes civiles (charte §4) : semaine du lundi, mois
// et année calendaires, jusqu'à aujourd'hui. 'all' renvoie fromISO = null.
function statsPeriodRange(period, today) {
  const toISO = today || todayISO();
  if (period === 'all')   return { fromISO: null, toISO };
  if (period === 'week')  return { fromISO: habitWeekStart(toISO), toISO };
  if (period === 'month') return { fromISO: toISO.slice(0, 8) + '01', toISO };
  return { fromISO: toISO.slice(0, 5) + '01-01', toISO }; // year
}

const STATS_PERIODS = [
  { id: 'week',  label: 'Cette semaine' },
  { id: 'month', label: 'Ce mois' },
  { id: 'year',  label: 'Cette année' },
  { id: 'all',   label: 'Tout' },
];

// Plage affichée sous le sélecteur de période (« 21–27 sept. »). Pour « Tout » :
// du premier jour tradé à aujourd'hui (trading), ou du premier jour de suivi de la
// plus ancienne habitude à aujourd'hui (habitudes, §5.14) ; « — » sans donnée.
function statsRangeLabel(range, view, firstDay, habitFirst) {
  if (range.fromISO) return fmtDayRange(range.fromISO, range.toISO);
  const start = view === 'habits' ? habitFirst : firstDay;
  if (!start) return '—';
  return fmtDayRange(start > range.toISO ? range.toISO : start, range.toISO);
}

// Titre de la page : table NAV_LABELS (core.jsx), seule source des noms d'écrans.
function statsTitle() {
  const n = (typeof NAV_LABELS !== 'undefined' && NAV_LABELS && NAV_LABELS.stats) || null;
  return (n && n.titre) || 'Statistiques';
}

// ─── Fuseau, jour d'un trade, signe d'un montant ─────────────────────
function statsTz()  { return (typeof window !== 'undefined' && window.__appTz) || 'Europe/Paris'; }

// Clé jour local cohérente avec le calendrier (fuseau du profil).
function statsDayKey(iso) {
  if (!iso) return '';
  if (window.localDayKey) return window.localDayKey(iso, statsTz());
  return localDayInTz(iso);
}

// Signe d'un montant tel qu'il s'affiche (arrondi aux décimales de la devise) :
// un P&L de 0,001 € s'affiche « 0,00 € », il est donc neutre, pas vert.
function statsMoneySign(v) {
  return moneySign(v); // règle unique, format-prefs.jsx
}
function statsMoneyTone(v) {
  const s = statsMoneySign(v);
  return s > 0 ? 'pos' : s < 0 ? 'neg' : undefined;
}
function statsToneColor(tone, fallback = 'var(--fg)') {
  return tone === 'pos' ? 'var(--green)' : tone === 'neg' ? 'var(--red)' : fallback;
}

// ─── Nombre animé (montant devise) ───────────────────────────────────
// Relais vers l'AnimatedMoney unique (animations.jsx, charte §2) : elle compte
// avec les décimales de la devise du profil et s'arrête exactement sur
// fmtMoney(value, signed) — jamais sur une valeur arrondie à l'unité.
function StatsAnimatedMoney({ value, signed = true, style }) {
  return <AnimatedMoney value={Number(value) || 0} sign={signed} duration={850} style={style} />;
}

// ─── Primitives UI partagées ─────────────────────────────────────────
// Largeur réelle d'un bloc, mesurée par ref de rappel : fonctionne même quand le
// bloc n'apparaît qu'après un squelette de chargement (contrairement à un effet
// monté une seule fois). Sert à choisir la grille selon la place disponible, et
// non selon le seul mode « mobile » (une tablette de 768 px a la place de 4 colonnes).
function useStatsBodyWidth() {
  const [width, setWidth] = React.useState(0);
  const roRef = React.useRef(null);
  const ref = React.useCallback((el) => {
    if (roRef.current) { roRef.current.disconnect(); roRef.current = null; }
    if (!el) return;
    const read = () => setWidth(Math.round(el.getBoundingClientRect().width));
    read();
    if (typeof ResizeObserver === 'function') {
      roRef.current = new ResizeObserver(read);
      roRef.current.observe(el);
    }
  }, []);
  return [ref, width];
}
const STATS_FOUR_COLS_MIN = 680;

// Largeur réelle d'un texte sur une ligne, en px, pour une taille et une graisse
// données : mesurée dans la page (même police, mêmes chiffres tabulaires que
// l'affichage), jamais estimée au nombre de caractères. Sert à décider si un
// montant ou un libellé tient dans sa case AVANT de l'y écrire.
//   opts.num           : classe .num (chiffres tabulaires), comme l'élément affiché ;
//   opts.letterSpacing : approche de l'élément affiché (ex. '-.018em').
// Une mesure n'est gardée en mémoire que si les polices étaient chargées : tant
// qu'Inter arrive, on remesure (voir useStatsFontEpoch, qui relance le rendu).
const statsTextCache = new Map();
let statsTextProbe = null;
function statsFontsLoaded() {
  return typeof document === 'undefined' || !document.fonts || document.fonts.status === 'loaded';
}
function statsTextWidth(text, size, weight, opts) {
  const str = String(text == null ? '' : text);
  if (!str) return 0;
  const num = !!(opts && opts.num);
  const spacing = (opts && opts.letterSpacing) || '';
  const fallback = str.length * size * 0.6;
  if (typeof document === 'undefined' || !document.body) return fallback;
  const key = size + '|' + weight + '|' + (num ? 1 : 0) + '|' + spacing + '|' + str;
  const hit = statsTextCache.get(key);
  if (hit !== undefined) return hit;
  let el = statsTextProbe;
  if (!el || !el.isConnected) {
    el = document.createElement('span');
    el.setAttribute('aria-hidden', 'true');
    el.style.cssText = 'position:absolute;left:-9999px;top:0;visibility:hidden;pointer-events:none;white-space:pre;line-height:1;padding:0;border:0;margin:0;';
    document.body.appendChild(el);
    statsTextProbe = el;
  }
  el.className = num ? 'num' : '';
  el.style.fontSize = size + 'px';
  el.style.fontWeight = String(weight);
  el.style.letterSpacing = spacing || (num ? '' : 'normal');
  el.textContent = str;
  const w = el.getBoundingClientRect().width;
  el.textContent = '';
  if (!(w > 0)) return fallback;
  // Vérifié APRÈS la mesure : c'est elle qui peut déclencher le chargement d'une graisse.
  if (statsFontsLoaded()) {
    if (statsTextCache.size >= 2000) statsTextCache.clear(); // garde-fou : la mémoire ne grossit pas sans fin
    statsTextCache.set(key, w);
  }
  return w;
}
function statsMaxTextWidth(texts, size, weight, opts) {
  let max = 0;
  for (const t of texts) { const w = statsTextWidth(t, size, weight, opts); if (w > max) max = w; }
  return max;
}
// Relance le rendu quand une police finit de charger : les largeurs mesurées
// avec la police de remplacement ne valent plus.
function useStatsFontEpoch() {
  const [epoch, setEpoch] = React.useState(0);
  React.useEffect(() => {
    const fonts = typeof document !== 'undefined' ? document.fonts : null;
    if (!fonts || typeof fonts.addEventListener !== 'function') return undefined;
    let alive = true;
    const bump = () => { if (!alive) return; statsTextCache.clear(); setEpoch((n) => n + 1); };
    fonts.addEventListener('loadingdone', bump);
    // Chargement déjà en cours au montage : l'événement peut être passé entre le rendu et cet effet.
    if (fonts.status !== 'loaded' && fonts.ready && typeof fonts.ready.then === 'function') fonts.ready.then(bump, () => {});
    return () => { alive = false; fonts.removeEventListener('loadingdone', bump); };
  }, []);
  return epoch;
}

// Les deux vues de l'écran (onglets Trading et Habitudes).
const STATS_VIEW_IDS = [{ id: 'trading' }, { id: 'habits' }];

// `idPrefix` (onglets seulement) : relie chaque onglet à son panneau — l'onglet
// reçoit id « <idPrefix>-tab-<id> » et aria-controls « <idPrefix>-panel-<id> ».
function StatsSegmented({ options, value, onChange, size = 'md', ariaLabel, kind = 'choice', stretch = false, idPrefix }) {
  // Style unique de l'application : SegmentedTabs (core.jsx).
  return <SegmentedTabs options={options} value={value} onChange={onChange} size={size} kind={kind} ariaLabel={ariaLabel} stretch={stretch} idPrefix={idPrefix} />;
}

// KPI « héros » : grande carte avec valeur animée optionnelle. `wide` : la carte
// occupe toute la ligne de la grille (montants sur mobile, jamais tronqués).
// Sans ton, la valeur est en var(--fg) (zéro neutre, charte §2).
// `size` : taille de la valeur en px (défaut 30 en `big`, 23 sinon) — réduite
// par l'écran quand la carte est trop étroite pour le montant le plus long.
function StatsKpi({ label, value, animatedMoney, sub, tone, big = false, wide = false, size }) {
  const color = statsToneColor(tone, 'var(--fg)');
  return (
    <div className="card lift" style={{ padding: big ? '18px 20px' : '15px 17px', minWidth: 0, gridColumn: wide ? '1 / -1' : undefined }}>
      {/* Libellé en casse de phrase, comme les cartes de l'Accueil et du Tableau de bord (KpiCard). */}
      <div style={{ fontSize: 13, fontWeight: 500, color: 'var(--fg-2)', letterSpacing: '-.005em', marginBottom: 12 }}>{label}</div>
      <div data-kpi-value style={{ fontSize: size || (big ? 30 : 23), fontWeight: 700, letterSpacing: '-.024em', color, lineHeight: 1, whiteSpace: 'nowrap' }}>
        {animatedMoney != null
          ? <StatsAnimatedMoney value={animatedMoney} signed style={{ fontSize: 'inherit', fontWeight: 'inherit', letterSpacing: 'inherit', lineHeight: 'inherit', color: 'inherit' }} />
          : <span className="num">{value}</span>}
      </div>
      {sub && <div style={{ fontSize: 11.5, color: 'var(--fg-3)', marginTop: 9 }}>{sub}</div>}
    </div>
  );
}

// Bandeau compact de chiffres secondaires : une carte, plusieurs cellules.
// Les libellés passent à la ligne plutôt que d'être coupés ; les valeurs restent
// sur une ligne (nowrap, jamais d'ellipsis sur un chiffre). `cols` et `size`
// (taille des valeurs, 17 px par défaut) sont choisis par l'écran d'après la
// largeur mesurée (statsStripLayout) : une valeur ne sort jamais de sa cellule.
const STATS_STRIP_SIZE = 17;      // taille normale des valeurs
const STATS_STRIP_SIZE_MIN = 13;  // en dessous, on passe à moins de colonnes
const STATS_STRIP_PAD_X = 16;     // marge horizontale d'une cellule
const STATS_STRIP_SPACING = '-.018em';
// Colonnes et taille des valeurs d'un bandeau, pour une zone de `bodyW` px :
// d'abord `cols` colonnes à 17 px ; si la valeur la plus large
// (`maxW`, mesurée à 17 px) ne tient pas, la même grille en plus petit (jusqu'à
// 13 px) ; sinon moitié moins de colonnes, jusqu'à une seule.
function statsStripLayout(bodyW, cols, maxW) {
  if (!(bodyW > 0) || !(maxW > 0)) return { cols, size: STATS_STRIP_SIZE };
  // Bord de la carte (2 px), marges de la cellule, filet de séparation (1 px) et 1 px de sûreté.
  const room = (c) => (bodyW - 2) / c - STATS_STRIP_PAD_X * 2 - 2;
  const fit = (c) => Math.floor((STATS_STRIP_SIZE * room(c) / maxW) * 2) / 2; // largeur proportionnelle à la taille
  for (let c = cols; c >= 1; c = c > 2 ? Math.ceil(c / 2) : c - 1) {
    const size = fit(c);
    if (size >= STATS_STRIP_SIZE) return { cols: c, size: STATS_STRIP_SIZE };
    if (size >= STATS_STRIP_SIZE_MIN) return { cols: c, size };
    if (c === 1) return { cols: 1, size: Math.max(10, size) };
  }
  return { cols: 1, size: STATS_STRIP_SIZE };
}
function StatsStrip({ items, cols, size = STATS_STRIP_SIZE }) {
  const n = cols || items.length;
  return (
    <div className="card lift" data-stats-strip={n} style={{ padding: 0, display: 'grid', gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))`, overflow: 'hidden' }}>
      {items.map((it, i) => {
        const color = statsToneColor(it.tone, it.accent || 'var(--fg)');
        const startOfRow = i % n === 0;
        const secondRow = i >= n;
        return (
          <div key={i} style={{
            padding: '13px ' + STATS_STRIP_PAD_X + 'px', minWidth: 0,
            borderLeft: startOfRow ? 'none' : '1px solid var(--line)',
            borderTop: secondRow ? '1px solid var(--line)' : 'none',
          }}>
            <div style={{ fontSize: 12, fontWeight: 500, color: 'var(--fg-2)', letterSpacing: '-.005em', marginBottom: 7, lineHeight: 1.3 }}>{it.label}</div>
            <div className="num" data-strip-value style={{ fontSize: size, fontWeight: 700, letterSpacing: STATS_STRIP_SPACING, color, lineHeight: 1, whiteSpace: 'nowrap' }}>{it.value}</div>
            {it.sub && <div style={{ fontSize: 10.5, color: 'var(--fg-3)', marginTop: 5, lineHeight: 1.3 }}>{it.sub}</div>}
          </div>
        );
      })}
    </div>
  );
}

function StatsPanel({ title, subtitle, children, pad = '18px 20px' }) {
  return (
    <div className="card lift" style={{ padding: pad, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
      {title && (
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', flexWrap: 'wrap', columnGap: 12, rowGap: 4 }}>
          <CardTitle style={{ fontSize: 13.5, letterSpacing: '-.012em' }}>{title}</CardTitle>
          {subtitle && <span style={{ fontSize: 11.5, color: 'var(--fg-3)', textAlign: 'right' }}>{subtitle}</span>}
        </div>
      )}
      <div style={{ marginTop: 16, flex: 1 }}>{children}</div>
    </div>
  );
}

// État vide : une phrase et, si fournie, UNE action principale
// (action = { label, onClick, primary }).
function StatsEmpty({ title, sub, icon, action }) {
  // Format unique de l'application : EmptyState (core.jsx).
  return (
    <EmptyState icon={icon} title={title} text={sub} compact
      primary={action && action.primary ? { label: action.label, onClick: action.onClick, ico: Ico.plus } : null}
      secondary={action && !action.primary ? { label: action.label, onClick: action.onClick } : null}/>
  );
}

// Squelette de chargement (uniquement pendant le premier chargement des trades).
function StatsTradingSkeleton({ compact = false }) {
  const bar = (w, h) => <div style={{ width: w, height: h, borderRadius: 6, background: 'var(--bg-elev)' }} />;
  return (
    <div role="status" aria-busy="true" aria-label="Chargement de tes statistiques" style={{ display: 'flex', flexDirection: 'column', gap: compact ? 12 : 16 }}>
      <div style={{ display: 'grid', gridTemplateColumns: `repeat(${compact ? 2 : 4}, minmax(0, 1fr))`, gap: compact ? 10 : 14 }}>
        {[0, 1, 2, 3].map(i => (
          <div key={i} className="card" style={{ padding: '18px 20px', display: 'flex', flexDirection: 'column', gap: 12 }}>
            {bar('55%', 10)}{bar('75%', 26)}{bar('40%', 10)}
          </div>
        ))}
      </div>
      <div className="card" style={{ padding: '18px 20px' }}>{bar('100%', compact ? 190 : 250)}</div>
    </div>
  );
}

// Bandeau d'erreur de chargement des trades (charte §7 : une erreur n'est pas un
// compte vide). Lit state.tradesError ; les trades déjà chargés restent affichés.
function StatsLoadError({ state }) {
  const err = state && state.tradesError;
  if (!err) return null;
  const retry = typeof state.refreshTrades === 'function' ? () => state.refreshTrades() : null;
  return (
    <div role="alert" className="card" style={{
      display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12,
      padding: '12px 16px', marginBottom: 16, background: 'var(--red-soft)', borderColor: 'var(--red)',
    }}>
      <span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--red-text)' }}>Impossible de charger tes trades. Vérifie ta connexion.</span>
      {retry && <button type="button" onClick={retry} className="btn tap" style={{ color: 'var(--fg)' }}>Réessayer</button>}
    </div>
  );
}

// Hook : largeur responsive d'un conteneur (pour SVG fluides).
function useStatsWidth(initial) {
  const ref = React.useRef(null);
  const [w, setW] = React.useState(initial);
  React.useEffect(() => {
    if (!ref.current) return;
    const ro = new ResizeObserver(entries => {
      const cw = entries[0]?.contentRect?.width;
      if (cw) setW(Math.max(220, Math.round(cw)));
    });
    ro.observe(ref.current);
    return () => ro.disconnect();
  }, []);
  return [ref, w];
}

// ═════════════════════════════════════════════════════════════════════
//  GRAPHES SVG
// ═════════════════════════════════════════════════════════════════════

// Courbe du P&L cumulé : LA MÊME que celle du Tableau de bord
// (CumulativeChartReal, pages.jsx) — mêmes axes de dates, même échelle, même
// valeur finale exacte (celle du P&L net) — avec en plus la bande de drawdown.
// ddRange (optionnel) : { peak, trough } indices de la plus forte chute pic→creux.
function StatsEquityCurve({ points, dates, height = 240, ddRange = null }) {
  if (!points || points.length < 2) {
    return <StatsEmpty title="Pas assez de trades" sub="Au moins 2 trades sont nécessaires pour tracer le P&L cumulé." />;
  }
  return <CumulativeChartReal points={points} dates={dates} H={height} ddRange={ddRange} />;
}

// Histogramme (barres verticales, +/-).
// data : [{ label, labelShort?, name?, value, color?, valueText?, empty?, emptyText? }]
//   · label / labelShort : libellé d'axe, et sa forme courte quand la place manque ;
//   · name      : nom complet de la barre pour l'info-bulle et le lecteur d'écran
//                 (« vendredi » pour « Ve ») — à défaut, label ;
//   · valueText : valeur en toutes lettres (« 3 trades », « +120,00 €, 4 trades ») ;
//   · empty     : rien à compter (jour sans trade) : « — », jamais « +0,00 € ».
// La valeur de chaque barre est ÉCRITE en permanence dès qu'elle tient dans la
// place d'une barre (largeur mesurée, même règle que StatsHabitBars : 11 px,
// réduite jusqu'à 9 px), d'abord avec valueFmt, sinon avec valueFmtShort. Quand
// même la forme courte ne tient pas (montants sur téléphone), elle s'affiche à
// la demande — survol, toucher ou focus clavier : chaque barre est alors
// focalisable. Dans tous les cas le graphique est un role="img" dont le libellé
// énonce toutes les valeurs, et chaque barre porte son libellé et son info-bulle.
const STATS_BARS_LABEL_SIZE = 10.5; // taille normale d'un libellé d'axe
const STATS_BARS_LABEL_MIN = 9;
function StatsBars({ data, height = 180, valueFmt, valueFmtShort, ariaLabel }) {
  const [ref, w] = useStatsWidth(520);
  useStatsFontEpoch(); // largeurs mesurées : à refaire quand la police arrive
  const [hover, setHover] = React.useState(null);
  const [focus, setFocus] = React.useState(null); // { i, ring } : barre focalisée, anneau si focus clavier
  if (!data || !data.length) return <div ref={ref}><StatsEmpty title="Aucune donnée" /></div>;

  const H = height;
  const max = Math.max(0, ...data.map(d => d.value));
  const min = Math.min(0, ...data.map(d => d.value));
  // Sous une barre négative, la valeur a sa propre ligne : elle ne s'écrit
  // jamais sur les libellés d'axe.
  const padT = 20, padB = 28 + (min < 0 ? 14 : 0), padX = 6;
  const innerW = w - padX * 2, innerH = H - padT - padB;
  const span = (max - min) || 1;
  const zeroY = padT + innerH - ((0 - min) / span) * innerH;
  const slot = innerW / data.length;
  const bw = Math.min(48, slot * 0.6);
  const fmt = valueFmt || (v => fmtMoney(v, true));
  const numOpts = { num: true };

  // Valeurs : la taille qui fait tenir la plus large dans la place d'une barre.
  const rated = data.filter(d => !d.empty);
  const fitValues = (f) => {
    const widest = statsMaxTextWidth(rated.map(d => f(d.value)), STATS_BAR_VALUE_SIZE, 700, numOpts);
    return widest > 0 ? Math.floor((STATS_BAR_VALUE_SIZE * (slot - 2) / widest) * 2) / 2 : STATS_BAR_VALUE_SIZE;
  };
  let shownFmt = fmt, valueFit = fitValues(fmt);
  if (valueFit < STATS_BAR_VALUE_MIN && valueFmtShort) { shownFmt = valueFmtShort; valueFit = fitValues(valueFmtShort); }
  const showValues = valueFit >= STATS_BAR_VALUE_MIN;
  const valueSize = Math.min(STATS_BAR_VALUE_SIZE, valueFit);

  // Libellés d'axe : complets à 10,5 px, puis en plus petit (9 px au moins),
  // sinon leur forme courte — deux voisins gardent STATS_BAR_LABEL_GAP entre eux.
  const fitLabels = (list) => {
    const widest = statsMaxTextWidth(list, STATS_BARS_LABEL_SIZE, 500);
    return widest > 0 ? Math.floor((STATS_BARS_LABEL_SIZE * (slot - STATS_BAR_LABEL_GAP) / widest) * 2) / 2 : STATS_BARS_LABEL_SIZE;
  };
  let labels = data.map(d => d.label), labelFit = fitLabels(labels);
  if (labelFit < STATS_BARS_LABEL_MIN && data.some(d => d.labelShort)) {
    labels = data.map(d => d.labelShort || d.label);
    labelFit = fitLabels(labels);
  }
  const labelSize = Math.max(STATS_BARS_LABEL_MIN, Math.min(STATS_BARS_LABEL_SIZE, labelFit));

  const titles = data.map(d => (d.name || d.label) + ' : ' + (d.empty ? (d.emptyText || 'aucune donnée') : (d.valueText || fmt(d.value))));
  const halo = { paintOrder: 'stroke', stroke: 'var(--bg-card)', strokeWidth: 3, strokeLinejoin: 'round' };
  // Barre mise en avant : celle survolée, sinon — quand les valeurs sont à la
  // demande, seul cas où les barres sont focalisables — celle qui a le focus.
  const active = hover != null ? hover : (!showValues && focus ? focus.i : null);
  const onFocusBar = (i) => (e) => {
    let ring = true;
    try { ring = e.currentTarget.matches(':focus-visible'); } catch (err) { ring = true; }
    setFocus({ i, ring });
  };

  return (
    <div ref={ref} style={{ width: '100%' }}>
      <svg width="100%" viewBox={`0 0 ${w} ${H}`} role="img"
        aria-label={(ariaLabel ? ariaLabel + ' — ' : '') + titles.join(' ; ')}
        data-stats-bars={showValues ? 'values' : 'on-demand'} style={{ display: 'block', overflow: 'visible' }}>
        <line x1={padX} y1={zeroY} x2={padX + innerW} y2={zeroY} stroke="var(--line-2)" strokeWidth="1" />
        {data.map((d, i) => {
          const cx = padX + slot * i + slot / 2;
          const v = d.value;
          const pos = v >= 0;
          const top = pos ? padT + innerH - ((v - min) / span) * innerH : zeroY;
          const bh = Math.max(v === 0 ? 0 : 2, Math.abs((v / span) * innerH));
          const on = active === i;
          const fill = d.color || (pos ? 'var(--green)' : 'var(--red)');
          const valueY = pos ? top - 7 : top + bh + 12;
          // À la demande : le montant complet, ramené dans la carte s'il est plus
          // large que la place de la première ou de la dernière barre.
          let demand = null;
          if (!showValues && on && !d.empty) {
            const text = fmt(v);
            const half = statsTextWidth(text, STATS_BAR_VALUE_SIZE, 700, numOpts) / 2;
            demand = { text, x: half * 2 >= w ? w / 2 : Math.max(half, Math.min(w - half, cx)) };
          }
          return (
            <g key={i} role="img" aria-label={titles[i]} data-stats-bar={i}
              tabIndex={showValues ? undefined : 0}
              onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(null)}
              onClick={showValues ? undefined : () => setHover(i)}
              onFocus={showValues ? undefined : onFocusBar(i)}
              onBlur={showValues ? undefined : () => { setFocus(null); setHover(null); }}
              style={{ cursor: showValues ? 'default' : 'pointer', outline: 'none' }}>
              <title>{titles[i]}</title>
              {/* Zone de la barre. Valeurs à la demande (écran étroit) : elle se
                  touche, donc 44 px de large au moins (taille tactile). */}
              <rect x={cx - (showValues ? slot : Math.max(slot, 44)) / 2} y={padT} width={showValues ? slot : Math.max(slot, 44)} height={innerH} fill="transparent" />
              {bh > 0 && (
                <rect x={cx - bw / 2} y={top} width={bw} height={bh} rx="4" fill={fill}
                  opacity={active == null || on ? 1 : 0.4}
                  style={{ transformBox: 'fill-box', transformOrigin: 'center bottom', animation: `bar-grow .5s var(--ease) ${0.03 * i}s both` }} />
              )}
              {/* Anneau de focus clavier : sur la colonne de la barre, pas autour des textes. */}
              {!showValues && focus && focus.i === i && focus.ring && (
                <rect data-stats-bar-focus x={cx - slot / 2 + 2} y={padT - 2} width={Math.max(0, slot - 4)} height={innerH + 4} rx="5"
                  fill="none" style={{ stroke: 'var(--blue)', strokeWidth: 2, pointerEvents: 'none' }} />
              )}
              {d.empty
                ? <text x={cx} y={zeroY - 7} textAnchor="middle" data-stats-bar-empty style={{ fontSize: 11, fill: 'var(--fg-3)' }}>—</text>
                : showValues
                  ? <text x={cx} y={valueY} textAnchor="middle" className="num" data-stats-bar-value
                      style={{ fontSize: valueSize, fontWeight: 700, fill: 'var(--fg)', ...halo }}>{shownFmt(v)}</text>
                  : demand && <text x={demand.x} y={valueY} textAnchor="middle" className="num" data-stats-bar-value
                      style={{ fontSize: STATS_BAR_VALUE_SIZE, fontWeight: 700, fill: 'var(--fg)', pointerEvents: 'none', ...halo }}>{demand.text}</text>}
              <text x={cx} y={H - 9} textAnchor="middle" data-stats-bar-label style={{ fontSize: labelSize, fill: 'var(--fg-3)', fontWeight: 500 }}>{labels[i]}</text>
            </g>
          );
        })}
      </svg>
    </div>
  );
}

// Barres horizontales (classement par montant). rows: [{ label, value, sub }].
function StatsHBars({ rows }) {
  if (!rows || !rows.length) return <StatsEmpty title="Aucune donnée" />;
  const maxAbs = Math.max(1, ...rows.map(r => Math.abs(r.value)));
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
      {rows.map((r, i) => {
        const pct = (Math.abs(r.value) / maxAbs) * 100;
        // Montant nul : texte neutre var(--fg), barre discrète.
        const color = statsToneColor(statsMoneyTone(r.value), null);
        return (
          <div key={i}>
            <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 6, gap: 10 }}>
              <span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--fg)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.label}</span>
              <span className="num" style={{ fontSize: 12.5, fontWeight: 600, color: color || 'var(--fg)', flexShrink: 0, whiteSpace: 'nowrap' }}>
                {fmtMoney(r.value, true)}
                {r.sub != null && <span style={{ color: 'var(--fg-3)', fontWeight: 500, marginLeft: 7 }}>{r.sub}</span>}
              </span>
            </div>
            <div style={{ height: 8, borderRadius: 5, background: 'var(--bg-elev)', overflow: 'hidden' }}>
              <div style={{ width: `${Math.max(2, pct)}%`, height: '100%', borderRadius: 5, background: color || 'var(--line-2)', transition: 'width .7s var(--ease)' }} />
            </div>
          </div>
        );
      })}
    </div>
  );
}

// ═════════════════════════════════════════════════════════════════════
//  TRADING
// ═════════════════════════════════════════════════════════════════════
// Trades de la période : filtre sur le jour civil de chaque trade dans le fuseau
// du profil (jamais sur minuit du navigateur). « Tout » reste sans borne, comme
// le Tableau de bord.
function statsTradesInRange(trades, range) {
  if (!Array.isArray(trades)) return [];
  if (!range.fromISO) return trades.slice();
  return trades.filter(t => {
    const k = statsDayKey(t.executed_at);
    return !!k && k >= range.fromISO && k <= range.toISO;
  });
}

// P&L par jour civil (fuseau du profil) : { 'YYYY-MM-DD': { pnl, count, wins, losses } }.
function statsBuildDayMap(trades) {
  const m = {};
  for (const t of trades) {
    const day = statsDayKey(t.executed_at);
    if (!day) continue;
    if (!m[day]) m[day] = { pnl: 0, count: 0, wins: 0, losses: 0 };
    const v = Number(t.pnl) || 0;
    m[day].pnl += v; m[day].count++;
    if (v > 0) m[day].wins++; else if (v < 0) m[day].losses++;
  }
  return m;
}

// Premier jour tradé (pour la plage affichée sous « Tout »).
function useStatsFirstDay(trades) {
  return React.useMemo(() => {
    let min = null;
    for (const t of trades) {
      const k = statsDayKey(t.executed_at);
      if (k && (!min || k < min)) min = k;
    }
    return min;
  }, [trades]);
}

// tradesByDay / today / nav / openJournal : ceux de l'app (state.tradesByDay,
// state.today), pour que le Calendrier P&L soit identique à celui du Tableau de bord.
function StatsTrading({ trades, range, setPeriod, compact = false, loading = false, error = false, onAddTrade, onImportFile, tradesByDay, today, nav, openJournal }) {
  const all = Array.isArray(trades) ? trades : [];
  const list = React.useMemo(() => statsTradesInRange(all, range), [trades, range]);
  const stats = React.useMemo(() => computeStats(list), [list]);
  const [bodyRef, bodyW] = useStatsBodyWidth();
  useStatsFontEpoch(); // les bandeaux mesurent leurs valeurs : à refaire quand la police arrive

  const sortedAsc = React.useMemo(
    () => [...list].sort((a, b) => new Date(a.executed_at) - new Date(b.executed_at)),
    [list]
  );

  // P&L cumulé + dates + métriques dérivées (drawdown, séries).
  const { cumulative, cumDates, derived } = React.useMemo(() => {
    let run = 0;
    const c = [], d = [];
    let peak = 0, maxDD = 0, ddPeakIdx = 0, ddTroughIdx = 0, curPeakIdx = 0;
    let curWin = 0, curLoss = 0, bestWin = 0, bestLoss = 0;
    for (let i = 0; i < sortedAsc.length; i++) {
      const p = Number(sortedAsc[i].pnl) || 0;
      run += p;
      c.push(run); d.push(sortedAsc[i].executed_at);
      if (run >= peak) { peak = run; curPeakIdx = i; }
      const dd = peak - run;
      if (dd > maxDD) { maxDD = dd; ddPeakIdx = curPeakIdx; ddTroughIdx = i; }
      if (p > 0) { curWin++; curLoss = 0; if (curWin > bestWin) bestWin = curWin; }
      else if (p < 0) { curLoss++; curWin = 0; if (curLoss > bestLoss) bestLoss = curLoss; }
    }
    return { cumulative: c, cumDates: d, derived: { maxDD, ddPeakIdx, ddTroughIdx, bestWin, bestLoss } };
  }, [sortedAsc]);

  // Distribution des R multiples — seulement si des R sont renseignés.
  // Tranches symétriques autour de zéro, classées sur le R tel qu'il s'écrit
  // (deux décimales) : un stop plein à −1,00 R va dans « −2 à −1 R » comme un
  // gain de +1,00 R va dans « 1 à 2 R » ; −2,00 R dans « ≤ −2 R », +2,00 R dans « ≥ 2 R ».
  const rDist = React.useMemo(() => {
    const RED = 'var(--red)', GREEN = 'var(--green)';
    const buckets = [
      { label: '≤ −2 R', short: '≤ −2', value: 0, color: RED },
      { label: '−2 à −1 R', short: '−2 à −1', value: 0, color: RED },
      { label: '−1 à 0 R', short: '−1 à 0', value: 0, color: RED },
      { label: '0 à 1 R', short: '0 à 1', value: 0, color: GREEN },
      { label: '1 à 2 R', short: '1 à 2', value: 0, color: GREEN },
      { label: '≥ 2 R', short: '≥ 2', value: 0, color: GREEN },
    ];
    let rTrades = 0;
    for (const t of list) {
      const rv = Number(t.r_multiple);
      if (t.r_multiple == null || t.r_multiple === '' || !Number.isFinite(rv) || rv === 0) continue;
      rTrades++;
      const size = Math.abs(statsRound(rv, 2));
      const k = size >= 2 ? 2 : size >= 1 ? 1 : 0;
      buckets[rv < 0 ? 2 - k : 3 + k].value++;
    }
    return rTrades > 0 ? { buckets, rTrades } : null;
  }, [list]);

  // Performance par stratégie (P&L net) : les 6 plus significatives, classées par
  // P&L décroissant (pertes en bas, même ordre que la page Stratégies), + taux de réussite.
  // Un trade appartient à une stratégie quand t.setup === strategy.name (strategy.jsx).
  const byStrategy = React.useMemo(() => {
    const m = {};
    for (const t of list) {
      const name = (t.setup && String(t.setup).trim()) || '';
      const key = name ? 's:' + name : 'none';
      if (!m[key]) m[key] = { label: name || 'Sans stratégie', none: !name, value: 0, count: 0, wins: 0 };
      const p = Number(t.pnl) || 0;
      m[key].value += p; m[key].count++; if (p > 0) m[key].wins++;
    }
    const arr = Object.values(m);
    return {
      hasStrategies: arr.some(s => !s.none),
      // Les 6 plus significatives (|P&L|), puis affichées par P&L décroissant.
      rows: arr.sort((a, b) => Math.abs(b.value) - Math.abs(a.value)).slice(0, 6).sort((a, b) => b.value - a.value).map(s => ({
        label: s.label, value: s.value,
        sub: statsTradesText(s.count) + ' · ' + fmtPct(s.count ? (s.wins / s.count) * 100 : 0, 0),
      })),
    };
  }, [list]);

  // Performance par jour de la semaine (P&L net), jour civil du fuseau du profil.
  const byWeekday = React.useMemo(() => {
    const sums = [0, 0, 0, 0, 0, 0, 0];
    const counts = [0, 0, 0, 0, 0, 0, 0];
    for (const t of list) {
      const k = statsDayKey(t.executed_at);
      if (!k) continue;
      const wd = habitWeekdayNum(k) - 1; // lundi = 0 … dimanche = 6
      sums[wd] += Number(t.pnl) || 0; counts[wd]++;
    }
    const any = counts.some(c => c > 0);
    return any ? FR_WEEKDAYS_2.map((lab, i) => ({ label: lab, value: sums[i], count: counts[i] })) : null;
  }, [list]);

  // P&L par jour de la période choisie (meilleur / pire jour).
  const dayMap = React.useMemo(() => statsBuildDayMap(list), [list]);

  // Meilleur jour parmi les jours gagnants, pire jour parmi les jours perdants.
  const bestWorst = React.useMemo(() => {
    const entries = Object.entries(dayMap);
    if (!entries.length) return null;
    let best = null, worst = null;
    for (const [day, v] of entries) {
      const s = statsMoneySign(v.pnl);
      if (s > 0 && (!best || v.pnl > best.pnl)) best = { day, pnl: v.pnl };
      if (s < 0 && (!worst || v.pnl < worst.pnl)) worst = { day, pnl: v.pnl };
    }
    return { best, worst };
  }, [dayMap]);

  if (list.length === 0) {
    if (loading) return <StatsTradingSkeleton compact={compact} />;
    if (error) return null; // le bandeau d'erreur suffit : ce n'est pas un compte vide
    // Compte sans aucun trade : l'état vide commun à tous les écrans (core.jsx).
    if (all.length === 0) {
      return <div className="card"><EmptyTrades onAdd={onAddTrade} onImport={onImportFile} compact={compact}/></div>;
    }
    return (
      <div className="card">
        <StatsEmpty icon={Ico.trades} title="Aucun trade sur cette période"
          sub="Tes trades sont en dehors de cette période. Affiche tout ton historique pour les analyser."
          action={setPeriod ? { label: 'Voir tout l’historique', onClick: () => setPeriod('all') } : null} />
      </div>
    );
  }

  const nuls = Number.isFinite(stats.breakeven) ? stats.breakeven : stats.count - stats.wins - stats.losses;
  const pfText = stats.wins + stats.losses === 0 ? '—' : fmtPF(stats.profitFactor, stats.count);
  const expectancy = stats.count ? stats.netPnl / stats.count : 0;
  const ddRange = derived.maxDD > 0 ? { peak: derived.ddPeakIdx, trough: derived.ddTroughIdx } : null;
  // Bandeaux secondaires — téléphone : 2 colonnes ; dès qu'il y a la place : 4.
  const narrow = compact && bodyW < STATS_FOUR_COLS_MIN;
  const gap = compact ? 10 : 14;
  // Les 4 indicateurs : colonnes et taille selon la largeur MESURÉE et le texte
  // le plus long, pour qu'un montant ne sorte jamais de sa carte (demi-fenêtre
  // d'ordinateur, tablette, P&L à six chiffres). Dans l'ordre : 4 colonnes à
  // 30 px, 4 colonnes à 24 px, 2 × 2, puis montants en pleine largeur (1-2-1,
  // téléphone) avec une taille réduite si même la pleine largeur est juste.
  const kpiLongest = [fmtMoney(stats.netPnl, true), fmtPct(stats.winRate, 1), pfText, fmtMoney(expectancy, true)]
    .reduce((m, t) => Math.max(m, String(t).length), 0);
  const kpiNeed = (font) => kpiLongest * font * 0.56 + 42; // chiffres gras + marges et bord de la carte
  const kpiW = (cols) => (bodyW - (cols - 1) * gap) / cols;
  let kpiCols = 4, kpiSize = 30, kpiWide = false;
  if (bodyW <= 0) { kpiCols = compact ? 2 : 4; kpiWide = compact; }
  else if (bodyW >= STATS_FOUR_COLS_MIN && kpiW(4) >= kpiNeed(30)) { /* 4 colonnes, 30 px */ }
  else if (bodyW >= STATS_FOUR_COLS_MIN && kpiW(4) >= kpiNeed(24)) kpiSize = 24;
  else if (kpiW(2) >= kpiNeed(30)) kpiCols = 2;
  else if (kpiW(2) >= kpiNeed(24)) { kpiCols = 2; kpiSize = 24; }
  else {
    kpiCols = 2; kpiWide = true;
    kpiSize = Math.max(18, Math.min(30, Math.floor((bodyW - 42) / (kpiLongest * 0.56))));
  }
  const streakValue = n => (n > 0 ? statsTradesText(n) : '—');
  // Les deux bandeaux de chiffres secondaires : mêmes colonnes et même taille
  // (cohérence), choisies d'après la valeur la plus large des deux, MESURÉE.
  // Téléphone : 2 colonnes à 17 px, puis en plus petit (CHF à cinq ou six
  // chiffres en 320 px), puis une colonne. Un montant n'est jamais abrégé ici
  // (fmtMoneyCompact est réservé aux calendriers et aux axes, charte §1).
  const stripMain = [
    { label: 'R moyen', value: rDist ? fmtR(stats.avgR) : '—', tone: rDist ? (stats.avgR > 0 ? 'pos' : stats.avgR < 0 ? 'neg' : undefined) : undefined,
      sub: rDist ? null : 'aucun R renseigné' },
    { label: 'Gain moyen', value: stats.wins > 0 ? fmtMoney(stats.avgWin, true) : '—', tone: stats.wins > 0 ? 'pos' : undefined,
      sub: stats.wins > 0 ? null : 'aucun gain' },
    { label: 'Perte moyenne', value: stats.losses > 0 ? fmtMoney(-stats.avgLoss, true) : '—', tone: stats.losses > 0 ? 'neg' : undefined,
      sub: stats.losses > 0 ? null : 'aucune perte' },
    { label: 'Drawdown max', value: derived.maxDD > 0 ? fmtMoney(-derived.maxDD, true) : fmtMoney(0), tone: statsMoneyTone(-derived.maxDD) },
  ];
  // Meilleur / pire jour + plus longues séries.
  const stripDays = bestWorst ? [
    { label: 'Meilleur jour', value: bestWorst.best ? fmtMoney(bestWorst.best.pnl, true) : '—', tone: bestWorst.best ? 'pos' : undefined,
      sub: bestWorst.best ? fmtDayShort(bestWorst.best.day) : 'aucun jour gagnant' },
    { label: 'Pire jour', value: bestWorst.worst ? fmtMoney(bestWorst.worst.pnl, true) : '—', tone: bestWorst.worst ? 'neg' : undefined,
      sub: bestWorst.worst ? fmtDayShort(bestWorst.worst.day) : 'aucun jour perdant' },
    { label: 'Plus longue série gagnante', value: streakValue(derived.bestWin), accent: derived.bestWin > 0 ? 'var(--green)' : undefined,
      sub: derived.bestWin > 0 ? 'd’affilée' : 'aucun gain' },
    { label: 'Plus longue série perdante', value: streakValue(derived.bestLoss), accent: derived.bestLoss > 0 ? 'var(--red)' : undefined,
      sub: derived.bestLoss > 0 ? 'd’affilée' : 'aucune perte' },
  ] : null;
  const stripMaxW = statsMaxTextWidth(stripMain.concat(stripDays || []).map((it) => it.value),
    STATS_STRIP_SIZE, 700, { num: true, letterSpacing: STATS_STRIP_SPACING });
  const strip = statsStripLayout(bodyW, narrow ? 2 : 4, stripMaxW);

  return (
    <div ref={bodyRef} className="stagger" style={{ display: 'flex', flexDirection: 'column', gap: compact ? 12 : 16 }}>
      {/* Indicateurs principaux — les 4 chiffres qui pilotent une décision.
          Sur téléphone : montants sur toute la largeur (1-2-1), jamais tronqués. */}
      <div style={{ display: 'grid', gridTemplateColumns: `repeat(${kpiCols}, minmax(0, 1fr))`, gap }}>
        <StatsKpi big size={kpiSize} wide={kpiWide} label="P&L net" animatedMoney={stats.netPnl} tone={statsMoneyTone(stats.netPnl)}
          sub={statsTradesText(stats.count)} />
        <StatsKpi big size={kpiSize} label="Taux de réussite" value={fmtPct(stats.winRate, 1)}
          sub={fmtNum(stats.wins, 0) + ' G · ' + fmtNum(stats.losses, 0) + ' P' + (nuls > 0 ? ' · ' + fmtNum(nuls, 0) + ' nul' + (nuls > 1 ? 's' : '') : '')} />
        <StatsKpi big size={kpiSize} label="Profit factor" value={pfText}
          sub={stats.wins === 0 ? 'ratio gain/perte —' : stats.avgLoss > 0 ? `ratio gain/perte ${fmtNum(stats.avgWin / stats.avgLoss, 2)}` : 'aucune perte'} />
        <StatsKpi big size={kpiSize} wide={kpiWide} label="Espérance par trade" animatedMoney={expectancy} tone={statsMoneyTone(expectancy)}
          sub={'sur ' + statsTradesText(stats.count)} />
      </div>

      {/* Bandeau secondaire : R moyen, gain / perte moyens, drawdown max */}
      <StatsStrip cols={strip.cols} size={strip.size} items={stripMain} />

      {/* P&L cumulé — pièce maîtresse */}
      <StatsPanel title="P&L cumulé"
        subtitle={ddRange ? `Drawdown max ${fmtMoney(-derived.maxDD, true)}` : statsTradesText(list.length)}>
        <StatsEquityCurve points={cumulative} dates={cumDates} ddRange={ddRange} height={compact ? 190 : 250} />
      </StatsPanel>

      {/* Chiffres-clés discrets : meilleur / pire jour + plus longues séries */}
      {stripDays && <StatsStrip cols={strip.cols} size={strip.size} items={stripDays} />}

      {/* Calendrier P&L : LE MÊME composant que le Tableau de bord (CalendarBlock,
          pages.jsx) — mêmes jours, colonne Semaine, pastilles G/P, total du mois,
          clic sur un jour = journal du jour. Tout l'historique, mois navigable. */}
      {typeof CalendarBlock === 'function' && (
        <CalendarBlock nav={nav} onDayClick={openJournal} tradesByDay={tradesByDay || {}}
          compact={compact} today={today || range.toISO} showAdd={false} />
      )}

      {/* Deux colonnes : distribution des R + P&L par jour de la semaine */}
      <div style={{ display: 'grid', gridTemplateColumns: compact ? 'minmax(0, 1fr)' : 'repeat(2, minmax(0, 1fr))', gap }}>
        <StatsPanel title="Distribution des R"
          subtitle={rDist ? statsTradesText(rDist.rTrades) + ' avec R' : null}>
          {rDist
            ? <StatsBars ariaLabel={'Distribution des R : ' + statsTradesText(rDist.rTrades) + ' avec R'}
                data={rDist.buckets.map(b => ({
                  label: b.label, labelShort: b.short, value: b.value, valueText: statsTradesText(b.value),
                  color: b.value > 0 ? b.color : 'var(--line-2)',
                }))}
                height={compact ? 160 : 186} valueFmt={v => fmtNum(v, 0)} />
            : <StatsEmpty title="Aucun R renseigné" sub="Ajoute un R multiple à tes trades pour voir leur distribution." />}
        </StatsPanel>

        <StatsPanel title="P&L par jour de la semaine" subtitle={'Quel jour te réussit le mieux ?'}>
          {byWeekday
            ? <StatsBars ariaLabel="P&L par jour de la semaine"
                data={byWeekday.map((d, i) => ({
                  label: d.label, name: FR_WEEKDAYS_LONG[i], value: d.value,
                  empty: d.count === 0, emptyText: 'aucun trade',
                  valueText: fmtMoney(d.value, true) + ', ' + statsTradesText(d.count),
                }))}
                height={compact ? 160 : 186} valueFmtShort={v => fmtMoneyCompact(v, true)} />
            : <StatsEmpty title="Aucune donnée" />}
        </StatsPanel>
      </div>

      {/* Performance par stratégie */}
      <StatsPanel title="Performance par stratégie" subtitle="P&L net · trades · taux de réussite">
        {byStrategy.hasStrategies
          ? <StatsHBars rows={byStrategy.rows} />
          : <StatsEmpty title="Aucune stratégie renseignée" sub="Associe une stratégie à tes trades pour voir celles qui te réussissent." />}
      </StatsPanel>
    </div>
  );
}

// ═════════════════════════════════════════════════════════════════════
//  HABITUDES (docs/spec-habitudes.md §8.5)
// ═════════════════════════════════════════════════════════════════════
// Cet écran ne fait AUCUNE requête et ne contient AUCUNE règle de calcul : il
// lit l'état de l'app (state.habitRows, state.habitCtx, state.habitGoal…) et
// appelle le moteur de design/state.jsx (habitDayTally, habitRangeStats,
// habitRate, habitStreaks, habitEvolution, habitWeekdayStats, habitDiscipline).
// Un indicateur a donc la même valeur ici, dans Routines et sur l'Accueil.
// Ordre à l'écran : indicateurs, Calendrier de régularité + Détail du jour,
// Évolution et Par jour de la semaine, Détail par habitude, Discipline et résultats.

const STATS_NB = ' ';
const STATS_NO_ROWS = [];
const STATS_HABIT_MIN_SAMPLE = 5;   // jours par groupe pour afficher un écart (§8.5.7)
const STATS_HABIT_SMALL_SAMPLE = 15; // en dessous : pastille « Échantillon réduit »
const STATS_HABIT_LARGE_ZONE = 620;  // largeur de zone du calendrier à partir de laquelle les cases sont grandes
const STATS_HABIT_SIDE_MAX = 8;      // au-delà, le Détail du jour (une ligne par habitude) passe sous la grille

// Arrondi à `dec` décimales, comme l'affichage (fmtR, fmtMoney) : un écart se
// calcule sur les valeurs telles qu'elles sont écrites à l'écran.
function statsRound(v, dec) {
  const p = Math.pow(10, dec);
  return Math.round(Number(v) * p * (1 + 8 * Number.EPSILON)) / p;
}

function statsCap(s) {
  const t = String(s == null ? '' : s);
  return t ? t.charAt(0).toUpperCase() + t.slice(1) : t;
}
// « 0 jour », « 1 jour », « 2 jours » ; forme courte « 5 j » réservée aux
// endroits où le mot « série » ou « record » précède (charte §5).
function statsDaysText(n, short = false) {
  const v = Number(n) || 0;
  return fmtNum(v, 0) + STATS_NB + (short ? 'j' : v > 1 ? 'jours' : 'jour');
}
// « 0 trade », « 1 trade », « 1 500 trades » : même règle que les jours (charte §1,
// séparateur de milliers ; espace insécable entre le nombre et son unité).
function statsTradesText(n) {
  return fmtCount(n, 'trade', STATS_NB);
}
// Écart en points : « +6 pts », « −1 pt », « 0 pt ».
function statsPtsText(delta) {
  return fmtNum(delta, 0, true) + STATS_NB + (Math.abs(Number(delta) || 0) > 1 ? 'pts' : 'pt');
}
// Les compteurs passent par fmtNum : « 1 500 faites sur 1 593 prévues » (charte §1).
function statsOutOf(done, planned) { return fmtNum(done, 0) + ' sur ' + fmtNum(planned, 0); }
function statsDonePlanned(done, planned) {
  return fmtNum(done, 0) + ' faite' + (done > 1 ? 's' : '') + ' sur ' + fmtNum(planned, 0) + ' prévue' + (planned > 1 ? 's' : '');
}
function statsTallyPct(t) { return t && t.planned > 0 ? (t.done / t.planned) * 100 : null; }

// Mois civils 'YYYY-MM' (arithmétique en UTC, jamais dans le fuseau du navigateur).
function statsMonthShift(ym, n) {
  return new Date(Date.UTC(Number(ym.slice(0, 4)), Number(ym.slice(5, 7)) - 1 + n, 1, 12)).toISOString().slice(0, 7);
}
function statsMonthLast(ym) { return habitShiftDay(statsMonthShift(ym, 1) + '-01', -1); }
// Semaines (lundi → dimanche) qui touchent un mois. `a` et `b` : la part de la
// semaine située DANS le mois. Une seule règle pour la colonne « Semaine » du
// calendrier et pour le graphique « Évolution » : « Sem. 40 » y vaut la même chose.
function statsMonthWeeks(ym) {
  const firstOfMonth = ym + '-01', lastOfMonth = statsMonthLast(ym);
  const out = [];
  for (let m = habitWeekStart(firstOfMonth); m <= lastOfMonth; m = habitShiftDay(m, 7)) {
    const sun = habitShiftDay(m, 6);
    out.push({
      monday: m, days: [0, 1, 2, 3, 4, 5, 6].map((k) => habitShiftDay(m, k)),
      a: m < firstOfMonth ? firstOfMonth : m, b: sun > lastOfMonth ? lastOfMonth : sun,
    });
  }
  return out;
}
function statsMonthSpan(fromYm, toYm) {
  const out = [];
  for (let m = fromYm, guard = 0; m <= toYm && guard < 600; m = statsMonthShift(m, 1), guard++) out.push(m);
  return out;
}

// Libellé d'un statut habitude × jour (§5.5), en minuscules (colonne de droite
// du Détail du jour, info-bulle d'une case filtrée).
function statsHabitStatusText(status, h, ctx) {
  switch (status) {
    case 'done': case 'today-done': return 'fait';
    case 'missed': return 'manqué';
    case 'extra': return 'fait, non compté';
    case 'today': return 'à faire';
    case 'future': return 'à venir';
    case 'off':
      if (h && h.section === 'pretrade') return ctx && ctx.tradedDays ? 'pas de trade ce jour' : 'non compté';
      return 'pas prévu';
    default: return 'non suivie';
  }
}

// Info-bulle et libellé accessible d'une case du calendrier.
function statsHabitCellTitle(iso, t, single, ctx) {
  const day = statsCap(fmtDayLong(iso));
  if (t.state === 'future') return day + ' · à venir';
  if (single) {
    const st = habitDayStatus(single, iso, ctx);
    if (st === 'none') return day + ' · rien de prévu';
    return day + ' · ' + (t.state === 'today' ? 'aujourd’hui · ' : '') + statsHabitStatusText(st, single, ctx);
  }
  if (t.state === 'today') return day + ' · aujourd’hui, en cours · ' + (t.planned ? statsOutOf(t.done, t.planned) : 'rien de prévu');
  if (t.state === 'none') return day + ' · rien de prévu';
  return day + ' · ' + fmtNum(t.done, 0) + ' habitude' + (t.done > 1 ? 's' : '') + ' faite' + (t.done > 1 ? 's' : '')
    + ' sur ' + fmtNum(t.planned, 0) + ' · ' + fmtPct(statsTallyPct(t), 0);
}

// ─── Case du calendrier ──────────────────────────────────────────────
// Un bouton par jour. kind : 'week' (grande case), 'week-sm' (semaine étroite),
// 'month' (mois entier), 'mini' (mini-calendrier). Semaine et mois : le chiffre
// « 3/3 » est écrit dans la case, la couleur ne fait que le confirmer. Mini :
// la case ne porte que le numéro du jour, coloré selon le niveau. `single` :
// habitude filtrée — le signe (coche, croix, tiret) remplace le COMPTE, jamais
// la date : en mini, la case garde son numéro et prend le fond et le bord du signe.
function StatsHabitCell({ iso, t, kind, dims, theme, selected, onSelect, single, ctx, dim = false }) {
  const isToday = t.state === 'today';
  const counted = t.state === 'counted';
  const st = single ? habitDayStatus(single, iso, ctx) : null;
  let look;
  if (isToday) look = { background: 'var(--bg-card)', color: 'var(--fg)', border: '1.5px solid var(--blue)' };
  else if (t.state === 'future') look = { background: 'transparent', color: 'var(--fg-3)', border: '1px dashed var(--line-2)' };
  else if (counted && !single) look = habitLevelStyle(t.level, theme);
  else look = { background: 'var(--bg-card)', color: single && st !== 'none' ? 'var(--fg)' : 'var(--fg-3)', border: '1px solid var(--line-2)' };
  const shadow = selected ? '0 0 0 1px var(--bg-card), 0 0 0 3px var(--fg)'
    : isToday ? '0 0 0 2px rgba(59,130,246,.16)' : 'none';
  const title = statsHabitCellTitle(iso, t, single, ctx);
  const dayNum = Number(iso.slice(8, 10));
  const wd = habitWeekdayNum(iso) - 1;
  const frac = t.planned > 0 ? fmtNum(t.done, 0) + '/' + fmtNum(t.planned, 0) : null;
  const dot = isToday ? <span aria-hidden="true" style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--blue)', flexShrink: 0 }}/> : null;
  const showMark = !!single && st !== 'none' && st !== 'future';
  // Jour d'un mois voisin (`dim`) : case estompée SANS opacité — une opacité
  // estompe aussi le texte, qui tombait à 2:1. Le fond du niveau est mélangé à
  // 50 % avec celui de la carte (le même fond qu'une opacité de 0,5) et le texte
  // passe en var(--fg) : 4,5:1 au moins sur les cinq niveaux, en clair comme en
  // sombre. Sans niveau (à venir, rien de prévu, habitude filtrée), la case garde
  // son fond ; l'encre pleine d'une habitude filtrée descend d'un cran (--fg-2).
  const dimMix = dim && counted && !single && t.level !== null && t.level !== undefined;
  const dimSoft = dim && !dimMix && !isToday && look.color === 'var(--fg)';
  const base = {
    background: dimMix ? 'color-mix(in srgb, ' + look.background + ' 50%, var(--bg-card))' : look.background,
    color: dimMix ? 'var(--fg)' : dimSoft ? 'var(--fg-2)' : look.color,
    border: dim ? String(look.border).replace('solid', 'dashed') : look.border,
    boxShadow: shadow, cursor: 'pointer', fontFamily: 'inherit',
    minWidth: 0, margin: 0, position: 'relative', boxSizing: 'border-box',
  };
  const common = {
    type: 'button', className: 'tap', title, 'aria-label': title, 'aria-pressed': selected ? 'true' : 'false',
    'data-day': iso, 'data-state': t.state, 'data-level': counted ? t.level : undefined,
    onClick: () => onSelect(iso),
  };
  // Le compte de la journée en cours reste discret : il n'entre dans aucun taux.
  const fracColor = isToday ? 'var(--fg-2)' : 'inherit';
  // Fraction à deux chiffres (« 17/20 ») dans une case étroite : police réduite
  // et marges rendues, pour qu'aucun chiffre ne passe sous la case voisine.
  const cellW = (dims && dims.cellW) || 0;
  const longFrac = !!frac && frac.length >= 5 && cellW > 0 && cellW < 46;

  if (kind === 'mini') {
    const cell = dims.cell;
    const ml = showMark ? habitMarkLook(st) : null;
    const markStyle = !ml ? null : {
      background: isToday && st !== 'today-done' ? 'var(--bg-card)' : ml.box.background,
      border: isToday ? '1.5px solid var(--blue)' : String(ml.box.border).replace('1.5px', '1px'),
      color: st === 'extra' ? 'var(--green-text)' : ml.ink,
    };
    const withGlyph = !!(ml && ml.glyph) && cell >= 30;
    return (
      <button {...common} data-habit-status={st || undefined} style={{
        ...base, aspectRatio: '1 / 1', width: '100%', padding: 0, borderRadius: 4,
        display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 2,
        ...markStyle,
      }}>
        {cell >= 14 && <span className="num" style={{ fontSize: cell >= 34 ? 12 : cell >= 22 ? 10 : cell >= 17 ? 9 : 8.5, fontWeight: isToday ? 700 : ml ? 600 : 500, lineHeight: 1, color: 'inherit' }}>{dayNum}</span>}
        {withGlyph && <HabitGlyph kind={ml.glyph} size={14} color={markStyle.color}/>}
      </button>
    );
  }

  if (kind === 'week-sm') {
    return (
      <button {...common} style={{
        ...base, minHeight: 76, padding: longFrac ? '7px 0' : '7px 2px', borderRadius: 8, overflow: 'hidden',
        display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'space-between', gap: 2,
      }}>
        <span style={{ fontSize: 10.5, fontWeight: 600, lineHeight: 1, color: 'inherit' }}>{FR_WEEKDAYS_2[wd]}</span>
        <span className="num" style={{ fontSize: 13, fontWeight: isToday ? 700 : 600, lineHeight: 1, color: 'inherit' }}>{dayNum}</span>
        <span style={{ minHeight: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
          {showMark ? <HabitDayMark status={st} size={16}/>
            : frac && <span className="num" data-habit-frac style={{ fontSize: longFrac ? (cellW < 40 ? 10 : 11.5) : 13, letterSpacing: longFrac ? '-.03em' : undefined, fontWeight: 700, lineHeight: 1, color: fracColor, whiteSpace: 'nowrap' }}>{frac}</span>}
        </span>
      </button>
    );
  }

  if (kind === 'week') {
    return (
      <button {...common} style={{
        ...base, minHeight: 108, padding: '11px 12px', borderRadius: 10, textAlign: 'left',
        display: 'flex', flexDirection: 'column', justifyContent: 'space-between', gap: 8,
      }}>
        <span style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 6, width: '100%' }}>
          <span style={{ fontSize: 12.5, fontWeight: isToday ? 700 : 600, color: 'inherit', whiteSpace: 'nowrap' }}>{FR_WEEKDAYS_SHORT[wd]} {dayNum}</span>
          {dot}
        </span>
        {showMark ? (
          <span style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-start' }}>
            <HabitDayMark status={st} size={24}/>
            <span style={{ fontSize: 12, color: 'inherit' }}>{statsHabitStatusText(st, single, ctx)}</span>
          </span>
        ) : (counted || isToday) && (
          <span style={{ display: 'block' }}>
            {frac && <span className="num" style={{ display: 'block', fontSize: 20, fontWeight: 700, letterSpacing: '-.02em', lineHeight: 1.1, color: fracColor, whiteSpace: 'nowrap' }}>{frac}</span>}
            <span className="num" style={{ display: 'block', fontSize: 12, marginTop: 4, color: isToday ? 'var(--fg-2)' : 'inherit', whiteSpace: 'nowrap' }}>
              {isToday ? (frac ? 'en cours' : 'rien de prévu') : fmtPct(statsTallyPct(t), 0)}
            </span>
          </span>
        )}
      </button>
    );
  }

  // Mois entier : anatomie du Calendrier P&L (numéro en haut à gauche, valeur en bas).
  return (
    <button {...common} style={{
      ...base, minHeight: dims.h, padding: dims.pad, borderRadius: dims.radius, textAlign: 'left', overflow: 'hidden',
      display: 'flex', flexDirection: 'column', justifyContent: 'space-between', gap: 2,
    }}>
      <span style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 4, width: '100%' }}>
        <span className="num" style={{ fontSize: dims.num, fontWeight: isToday ? 700 : 500, letterSpacing: '-.01em', color: 'inherit', whiteSpace: 'nowrap', lineHeight: 1.1 }}>
          {dayNum}{dim && dims.wide ? ' ' + FR_MONTHS_SHORT[Number(iso.slice(5, 7)) - 1] : ''}
        </span>
        {dot}
      </span>
      {showMark
        ? <HabitDayMark status={st} size={dims.mark}/>
        : frac && <span className="num" data-habit-frac style={{
            fontSize: longFrac ? (cellW < 40 ? 9.5 : 10.5) : dims.frac, fontWeight: 700, letterSpacing: longFrac ? '-.03em' : '-.015em',
            lineHeight: 1.1, color: fracColor, whiteSpace: 'nowrap',
            ...(longFrac ? { margin: '0 -4px', textAlign: 'center' } : null),
          }}>{frac}</span>}
    </button>
  );
}

// ─── Détail du jour (§8.5.3) ─────────────────────────────────────────
// side : panneau à droite de la grille. Sinon le bloc passe sous la grille et
// reste borné en largeur (le statut ne s'éloigne jamais du nom) ; `wide` : assez
// de place pour ranger les moments sur deux colonnes.
function StatsHabitDayDetail({ iso, state, rows, ctx, theme, openJournal, side, wide = false }) {
  const [editing, setEditing] = React.useState(false);
  const [error, setError] = React.useState(null);
  React.useEffect(() => { setEditing(false); setError(null); }, [iso]);
  // Panneau encore à l'écran ? Une coche lente peut échouer après le départ
  // de Statistiques : l'échec part alors vers l'avis commun de l'application.
  const alive = React.useRef(true);
  React.useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);

  const today = ctx.today;
  const isFuture = iso > today;
  const t = habitDayTally(rows, iso, ctx);
  // Habitudes à montrer : celles suivies ce jour-là (archivées comprises) ;
  // pour un jour à venir, celles qui seront prévues.
  const list = rows
    .map((h) => ({ h, st: habitDayStatus(h, iso, ctx) }))
    .filter((x) => (isFuture ? !x.h.archived && habitIsDue(x.h, iso, ctx) : x.st !== 'none'));
  const groups = SECTION_ORDER
    .map((sec) => ({ sec, items: list.filter((x) => x.h.section === sec) }))
    .filter((g) => g.items.length > 0);
  const editable = !isFuture && list.some((x) => !x.h.archived) && typeof state.toggleHabit === 'function';
  const dayLong = fmtDayLong(iso);

  const toggle = async (h) => {
    setError(null);
    let res = null;
    try { res = await state.toggleHabit(h.id, iso); } catch (e) { res = { error: e }; }
    if (!(res && res.error)) return;
    if (alive.current) { setError(sbErrorMessage(res.error)); return; }
    const text = 'La coche de «\u00A0' + (h.name || 'cette habitude') + '\u00A0» du ' + fmtDayShort(iso) + ' n’a pas été enregistrée. ' + sbErrorMessage(res.error);
    if (typeof state.notifyWriteError === 'function') state.notifyWriteError(text);
    else console.warn('[Tempo] ' + text);
  };

  let pill;
  if (t.state === 'future') pill = <span className="pill pill-gray">À venir</span>;
  else if (t.state === 'today') pill = <span className="pill pill-blue">{t.planned ? 'En cours · ' + statsOutOf(t.done, t.planned) : 'En cours · rien de prévu'}</span>;
  else if (t.state === 'none') pill = <span className="pill pill-gray">Rien de prévu</span>;
  else {
    const ls = habitLevelStyle(t.level, theme);
    pill = <span className="pill num" style={{ background: ls.background, color: ls.color, border: ls.border }}>{statsOutOf(t.done, t.planned)} · {fmtPct(statsTallyPct(t), 0)}</span>;
  }

  const dayTrades = state.tradesByDay && state.tradesByDay[iso];
  const nTrades = dayTrades && Array.isArray(dayTrades.trades) ? dayTrades.trades.length : 0;
  const statusColor = (st) => (st === 'missed' ? 'var(--red-text)' : st === 'done' || st === 'today-done' ? 'var(--green-text)' : 'var(--fg-2)');

  return (
    <div data-habit-day-detail={iso} style={side
      ? { borderLeft: '1px solid var(--line)', paddingLeft: 20, minWidth: 0 }
      : { borderTop: '1px solid var(--line)', paddingTop: 16, marginTop: 16, minWidth: 0 }}>
      <div style={side ? undefined : { maxWidth: wide ? 980 : 560 }}>
      <div style={{ fontSize: 11.5, fontWeight: 600, color: 'var(--fg-3)', textTransform: 'uppercase', letterSpacing: '.04em', marginBottom: 6 }}>Détail du jour</div>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: side ? 'space-between' : 'flex-start', flexWrap: 'wrap', gap: side ? 8 : '8px 14px', marginBottom: 12 }}>
        <span style={{ fontSize: 14.5, fontWeight: 600, letterSpacing: '-.01em' }}>{statsCap(dayLong)}</span>
        {pill}
      </div>

      {groups.length === 0 ? (
        <div style={{ fontSize: 12.5, color: 'var(--fg-3)', lineHeight: 1.5 }}>
          {isFuture ? 'Aucune habitude prévue ce jour-là.' : 'Aucune habitude n’était suivie ce jour-là.'}
        </div>
      ) : (
        <div data-habit-day-groups style={wide && !side && list.length > 3 ? { columnCount: 2, columnGap: 44 } : undefined}>
          {groups.map((g, gi) => (
            <div key={g.sec} style={{ breakInside: 'avoid', paddingBottom: gi === groups.length - 1 ? 0 : 12 }}>
              <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--fg-3)', textTransform: 'uppercase', letterSpacing: '.04em', marginBottom: 4 }}>{SECTION_LABELS[g.sec]}</div>
              {g.items.map(({ h, st }) => {
                const done = habitIsDone(h, iso, ctx);
                const canEdit = editing && !h.archived;
                return (
                  <div key={h.id} data-habit-day={h.id + '|' + iso} style={{ display: 'flex', alignItems: 'center', gap: 10, minHeight: canEdit ? 44 : 32 }}>
                    {canEdit
                      ? <HabitCheck checked={done} onToggle={() => toggle(h)}
                          label={h.name + ' — ' + dayLong + ' : ' + (done ? 'fait, décocher' : 'à cocher')}/>
                      : <span style={{ width: 32, display: 'inline-flex', justifyContent: 'center', flex: 'none' }}><HabitDayMark status={st} size={20}/></span>}
                    <span style={{ flex: 1, minWidth: 0, fontSize: 13, lineHeight: 1.35, overflowWrap: 'anywhere' }}>
                      {h.name}
                      {h.archived && <span style={{ fontSize: 11, color: 'var(--fg-3)' }}>{' · archivée'}</span>}
                    </span>
                    <span style={{ fontSize: 11.5, fontWeight: 500, color: statusColor(st), flexShrink: 0, textAlign: 'right' }}>{statsHabitStatusText(st, h, ctx)}</span>
                  </div>
                );
              })}
            </div>
          ))}
        </div>
      )}

      {error && <div role="status" style={{ fontSize: 12, color: 'var(--red-text)', marginTop: 10 }}>{error}</div>}

      {nTrades > 0 && (
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '4px 12px', marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--line)' }}>
          <span className="num" style={{ fontSize: 12.5, whiteSpace: 'nowrap' }}>
            {statsTradesText(nTrades)} · <span style={{ fontWeight: 600, color: statsToneColor(statsMoneyTone(dayTrades.pnl)) }}>{fmtMoney(dayTrades.pnl, true)}</span>
          </span>
          {typeof openJournal === 'function' && (
            <button type="button" onClick={() => openJournal(iso)} className="tap"
              style={{ background: 'transparent', border: 'none', padding: '4px 0', color: 'var(--blue-600)', fontSize: 12.5, fontWeight: 500, cursor: 'pointer' }}>
              Ouvrir le journal du jour ›
            </button>
          )}
        </div>
      )}

      {editable && (
        <div style={{ marginTop: 14 }}>
          <button type="button" onClick={() => setEditing((v) => !v)} aria-pressed={editing ? 'true' : 'false'}
            className={'btn tap' + (editing ? ' btn-blue' : '')} style={{ color: editing ? '#fff' : 'var(--fg)' }}>
            {editing ? 'Terminé' : 'Corriger ce jour'}
          </button>
          {editing && <div style={{ fontSize: 11.5, color: 'var(--fg-3)', marginTop: 8, lineHeight: 1.45 }}>Coche ou décoche : tes chiffres se recalculent aussitôt.</div>}
        </div>
      )}
      </div>
    </div>
  );
}

// ─── Calendrier de régularité (§8.5.2) ───────────────────────────────
// La grille suit la période : 7 grandes cases (semaine), mois entier à
// l'anatomie du Calendrier P&L, ou un mini-calendrier par mois (année, tout).
// Année ou tout avec un seul mois à montrer (compte récent, mois de janvier) :
// la grille du mois entier, avec ses fractions, plutôt qu'un mini-mois isolé.
// Elle occupe toute la largeur de sa zone ; les tailles viennent de la largeur
// MESURÉE de la carte, pas du seul mode « mobile ».
function StatsHabitCalendar({ state, rows, ctx, period, range, compact, theme, filterId, setFilterId, selDay, setSelDay, openJournal, anchorRef }) {
  const [measureRef, cardW] = useStatsBodyWidth();
  const setCardRef = React.useCallback((el) => { measureRef(el); if (anchorRef) anchorRef.current = el; }, [measureRef, anchorRef]);
  const [allMonths, setAllMonths] = React.useState(false);

  const today = ctx.today;
  const curYm = today.slice(0, 7);
  const first = habitFirstDay(rows);
  // Mois à montrer en année / tout : du premier jour de suivi (ou de janvier) au mois en cours.
  let spanMonths = null;
  if (period !== 'week' && period !== 'month') {
    const firstYm = first ? first.slice(0, 7) : curYm;
    const startYm = period === 'year' && firstYm < curYm.slice(0, 4) + '-01' ? curYm.slice(0, 4) + '-01' : firstYm;
    spanMonths = statsMonthSpan(startYm > curYm ? curYm : startYm, curYm);
  }
  const mode = period === 'week' ? 'week' : (period === 'month' || spanMonths.length === 1) ? 'month' : 'months';
  const single = filterId ? rows.find((h) => h.id === filterId) || null : null;
  const calRows = React.useMemo(() => (single ? [single] : rows), [single, rows]);
  const tallies = React.useMemo(() => new Map(), [calRows, ctx]);
  const tallyOf = (iso) => {
    let t = tallies.get(iso);
    if (!t) { t = habitDayTally(calRows, iso, ctx); tallies.set(iso, t); }
    return t;
  };

  const padX = compact ? 14 : 22;

  // Jours de la période déjà terminés : pour la légende (en nombre si le compte
  // prévu est le même tous les jours comptés, en pourcentage sinon).
  const legendPlanned = React.useMemo(() => {
    if (single || !first) return null;
    const from = range.fromISO && range.fromISO > first ? range.fromISO : first;
    const to = habitShiftDay(today, -1);
    let n = null;
    for (let d = from, guard = 0; d <= to && guard < 4000; d = habitShiftDay(d, 1), guard++) {
      const t = tallyOf(d);
      if (t.state !== 'counted') continue;
      if (n === null) n = t.planned;
      else if (n !== t.planned) return null;
    }
    return n;
  }, [single, first, range.fromISO, today, tallies]);

  // ── Données de la grille ──
  const monday = habitWeekStart(today);
  let rangeLabel = '';
  let visFrom = today, visTo = today;
  let weekDays = null, monthWeeks = null, months = null, hiddenMonths = 0;
  if (mode === 'week') {
    weekDays = [0, 1, 2, 3, 4, 5, 6].map((k) => habitShiftDay(monday, k));
    visFrom = weekDays[0]; visTo = weekDays[6];
    rangeLabel = fmtWeekLabel(monday) + ' · ' + fmtWeekRange(monday);
  } else if (mode === 'month') {
    monthWeeks = statsMonthWeeks(curYm);
    visFrom = monthWeeks[0].days[0]; visTo = monthWeeks[monthWeeks.length - 1].days[6];
    rangeLabel = statsCap(fmtMonthYear(curYm));
  } else {
    const all = spanMonths;
    hiddenMonths = !allMonths && all.length > 12 ? all.length - 12 : 0;
    months = hiddenMonths ? all.slice(-12) : all;
    visFrom = months[0] + '-01'; visTo = statsMonthLast(curYm);
    const mLabel = (ym) => FR_MONTHS_SHORT[Number(ym.slice(5, 7)) - 1] + STATS_NB + ym.slice(0, 4);
    rangeLabel = period === 'year' ? curYm.slice(0, 4)
      : all[0] === curYm ? statsCap(fmtMonthYear(curYm))
      : mLabel(all[0]) + STATS_NB + '–' + STATS_NB + mLabel(curYm);
  }
  // Détail du jour à droite : seulement quand la grille a une hauteur comparable.
  //  · jamais en semaine (une rangée de 7 cases à côté d'un panneau cinq fois
  //    plus haut laissait la carte à moitié vide) : les 7 cases prennent toute
  //    la largeur, le détail passe dessous ;
  //  · en mini-calendriers, tant qu'ils tiennent sur une rangée (3 mois au plus) ;
  //  · jamais au-delà de STATS_HABIT_SIDE_MAX habitudes (le panneau, une ligne
  //    par habitude, dépasserait la grille et étirerait la carte) ;
  //  · et seulement si la zone restante garde ses grandes cases.
  // cardW est mesuré bord compris (2 px).
  const innerW = Math.max(0, (cardW || (compact ? 340 : 900)) - 2 - padX * 2);
  const detailCount = rows.reduce((n, h) => n + (!h.archived || h.archivedDay ? 1 : 0), 0);
  const sideDetail = innerW - 320 >= STATS_HABIT_LARGE_ZONE && detailCount <= STATS_HABIT_SIDE_MAX
    && (mode === 'month' || (mode === 'months' && months.length <= 3));
  const zoneW = innerW - (sideDetail ? 320 : 0);

  // Jour sélectionné : aujourd'hui par défaut, et dès que la sélection sort de la grille.
  const sel = selDay && selDay >= visFrom && selDay <= visTo ? selDay : today;
  const cellProps = (iso) => ({ iso, t: tallyOf(iso), theme, selected: iso === sel, onSelect: setSelDay, single, ctx });

  // ── Grilles ──
  let grid;
  if (mode === 'week') {
    const large = zoneW >= STATS_HABIT_LARGE_ZONE;
    const gap = large ? 8 : 4;
    const dims = { cellW: (zoneW - gap * 6) / 7 };
    grid = (
      <div data-habit-grid role="group" aria-label={'Semaine du ' + fmtWeekRange(monday)}
        style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', gap }}>
        {weekDays.map((iso) => <StatsHabitCell key={iso} kind={large ? 'week' : 'week-sm'} dims={dims} {...cellProps(iso)}/>)}
      </div>
    );
  } else if (mode === 'month') {
    const tiny = zoneW < 420;
    const large = zoneW >= STATS_HABIT_LARGE_ZONE;
    const withWeek = zoneW >= 900;
    const gap = tiny ? 4 : large ? 8 : 6;
    const baseDims = large
      ? { h: 68, pad: '8px 10px', radius: 10, num: 13, frac: 13, mark: 20, wide: zoneW >= 760 }
      : tiny ? { h: 46, pad: '5px 5px', radius: 7, num: 11, frac: 11.5, mark: 14, wide: false }
      : { h: 56, pad: '6px 7px', radius: 8, num: 12, frac: 13, mark: 18, wide: false };
    const dims = { ...baseDims, cellW: (zoneW - (withWeek ? 116 + gap : 0) - gap * 6) / 7 };
    const cols = 'repeat(7, minmax(0, 1fr))' + (withWeek ? ' 116px' : '');
    const headStyle = { fontSize: tiny ? 10.5 : 12, fontWeight: 600, color: 'var(--fg-3)', textTransform: tiny ? 'none' : 'uppercase', letterSpacing: '.04em', padding: '0 4px', textAlign: large ? 'left' : 'center' };
    grid = (
      <div data-habit-grid role="group" aria-label={statsCap(fmtMonthYear(curYm))}>
        <div style={{ display: 'grid', gridTemplateColumns: cols, gap, marginBottom: gap }}>
          {(tiny ? FR_WEEKDAYS_2 : FR_WEEKDAYS_SHORT).map((d, i) => <span key={i} data-habit-weekday style={headStyle}>{d}</span>)}
          {withWeek && <span style={headStyle}>Semaine</span>}
        </div>
        {monthWeeks.map((wk, wi) => {
          // Part de la semaine située dans le mois (les cases estompées n'y entrent pas),
          // comme la barre de la même semaine dans « Évolution de la régularité ».
          const ws = withWeek ? habitRangeStats(calRows, wk.a, wk.b, ctx) : null;
          return (
            <div key={wk.monday} style={{ display: 'grid', gridTemplateColumns: cols, gap, marginBottom: wi === monthWeeks.length - 1 ? 0 : gap }}>
              {wk.days.map((iso) => <StatsHabitCell key={iso} kind="month" dims={dims} dim={iso.slice(0, 7) !== curYm} {...cellProps(iso)}/>)}
              {withWeek && (
                <div data-habit-weekcol={wk.monday} title={fmtWeekLabel(wk.monday) + ' · ' + fmtDayRange(wk.a, wk.b) + ' · ' + (ws.rate === null ? 'rien de compté' : fmtPct(ws.rate, 0) + ', ' + statsDonePlanned(ws.done, ws.planned))}
                  style={{ border: '1px solid var(--line)', borderRadius: 10, background: 'var(--bg-soft)', padding: '8px 10px', minHeight: dims.h, minWidth: 0, boxSizing: 'border-box', display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
                  <span style={{ display: 'block' }}>
                    <span style={{ display: 'block', fontSize: 11.5, fontWeight: 600, color: 'var(--fg-2)', whiteSpace: 'nowrap' }}>{fmtWeekLabel(wk.monday, true)}</span>
                    <span style={{ display: 'block', fontSize: 10.5, color: 'var(--fg-3)', marginTop: 1, whiteSpace: 'nowrap' }}>{fmtDayRange(wk.a, wk.b)}</span>
                  </span>
                  <span className="num" style={{ fontSize: 13, fontWeight: 700, color: ws.rate === null ? 'var(--fg-3)' : 'var(--fg)' }}>{ws.rate === null ? '—' : fmtPct(ws.rate, 0)}</span>
                </div>
              )}
            </div>
          );
        })}
      </div>
    );
  } else {
    const gap = compact ? 10 : 14;
    // Peu de mois : ils se partagent la largeur (2 colonnes au moins sur
    // ordinateur, pour qu'un mois seul ne devienne pas démesuré).
    const fitCols = compact && zoneW < 560 ? 2 : Math.max(1, Math.floor((zoneW + gap) / (196 + gap)));
    const nCols = Math.min(fitCols, Math.max(compact ? 1 : 2, months.length));
    const monthW = (zoneW - gap * (nCols - 1)) / nCols;
    const framed = monthW >= 180;
    const inner = monthW - (framed ? 22 : 0);
    const dims = { cell: (inner - 18) / 7 };
    const multiYear = months[0].slice(0, 4) !== curYm.slice(0, 4);
    grid = (
      <div data-habit-grid>
        {hiddenMonths > 0 && (
          <div style={{ marginBottom: 12 }}>
            <button type="button" className="btn tap" onClick={() => setAllMonths(true)} style={{ color: 'var(--fg)' }}>
              {hiddenMonths > 1 ? 'Afficher les ' + fmtNum(hiddenMonths, 0) + ' mois précédents' : 'Afficher le mois précédent'}
            </button>
          </div>
        )}
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(' + nCols + ', minmax(0, 1fr))', gap }}>
          {months.map((ym) => {
            const firstDay = ym + '-01', lastDay = statsMonthLast(ym);
            const offset = habitWeekdayNum(firstDay) - 1;
            const days = [];
            for (let d = firstDay; d <= lastDay; d = habitShiftDay(d, 1)) days.push(d);
            const ms = habitRangeStats(calRows, firstDay, lastDay, ctx);
            const name = FR_MONTHS_LONG[Number(ym.slice(5, 7)) - 1] + (multiYear ? ' ' + ym.slice(0, 4) : '');
            return (
              <div key={ym} data-habit-month={ym} role="group" aria-label={statsCap(fmtMonthYear(ym))}
                style={{ minWidth: 0, boxSizing: 'border-box', ...(framed ? { border: '1px solid var(--line)', borderRadius: 10, padding: 10 } : null) }}>
                <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 6, marginBottom: 7 }}>
                  <span style={{ fontSize: 12.5, fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{name}</span>
                  <span className="num" title={ms.rate === null ? 'Rien de compté ce mois-là' : 'Régularité du mois : ' + statsDonePlanned(ms.done, ms.planned)}
                    style={{ fontSize: 12, fontWeight: 600, color: ms.rate === null ? 'var(--fg-3)' : 'var(--fg-2)', whiteSpace: 'nowrap' }}>
                    {ms.rate === null ? '—' : fmtPct(ms.rate, 0)}
                  </span>
                </div>
                <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', gap: 3 }}>
                  {FR_WEEKDAYS_2.map((d, i) => <span key={'h' + i} style={{ fontSize: 9.5, fontWeight: 600, color: 'var(--fg-3)', textAlign: 'center', lineHeight: 1.5 }}>{d}</span>)}
                  {Array.from({ length: offset }, (_, i) => <span key={'o' + i} aria-hidden="true"/>)}
                  {days.map((iso) => <StatsHabitCell key={iso} kind="mini" dims={dims} {...cellProps(iso)}/>)}
                </div>
              </div>
            );
          })}
        </div>
      </div>
    );
  }

  // ── Filtre par habitude ──
  const active = rows.filter((h) => !h.archived);
  const archivedDated = rows.filter((h) => h.archived && h.archivedDay);
  const select = (
    <select className="input" value={single ? single.id : ''} onChange={(e) => setFilterId(e.target.value)}
      aria-label="Habitude affichée dans le calendrier" data-habit-filter
      style={{ width: compact ? '100%' : 260, maxWidth: '100%', padding: '7px 10px', fontSize: 12.5, flexShrink: 0 }}>
      <option value="">Toutes les habitudes</option>
      {SECTION_ORDER.map((sec) => {
        const items = active.filter((h) => h.section === sec);
        return items.length ? (
          <optgroup key={sec} label={SECTION_LABELS[sec]}>
            {items.map((h) => <option key={h.id} value={h.id}>{h.name}</option>)}
          </optgroup>
        ) : null;
      })}
      {archivedDated.length > 0 && (
        <optgroup label="Archivées">
          {archivedDated.map((h) => <option key={h.id} value={h.id}>{h.name}</option>)}
        </optgroup>
      )}
    </select>
  );

  const detail = <StatsHabitDayDetail iso={sel} state={state} rows={rows} ctx={ctx} theme={theme} openJournal={openJournal} side={sideDetail} wide={innerW >= 760}/>;

  return (
    <div ref={setCardRef} className="card" data-habit-cal data-mode={mode} style={{ padding: compact ? '16px ' + padX + 'px' : '20px ' + padX + 'px', minWidth: 0, scrollMarginTop: 76 }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', flexWrap: 'wrap', gap: '4px 12px' }}>
        <CardTitle>Calendrier de régularité</CardTitle>
        <span className="num" style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--fg-2)' }}>{rangeLabel}</span>
      </div>
      <div style={{ display: 'flex', flexDirection: compact ? 'column' : 'row', alignItems: compact ? 'stretch' : 'center', justifyContent: 'space-between', gap: compact ? 10 : 16, marginTop: 6 }}>
        <span style={{ fontSize: 12, color: 'var(--fg-2)', lineHeight: 1.5, minWidth: 0 }}>
          {single
            ? <>Chaque case est un jour : «{STATS_NB}{single.name}{STATS_NB}» faite, manquée ou {single.section === 'pretrade' ? 'sans trade ce jour' : 'pas prévue'}.</>
            : mode === 'months'
              ? 'Chaque case est un jour, colorée selon la part d’habitudes faites. Clique sur un jour pour voir le détail.'
              : 'Chaque case est un jour : le nombre d’habitudes faites sur le nombre prévu.'}
        </span>
        {select}
      </div>
      <div style={{ margin: '14px 0 16px' }}>
        <HabitLegend mode={single ? 'marks' : 'levels'} planned={legendPlanned || undefined}
          pretrade={!!single && single.section === 'pretrade'}
          extra={!!single && (single.section === 'pretrade' || (Array.isArray(single.days) && single.days.length < 7))}/>
      </div>
      {sideDetail ? (
        <div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 300px', gap: 20, alignItems: 'start' }}>
          <div data-habit-zone style={{ minWidth: 0 }}>{grid}</div>
          {detail}
        </div>
      ) : (
        <>
          <div data-habit-zone style={{ minWidth: 0 }}>{grid}</div>
          {detail}
        </>
      )}
    </div>
  );
}

// ─── Histogramme de régularité (SVG fait main, échelle absolue 0–100 %) ──
// bars : [{ key, label, sub, subShort, rate (0–100 | null), title, current }]. Barres
// vertes, jamais de rouge ; rien de compté → pas de barre, « — ». `goal` :
// ligne pointillée, nommée par la carte hors du graphique. Une valeur qui
// tomberait sur cette ligne est écrite juste au-dessus : la ligne n'est jamais
// interrompue et ne se lit jamais comme « 75 % ». Barre `current` : hachurée,
// « en cours ». `tag` (facultatif, ex. l'année) : écrit sous la première barre
// LIBELLÉE de chaque groupe, pour qu'aucune année ne manque quand un libellé
// sur deux est sauté.
// Rien n'est décidé sur un seuil fixe : la largeur des textes est MESURÉE
// (statsTextWidth) et comparée à la place d'une barre.
//  · Libellés : toutes les barres sont nommées dès que le libellé le plus large
//    tient (avec `sub`, sinon avec sa forme courte `subShort`) ; à défaut un
//    libellé sur deux, sur trois… — compté depuis la barre en cours (à défaut la
//    dernière), qui reste donc toujours nommée.
//  · Valeurs : au-dessus de chaque barre jusqu'à 12 barres, en plus petit si la
//    place manque (9 px au moins) ; au-delà, ou si même 9 px ne tiennent pas,
//    seules la plus haute, la plus basse et la barre en cours portent leur
//    valeur (lisibles sans survol ; chaque barre garde son info-bulle).
const STATS_BAR_LABEL_GAP = 4;   // blanc minimal entre deux libellés voisins
const STATS_BAR_VALUE_SIZE = 11; // taille normale d'une valeur
const STATS_BAR_VALUE_MIN = 9;
function StatsHabitBars({ bars, goal = null, height = 220, ariaLabel }) {
  const [ref, w] = useStatsWidth(520);
  useStatsFontEpoch(); // largeurs mesurées : à refaire quand la police arrive
  const uid = typeof React.useId === 'function' ? React.useId() : 'hb';
  const pid = 'hab-hatch-' + String(uid).replace(/[^a-zA-Z0-9_-]/g, '');
  const n = bars.length;
  const hasSub = bars.some((b) => b.sub || b.tag);
  const hasCurrent = bars.some((b) => b.current);
  const padL = 42, padR = 6, padT = 24;
  const padB = 20 + (hasSub ? 13 : 0) + (hasCurrent ? 13 : 0);
  const H = height;
  const innerW = Math.max(40, w - padL - padR), innerH = H - padT - padB;
  const slot = innerW / Math.max(1, n);
  const bw = Math.max(4, Math.min(52, slot * 0.62));
  const Y = (v) => padT + innerH - (Math.max(0, Math.min(100, v)) / 100) * innerH;
  const rated = (b) => b.rate !== null && b.rate !== undefined;
  // Valeurs : taille qui fait tenir la plus large dans la place d'une barre.
  const valueW = statsMaxTextWidth(bars.filter(rated).map((b) => fmtPct(b.rate, 0)), STATS_BAR_VALUE_SIZE, 700, { num: true });
  const valueFit = valueW > 0 ? Math.floor((STATS_BAR_VALUE_SIZE * (slot - 2) / valueW) * 2) / 2 : STATS_BAR_VALUE_SIZE;
  const showValues = n <= 12 && valueFit >= STATS_BAR_VALUE_MIN;
  const valueSize = showValues ? Math.min(STATS_BAR_VALUE_SIZE, valueFit) : STATS_BAR_VALUE_SIZE;
  // Libellés (11 px semi-gras) et sous-libellés (10 px, chiffres tabulaires) :
  // le plus petit pas qui laisse STATS_BAR_LABEL_GAP entre deux voisins, d'abord
  // avec les sous-libellés complets, sinon avec leur forme courte.
  const labelW = statsMaxTextWidth(bars.map((b) => b.label), 11, 600);
  const tagW = statsMaxTextWidth(bars.map((b) => b.tag || ''), 10, 400, { num: true });
  const subLongW = Math.max(tagW, statsMaxTextWidth(bars.map((b) => b.sub || ''), 10, 400, { num: true }));
  const hasShort = bars.some((b) => b.subShort);
  const subShortW = hasShort ? Math.max(tagW, statsMaxTextWidth(bars.map((b) => b.subShort || b.sub || ''), 10, 400, { num: true })) : subLongW;
  let step = Math.max(1, n), shortSubs = hasShort;
  for (let k = 1; k <= n; k++) {
    const room = slot * k - STATS_BAR_LABEL_GAP;
    if (Math.max(labelW, subLongW) <= room) { step = k; shortSubs = false; break; }
    if (hasShort && Math.max(labelW, subShortW) <= room) { step = k; shortSubs = true; break; }
  }
  const halo = { paintOrder: 'stroke', stroke: 'var(--bg-card)', strokeWidth: 3, strokeLinejoin: 'round' };
  const g = goal === null || goal === undefined ? null : Math.max(0, Math.min(100, Number(goal)));
  const geom = (b, i) => {
    const has = rated(b);
    return { cx: padL + slot * i + slot / 2, has, bh: has ? Math.max(2, Y(0) - Y(b.rate)) : 0 };
  };
  // Ligne de base d'une valeur : 6 px au-dessus de la barre, ou 6 px au-dessus
  // de la ligne d'objectif si le texte la chevaucherait.
  const valueY = (bh) => {
    const y = Y(0) - bh - 6;
    return g !== null && Y(g) > y - 11 && Y(g) < y + 4 ? Y(g) - 6 : y;
  };
  // Le pas des libellés est ancré sur la barre en cours (« Ce mois » : elle n'est
  // pas forcément la dernière), à défaut sur la dernière : la période en cours
  // est donc toujours nommée, même quand un libellé sur deux est sauté.
  const curIdx = bars.findIndex((b) => b.current);
  const anchor = curIdx >= 0 ? curIdx : n - 1;
  const labelled = bars.map((b, i) => Math.abs(anchor - i) % step === 0);
  // Première barre : si son libellé, plus large qu'elle, déborderait à gauche sur
  // l'échelle (« 0 % »), elle n'est pas nommée — l'année éventuelle passe alors
  // sous la barre nommée suivante. La barre en cours, elle, garde toujours son nom.
  if (n > 1 && labelled[0] && !bars[0].current) {
    const b0 = bars[0];
    const firstW = Math.max(statsTextWidth(b0.label, 11, 600),
      statsTextWidth((shortSubs && b0.subShort) || b0.sub || b0.tag || '', 10, 400, { num: true }));
    if (firstW > slot + 8) labelled[0] = false;
  }
  let lastTag = null;
  const subs = bars.map((b, i) => {
    if (!labelled[i]) return '';
    if (b.sub) return (shortSubs && b.subShort) || b.sub;
    if (b.tag && b.tag !== lastTag) { lastTag = b.tag; return b.tag; }
    return '';
  });
  const valued = new Set();
  if (!showValues) {
    let hi = -1, lo = -1;
    bars.forEach((b, i) => {
      if (!rated(b)) return;
      if (hi < 0 || b.rate > bars[hi].rate) hi = i;
      if (lo < 0 || b.rate < bars[lo].rate) lo = i;
      if (b.current) valued.add(i);
    });
    if (hi >= 0) valued.add(hi);
    if (lo >= 0) valued.add(lo);
  }

  return (
    <div ref={ref} style={{ width: '100%' }}>
      <svg width="100%" viewBox={`0 0 ${w} ${H}`} role="img" aria-label={ariaLabel} style={{ display: 'block', overflow: 'visible' }}>
        <defs>
          <pattern id={pid} width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
            <rect width="6" height="6" style={{ fill: 'color-mix(in srgb, var(--green) 30%, var(--bg-card))' }}/>
            <rect width="3" height="6" style={{ fill: 'var(--green)' }}/>
          </pattern>
        </defs>
        {[0, 50, 100].map((v) => (
          <g key={v}>
            <line x1={padL} y1={Y(v)} x2={padL + innerW} y2={Y(v)} stroke={v === 0 ? 'var(--line-2)' : 'var(--line)'} strokeWidth="1"/>
            <text x={padL - 7} y={Y(v) + 3.5} textAnchor="end" className="num" style={{ fontSize: 10.5, fill: 'var(--fg-3)' }}>{fmtPct(v, 0)}</text>
          </g>
        ))}
        {/* Ordre de dessin : barres, ligne d'objectif, puis les textes (voir valueY : jamais sur la ligne). */}
        {bars.map((b, i) => {
          const g0 = geom(b, i);
          return g0.has ? (
            <g key={b.key} aria-hidden="true">
              <rect x={g0.cx - bw / 2} y={Y(0) - g0.bh} width={bw} height={g0.bh} rx={Math.min(4, g0.bh / 2)}
                style={{ fill: b.current ? `url(#${pid})` : 'var(--green)' }}/>
              {b.current && <rect x={g0.cx - bw / 2 + 0.5} y={Y(0) - g0.bh + 0.5} width={Math.max(0, bw - 1)} height={Math.max(0, g0.bh - 1)} rx={Math.min(4, g0.bh / 2)}
                fill="none" style={{ stroke: 'var(--green)', strokeWidth: 1 }}/>}
            </g>
          ) : null;
        })}
        {g !== null && (
          <g data-habit-goal-line>
            <title>Objectif {fmtPct(g, 0)}</title>
            <line x1={padL} y1={Y(g)} x2={padL + innerW} y2={Y(g)} strokeDasharray="5 4" style={{ stroke: 'var(--amber-text)', strokeWidth: 1.25 }}/>
          </g>
        )}
        {bars.map((b, i) => {
          const { cx, has, bh } = geom(b, i);
          const showLabel = labelled[i];
          const showValue = showValues || valued.has(i);
          return (
            <g key={b.key} role="img" aria-label={b.title} data-habit-bar={b.key}>
              <title>{b.title}</title>
              <rect x={cx - slot / 2} y={padT} width={slot} height={innerH + padB} fill="transparent"/>
              {has
                ? showValue && <text x={cx} y={valueY(bh)} textAnchor="middle" className="num" data-habit-bar-value style={{ fontSize: showValues ? valueSize : STATS_BAR_VALUE_SIZE, fontWeight: 700, fill: 'var(--fg)', ...halo }}>{fmtPct(b.rate, 0)}</text>
                : (showValues || showLabel) && <text x={cx} y={Y(0) - 7} textAnchor="middle" style={{ fontSize: 11, fill: 'var(--fg-3)' }}>—</text>}
              {showLabel && (
                <>
                  <text x={cx} y={Y(0) + 15} textAnchor="middle" style={{ fontSize: 11, fontWeight: 600, fill: 'var(--fg-2)' }}>{b.label}</text>
                  {subs[i] && <text x={cx} y={Y(0) + 28} textAnchor="middle" className="num" style={{ fontSize: 10, fill: 'var(--fg-3)' }}>{subs[i]}</text>}
                </>
              )}
              {/* « en cours » a sa propre ligne (réservée par padB) : sous celle des
                  sous-libellés dès que le graphique en porte, jamais sur la plage ou
                  l'année d'une barre voisine. */}
              {b.current && <text x={cx} y={Y(0) + 15 + (hasSub ? 26 : 13)} textAnchor="middle" style={{ fontSize: 10, fontWeight: 600, fill: 'var(--blue-600)' }}>en cours</text>}
            </g>
          );
        })}
      </svg>
    </div>
  );
}

// Carte titrée des blocs Habitudes (même gabarit que StatsPanel, avec l'attribut de test).
function StatsHabitCard({ card, title, sub, right, children, compact = false }) {
  return (
    <div className="card" data-habit-card={card} style={{ padding: compact ? '16px 14px' : '18px 20px', display: 'flex', flexDirection: 'column', minWidth: 0 }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '6px 12px' }}>
        <CardTitle style={{ fontSize: 13.5, letterSpacing: '-.012em' }}>{title}</CardTitle>
        {right}
      </div>
      {sub && <div style={{ fontSize: 12, color: 'var(--fg-2)', lineHeight: 1.5, marginTop: 5 }}>{sub}</div>}
      <div style={{ marginTop: 14, flex: 1, minWidth: 0 }}>{children}</div>
    </div>
  );
}

// ─── Évolution de la régularité (§8.5.4) ─────────────────────────────
function StatsHabitEvolution({ rows, ctx, period, goal, average, compact }) {
  const today = ctx.today;
  const curYm = today.slice(0, 7);
  const bars = React.useMemo(() => {
    const out = [];
    const rateTitle = (s) => (s.rate === null ? 'rien de compté' : fmtPct(s.rate, 0) + ', ' + statsDonePlanned(s.done, s.planned));
    if (period === 'month') {
      for (const wk of statsMonthWeeks(curYm)) {
        const m = wk.monday, a = wk.a, b = wk.b;
        const s = habitRangeStats(rows, a, b, ctx);
        const da = Number(a.slice(8, 10)), db = Number(b.slice(8, 10));
        out.push({
          key: m, label: fmtWeekLabel(m, true), rate: s.rate, current: today >= a && today <= b,
          // Plage de la semaine ; forme courte (« 3–9 ») quand la plage complète ne tient pas sous la barre.
          sub: fmtDayRange(a, b), subShort: da === db ? String(da) : da + '–' + db,
          title: fmtWeekLabel(m) + ' (' + fmtDayRange(a, b) + ') : ' + rateTitle(s),
        });
      }
      return out;
    }
    const first = habitFirstDay(rows);
    const firstYm = first ? first.slice(0, 7) : curYm;
    const yearStart = curYm.slice(0, 4) + '-01';
    let list = statsMonthSpan(period === 'year' && firstYm < yearStart ? yearStart : (firstYm > curYm ? curYm : firstYm), curYm);
    if (list.length > 24) list = list.slice(-24);
    const multiYear = list[0].slice(0, 4) !== curYm.slice(0, 4);
    list.forEach((ym, i) => {
      const s = habitRangeStats(rows, ym + '-01', statsMonthLast(ym), ctx);
      out.push({
        key: ym, label: FR_MONTHS_SHORT[Number(ym.slice(5, 7)) - 1], rate: s.rate, current: ym === curYm,
        sub: '', tag: multiYear ? ym.slice(0, 4) : '',
        title: statsCap(fmtMonthYear(ym)) + ' : ' + rateTitle(s),
      });
    });
    return out;
  }, [rows, ctx, period, curYm, today]);

  return (
    <StatsHabitCard card="evolution" title="Évolution de la régularité" compact={compact}
      sub={
        <span style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '4px 14px' }}>
          <span>{period === 'month' ? 'Une barre par semaine du mois.' : 'Une barre par mois.'}</span>
          <span data-habit-goal-label className="num" style={{ display: 'inline-flex', alignItems: 'center', gap: 7, fontWeight: 600, color: 'var(--amber-text)', whiteSpace: 'nowrap' }}>
            <span aria-hidden="true" style={{ width: 22, height: 0, borderTop: '2px dashed var(--amber-text)' }}/>
            Objectif {fmtPct(goal, 0)}
          </span>
        </span>
      }
      right={<span className="num" style={{ fontSize: 12, color: 'var(--fg-2)', whiteSpace: 'nowrap' }}>Moyenne {average === null ? '—' : fmtPct(average, 1)}</span>}>
      {/* Une seule barre par mois ne montre aucune évolution : une phrase, pas un graphique vide. */}
      {period !== 'month' && bars.length < 2 ? (
        <div data-habit-evolution-wait style={{ fontSize: 12.5, color: 'var(--fg-2)', lineHeight: 1.5, padding: '6px 0' }}>
          Un seul mois à afficher pour l’instant{STATS_NB}: l’évolution apparaîtra à partir du deuxième mois. En attendant, la période «{STATS_NB}Ce mois{STATS_NB}» la montre semaine par semaine.
        </div>
      ) : (
        <StatsHabitBars bars={bars} goal={goal} height={compact ? 200 : 232}
          ariaLabel={'Évolution de la régularité, ' + (period === 'month' ? 'par semaine' : 'par mois')}/>
      )}
    </StatsHabitCard>
  );
}

// ─── Par jour de la semaine (§8.5.5) ─────────────────────────────────
function StatsHabitWeekdays({ rows, ctx, range, countedDays, compact }) {
  const stats = React.useMemo(() => habitWeekdayStats(rows, range.fromISO, range.toISO, ctx), [rows, ctx, range.fromISO, range.toISO]);
  // Sept barres, toutes nommées (§8.5.5) ; « 3 jours » sous la barre, « 3 j » si la place manque.
  const bars = stats.map((s, i) => ({
    key: 'wd' + i, label: FR_WEEKDAYS_SHORT[i], rate: s.rate, current: false,
    sub: s.days > 0 ? statsDaysText(s.days) : '', subShort: s.days > 0 ? statsDaysText(s.days, true) : '',
    title: statsCap(FR_WEEKDAYS_LONG[i]) + ' : ' + (s.rate === null ? 'rien de compté'
      : fmtPct(s.rate, 0) + ', ' + statsDonePlanned(s.done, s.planned) + ', sur ' + statsDaysText(s.days)),
  }));
  // Phrase de synthèse : seulement avec assez de recul (14 jours terminés) et un vrai écart (10 points).
  let best = -1, worst = -1;
  stats.forEach((s, i) => {
    if (s.rate === null) return;
    if (best < 0 || s.rate > stats[best].rate) best = i;
    if (worst < 0 || s.rate < stats[worst].rate) worst = i;
  });
  const summary = countedDays >= 14 && best >= 0 && worst >= 0 && stats[best].rate - stats[worst].rate >= 10;
  return (
    <StatsHabitCard card="weekdays" title="Par jour de la semaine" compact={compact}
      sub="Ta régularité selon le jour, et le nombre de jours comptés sous chaque barre.">
      <StatsHabitBars bars={bars} height={compact ? 200 : 232} ariaLabel="Régularité par jour de la semaine"/>
      {summary && (
        <div style={{ fontSize: 12.5, color: 'var(--fg-2)', lineHeight: 1.5, marginTop: 10 }}>
          Ton jour le plus régulier : <strong style={{ fontWeight: 600, color: 'var(--fg)' }}>{FR_WEEKDAYS_LONG[best]} ({fmtPct(stats[best].rate, 0)})</strong>.
          {' '}Le moins régulier : <strong style={{ fontWeight: 600, color: 'var(--fg)' }}>{FR_WEEKDAYS_LONG[worst]} ({fmtPct(stats[worst].rate, 0)})</strong>.
        </div>
      )}
    </StatsHabitCard>
  );
}

// ─── Détail par habitude (§8.5.6) ────────────────────────────────────
// tradesKnown = false (trades jamais chargés) : les vérifications avant trade
// ne sont pas comptées — « — » partout sur leurs lignes, jamais « 0 jour ».
function StatsHabitDetail({ rows, ctx, range, goal, compact, narrow, filterId, onPick, tradesKnown = true }) {
  const [sort, setSort] = React.useState('order');
  const [showArchived, setShowArchived] = React.useState(false);
  const archivedDated = rows.filter((h) => h.archived && h.archivedDay);
  const lines = React.useMemo(() => rows
    .filter((h) => !h.archived || (showArchived && h.archivedDay))
    .map((h) => ({ h, r: habitRate(h, range.fromISO, range.toISO, ctx), s: habitStreaks(h, ctx) })),
  [rows, ctx, range.fromISO, range.toISO, showArchived]);

  const unit = (h, n) => (n > 1 ? 'jours' : 'jour') + (h.section === 'pretrade' ? (n > 1 ? ' tradés' : ' tradé') : '');
  const unknown = (h) => !tradesKnown && h.section === 'pretrade';
  const countText = (x) => (unknown(x.h) ? 'trades non chargés'
    : x.r.planned > 0 ? statsOutOf(x.r.done, x.r.planned) + STATS_NB + unit(x.h, x.r.planned)
    : 'rien de compté sur la période');
  const rateText = (x) => (x.r.rate === null ? '—' : fmtPct(x.r.rate, 0));
  const daysNote = (h) => (h.section !== 'pretrade' && h.days && h.days.length < 7 ? habitDaysLabel(h.days) : '');
  const metaOf = (x, withMoment) => [
    withMoment ? SECTION_LABELS[x.h.section] : '',
    daysNote(x.h),
    x.h.archived ? 'archivée le ' + fmtDayShort(x.h.archivedDay) : '',
  ].filter(Boolean).join(' · ');

  const groups = sort === 'order'
    ? SECTION_ORDER.map((sec) => ({ sec, items: lines.filter((x) => x.h.section === sec) })).filter((g) => g.items.length > 0)
    : [{ sec: null, items: lines.slice().sort((a, b) => {
        if (a.r.rate === null && b.r.rate === null) return 0;
        if (a.r.rate === null) return 1;
        if (b.r.rate === null) return -1;
        return a.r.rate - b.r.rate;
      }) }];

  const cols = 'minmax(0, 1.5fr) minmax(200px, 1.5fr) 104px 150px';
  const headCell = { fontSize: 11.5, fontWeight: 600, color: 'var(--fg-3)' };
  const rowBtn = (x, selected) => ({
    width: '100%', textAlign: 'left', fontFamily: 'inherit', color: 'var(--fg)', cursor: 'pointer',
    border: 'none', borderTop: '1px solid var(--line)', margin: 0, borderRadius: 0,
    background: selected ? 'var(--blue-soft-2)' : 'transparent', opacity: x.h.archived ? 0.72 : 1,
  });
  const pickLabel = (x) => 'Afficher «' + STATS_NB + x.h.name + STATS_NB + '» dans le calendrier';

  const renderRow = (x, withMoment) => {
    const selected = filterId === x.h.id;
    const meta = metaOf(x, withMoment);
    const noStreak = x.s.current === null || unknown(x.h);
    const streak = noStreak ? '—' : statsDaysText(x.s.current);
    const record = unknown(x.h) ? '—' : null;
    if (narrow) {
      return (
        <button key={x.h.id} type="button" className="tap" data-habit-stat={x.h.id} onClick={() => onPick(x.h.id)}
          title={pickLabel(x)} aria-pressed={selected ? 'true' : 'false'}
          style={{ ...rowBtn(x, selected), display: 'block', padding: '12px 4px' }}>
          <span style={{ display: 'block', fontSize: 13.5, fontWeight: 600, lineHeight: 1.35, overflowWrap: 'anywhere' }}>{x.h.name}</span>
          {meta && <span style={{ display: 'block', fontSize: 11.5, color: 'var(--fg-3)', marginTop: 2 }}>{meta}</span>}
          <span style={{ display: 'block', margin: '8px 0 6px' }}><HabitRateBar rate={x.r.rate} goal={goal}/></span>
          <span className="num" style={{ display: 'block', fontSize: 12.5 }}>
            <strong style={{ fontWeight: 700 }}>{rateText(x)}</strong>
            <span style={{ color: 'var(--fg-2)' }}> · {countText(x)}</span>
          </span>
          <span className="num" style={{ display: 'block', fontSize: 12, color: 'var(--fg-2)', marginTop: 3 }}>
            série {noStreak ? '—' : statsDaysText(x.s.current, true)} · record {record || statsDaysText(x.s.best, true)}
          </span>
        </button>
      );
    }
    return (
      <button key={x.h.id} type="button" className="tap" data-habit-stat={x.h.id} onClick={() => onPick(x.h.id)}
        title={pickLabel(x)} aria-pressed={selected ? 'true' : 'false'}
        style={{ ...rowBtn(x, selected), display: 'grid', gridTemplateColumns: cols, columnGap: 18, alignItems: 'center', padding: '11px 8px' }}>
        <span style={{ minWidth: 0 }}>
          <span style={{ display: 'block', fontSize: 13, fontWeight: 500, lineHeight: 1.35, overflowWrap: 'anywhere' }}>{x.h.name}</span>
          {meta && <span style={{ display: 'block', fontSize: 11.5, color: 'var(--fg-3)', marginTop: 2 }}>{meta}</span>}
        </span>
        <span style={{ minWidth: 0 }}>
          <span style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
            <span style={{ flex: 1, minWidth: 0 }}><HabitRateBar rate={x.r.rate} goal={goal}/></span>
            <span className="num" style={{ width: 46, textAlign: 'right', fontSize: 13, fontWeight: 700, whiteSpace: 'nowrap', color: x.r.rate === null ? 'var(--fg-3)' : 'var(--fg)' }}>{rateText(x)}</span>
          </span>
          <span className="num" style={{ display: 'block', fontSize: 11.5, color: 'var(--fg-3)', marginTop: 2 }}>{countText(x)}</span>
        </span>
        <span className="num" style={{ fontSize: 12.5, whiteSpace: 'nowrap', color: x.s.current && !noStreak ? 'var(--fg)' : 'var(--fg-3)' }}>{streak}</span>
        <span className="num" style={{ fontSize: 12.5, whiteSpace: 'nowrap', color: 'var(--fg-2)' }}>{record || statsDaysText(x.s.best)}</span>
      </button>
    );
  };

  return (
    <StatsHabitCard card="detail" title="Détail par habitude" compact={compact}
      sub={'Clique sur une habitude pour la voir seule dans le calendrier. Le trait vertical marque ton objectif (' + fmtPct(goal, 0) + ').'}
      right={<StatsSegmented value={sort} onChange={setSort} size="xs" ariaLabel="Tri des habitudes"
        options={[{ id: 'order', label: 'Ordre des routines' }, { id: 'rate', label: 'Moins régulières d’abord' }]}/>}>
      {!narrow && (
        <div style={{ display: 'grid', gridTemplateColumns: cols, columnGap: 18, padding: '0 8px 8px' }}>
          <span style={headCell}>Habitude</span>
          <span style={headCell}>Régularité</span>
          <span style={headCell}>Série en cours</span>
          <span style={headCell}>Record depuis le début</span>
        </div>
      )}
      {lines.length === 0 && <div style={{ fontSize: 12.5, color: 'var(--fg-3)', padding: '8px 0' }}>Aucune habitude active. Coche la case ci-dessous pour revoir tes habitudes archivées.</div>}
      {groups.map((g) => (
        <div key={g.sec || 'all'}>
          {g.sec && (
            <div style={{ padding: narrow ? '12px 4px 6px' : '12px 8px 6px', borderTop: '1px solid var(--line)' }}>
              <span style={{ fontSize: 11, fontWeight: 600, color: 'var(--fg-2)', textTransform: 'uppercase', letterSpacing: '.04em' }}>{SECTION_LABELS[g.sec]}</span>
              {g.sec === 'pretrade' && <span style={{ fontSize: 11.5, color: 'var(--fg-3)' }}> · {tradesKnown ? 'jours tradés uniquement' : 'non comptées : tes trades ne sont pas chargés'}</span>}
            </div>
          )}
          {g.items.map((x) => renderRow(x, !g.sec))}
        </div>
      ))}
      {archivedDated.length > 0 && (
        <label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, marginTop: 12, paddingTop: 2, minHeight: narrow ? 44 : 28, fontSize: 12.5, color: 'var(--fg-2)', cursor: 'pointer' }}>
          <input type="checkbox" checked={showArchived} onChange={(e) => setShowArchived(e.target.checked)} style={{ width: 15, height: 15, margin: 0, accentColor: 'var(--blue)' }}/>
          Afficher les habitudes archivées ({fmtNum(archivedDated.length, 0)})
        </label>
      )}
    </StatsHabitCard>
  );
}

// ─── Discipline et résultats (§8.5.7, règles §5.16 dans habitDiscipline) ──
function StatsHabitDiscipline({ state, rows, ctx, range, goal, compact, narrow, nav, openJournal }) {
  const [mode, setMode] = React.useState('pretrade');
  const res = React.useMemo(() => habitDiscipline(rows, range.fromISO, range.toISO, mode, ctx, goal),
    [rows, ctx, range.fromISO, range.toISO, mode, goal]);
  const pre = mode === 'pretrade';
  const { a, b, skipped } = res;
  const has = rows.some((h) => (pre ? h.section === 'pretrade' : h.section !== 'pretrade') && (!h.archived || h.archivedDay));
  const compared = a.days + b.days;
  const minDays = Math.min(a.days, b.days);
  const enough = minDays >= STATS_HABIT_MIN_SAMPLE;
  const labelA = pre ? 'Tout vérifié' : 'Objectif atteint';
  const labelB = pre ? 'Incomplet' : 'Sous l’objectif';

  const money = (v) => (v === null || v === undefined
    ? <span style={{ color: 'var(--fg-3)' }}>—</span>
    : <span style={{ color: statsToneColor(statsMoneyTone(v)) }}>{fmtMoney(v, true)}</span>);
  const plain = (txt) => (txt === '—' ? <span style={{ color: 'var(--fg-3)' }}>—</span> : txt);
  const daysCell = (grp) => (grp.days > 0 ? statsDaysText(grp.days) + ' · ' + statsTradesText(grp.trades) : '—');
  // Écarts calculés sur les valeurs telles qu'elles sont écrites dans les deux
  // colonnes (arrondies) : « +0,56 R » et « +0,50 R » donnent « +0,06 R ».
  const moneyDec = typeof currencyDecimals === 'function' ? currencyDecimals(window.__appCurrency) : 2;
  const diffR = a.avgR !== null && b.avgR !== null ? statsRound(statsRound(a.avgR, 2) - statsRound(b.avgR, 2), 2) : null;
  const diffDay = a.pnlPerDay !== null && b.pnlPerDay !== null
    ? statsRound(statsRound(a.pnlPerDay, moneyDec) - statsRound(b.pnlPerDay, moneyDec), moneyDec) : null;
  const diffWin = a.winRate !== null && b.winRate !== null
    ? Math.round(statsRound(statsRound(a.winRate, 1) - statsRound(b.winRate, 1), 1)) : null;
  const metrics = [
    { label: 'Jours tradés', a: plain(daysCell(a)), b: plain(daysCell(b)), d: null },
    { label: 'P&L net', a: money(a.days ? a.pnl : null), b: money(b.days ? b.pnl : null), d: null },
    { label: 'P&L moyen par jour', a: money(a.pnlPerDay), b: money(b.pnlPerDay),
      d: enough && diffDay !== null ? money(diffDay) : null },
    { label: 'Taux de réussite', a: plain(a.winRate === null ? '—' : fmtPct(a.winRate, 1)), b: plain(b.winRate === null ? '—' : fmtPct(b.winRate, 1)),
      d: enough && diffWin !== null ? statsPtsText(diffWin) : null },
    { label: 'R moyen', a: plain(a.avgR === null ? '—' : fmtR(a.avgR)), b: plain(b.avgR === null ? '—' : fmtR(b.avgR)),
      d: enough && diffR !== null ? fmtR(diffR) : null },
  ];

  // Jours tradés inconnus (trades JAMAIS chargés) : pas de chiffres, le message
  // d'erreur seul (§8.5.7). Un simple échec de rechargement garde au contraire
  // les trades déjà chargés : la comparaison reste affichée, comme le reste de
  // l'onglet, et le bandeau est en tête de page (StatsHabits).
  const tradesUnknown = !ctx || !ctx.tradedDays;
  let body;
  if (tradesUnknown) {
    body = <StatsLoadError state={state}/>;
  } else if (!has) {
    body = pre ? (
      <StatsEmpty title="Aucune vérification avant trade"
        sub="Ajoute les points à contrôler avant chaque trade : tu verras ici si tes résultats changent quand tu les respectes."
        action={typeof nav === 'function' ? { label: 'Ajouter mes vérifications', onClick: () => nav('routines') } : null}/>
    ) : (
      <StatsEmpty title="Aucune habitude du matin, de la journée ou du soir"
        sub="Crée une habitude dans Routines pour comparer tes journées de trading."
        action={typeof nav === 'function' ? { label: 'Créer mes habitudes', onClick: () => nav('routines') } : null}/>
    );
  } else if (compared + skipped === 0) {
    body = <div data-habit-discipline-empty style={{ fontSize: 13, color: 'var(--fg-2)', padding: '6px 0' }}>Aucun jour tradé sur la période.</div>;
  } else {
    // Un groupe sans aucun jour n'a ni colonne ni bloc (cinq tirets n'apprennent
    // rien) : une phrase le dit sous le tableau.
    const groupsShown = [{ id: 'a', label: labelA, grp: a }, { id: 'b', label: labelB, grp: b }].filter((col) => col.grp.days > 0);
    const tableCols = 'minmax(120px, 1.2fr) repeat(' + (groupsShown.length + (enough ? 1 : 0)) + ', minmax(0, 1fr))';
    const cell = { fontSize: 13, whiteSpace: 'nowrap' };
    const groupTitle = (label, grp) => (
      <span style={{ fontSize: 12.5, fontWeight: 600 }}>{label}<span className="num" style={{ fontWeight: 500, color: 'var(--fg-3)' }}> · {statsDaysText(grp.days)}</span></span>
    );
    const review = b.dayList.slice(0, 5);
    body = (
      <>
        {compared > 0 && (narrow ? (
          <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
            {groupsShown.map((col) => (
              <div key={col.id} data-habit-group={col.id} style={{ border: '1px solid var(--line)', borderRadius: 10, padding: '12px 12px 6px' }}>
                <div style={{ marginBottom: 6 }}>{groupTitle(col.label, col.grp)}</div>
                {metrics.map((m) => (
                  <div key={m.label} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 12, padding: '7px 0', borderTop: '1px solid var(--line)' }}>
                    <span style={{ fontSize: 12.5, color: 'var(--fg-2)' }}>{m.label}</span>
                    <span className="num" style={{ ...cell, fontWeight: 600 }}>{m[col.id]}</span>
                  </div>
                ))}
              </div>
            ))}
            {enough && (
              <div data-habit-group="ecart" style={{ border: '1px solid var(--line)', borderRadius: 10, padding: '12px 12px 6px', background: 'var(--bg-soft)' }}>
                <div style={{ fontSize: 12.5, fontWeight: 600, marginBottom: 6 }}>Écart</div>
                {metrics.filter((m) => m.d !== null).map((m) => (
                  <div key={m.label} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 12, padding: '7px 0', borderTop: '1px solid var(--line)' }}>
                    <span style={{ fontSize: 12.5, color: 'var(--fg-2)' }}>{m.label}</span>
                    <span className="num" style={{ ...cell, fontWeight: 600 }}>{m.d}</span>
                  </div>
                ))}
              </div>
            )}
          </div>
        ) : (
          <div role="table" aria-label="Discipline et résultats" data-habit-table>
            <div role="row" style={{ display: 'grid', gridTemplateColumns: tableCols, columnGap: 16, alignItems: 'baseline', padding: '0 4px 9px' }}>
              <span role="columnheader" aria-label="Indicateur"/>
              {groupsShown.map((col) => <span key={col.id} role="columnheader" data-habit-group={col.id}>{groupTitle(col.label, col.grp)}</span>)}
              {enough && <span role="columnheader" style={{ fontSize: 12.5, fontWeight: 600 }}>Écart</span>}
            </div>
            {metrics.map((m) => (
              <div role="row" key={m.label} style={{ display: 'grid', gridTemplateColumns: tableCols, columnGap: 16, alignItems: 'baseline', padding: '9px 4px', borderTop: '1px solid var(--line)' }}>
                <span role="rowheader" style={{ fontSize: 12.5, color: 'var(--fg-2)' }}>{m.label}</span>
                {groupsShown.map((col) => <span key={col.id} role="cell" className="num" style={{ ...cell, fontWeight: 600 }}>{m[col.id]}</span>)}
                {enough && <span role="cell" className="num" style={{ ...cell, fontWeight: 600 }}>{m.d}</span>}
              </div>
            ))}
          </div>
        ))}

        {compared > 0 && b.days === 0 && (
          <div data-habit-group-empty="b" style={{ fontSize: 12.5, color: 'var(--fg-2)', lineHeight: 1.5, marginTop: 12 }}>
            {pre
              ? (a.days > 1 ? 'Tes vérifications étaient complètes les ' + fmtNum(a.days, 0) + ' jours tradés.' : 'Tes vérifications étaient complètes le seul jour tradé.')
              : (a.days > 1 ? 'Tu as atteint ton objectif les ' + fmtNum(a.days, 0) + ' jours tradés.' : 'Tu as atteint ton objectif le seul jour tradé.')}
          </div>
        )}
        {compared > 0 && a.days === 0 && (
          <div data-habit-group-empty="a" style={{ fontSize: 12.5, color: 'var(--fg-2)', lineHeight: 1.5, marginTop: 12 }}>
            {pre ? 'Aucun jour tradé avec toutes tes vérifications sur la période.' : 'Aucun jour tradé avec ton objectif atteint sur la période.'}
          </div>
        )}
        {a.days > 0 && b.days > 0 && !enough && (
          <div data-habit-sample style={{ fontSize: 12.5, lineHeight: 1.5, marginTop: 12, padding: '9px 12px', borderRadius: 8, background: 'var(--amber-soft)', border: '1px solid var(--amber-border)', color: 'var(--fg)' }}>
            Peu de jours pour comparer ({fmtNum(a.days, 0)} et {fmtNum(b.days, 0)}){STATS_NB}: à confirmer avec le temps.
          </div>
        )}
        {enough && (
          <div data-habit-summary style={{ fontSize: 13, lineHeight: 1.55, marginTop: 12 }}>
            Sur {fmtNum(compared, 0)} jours tradés, ton P&L moyen est de <strong className="num" style={{ fontWeight: 600, whiteSpace: 'nowrap', color: statsToneColor(statsMoneyTone(a.pnlPerDay)) }}>{fmtMoney(a.pnlPerDay, true)}</strong>
            {' '}{pre ? 'les jours où tout est vérifié' : 'les jours où ton objectif est atteint'} ({statsDaysText(a.days)}), contre{' '}
            <strong className="num" style={{ fontWeight: 600, whiteSpace: 'nowrap', color: statsToneColor(statsMoneyTone(b.pnlPerDay)) }}>{fmtMoney(b.pnlPerDay, true)}</strong>
            {' '}les autres jours ({statsDaysText(b.days)}).
          </div>
        )}

        {review.length > 0 && (
          <div data-habit-review style={{ marginTop: 16 }}>
            <div style={{ fontSize: 12.5, fontWeight: 600, marginBottom: 2 }}>Jours à revoir</div>
            <div style={{ fontSize: 11.5, color: 'var(--fg-3)', marginBottom: 4 }}>
              {pre ? 'Les derniers jours tradés sans toutes tes vérifications.' : 'Les derniers jours tradés sous ton objectif.'}
            </div>
            {review.map((d) => (
              <div key={d.day} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '2px 14px', padding: '8px 0', borderTop: '1px solid var(--line)' }}>
                <span className="num" style={{ fontSize: 12.5 }}>
                  <span style={{ fontWeight: 500 }}>{fmtWeekdayShort(d.day)} {fmtDayShort(d.day)}</span>
                  <span style={{ color: 'var(--fg-2)' }}> · {statsOutOf(d.done, d.planned)} · </span>
                  <span style={{ fontWeight: 600, whiteSpace: 'nowrap', color: statsToneColor(statsMoneyTone(d.pnl)) }}>{fmtMoney(d.pnl, true)}</span>
                </span>
                {typeof openJournal === 'function' && (
                  <button type="button" onClick={() => openJournal(d.day)} className="tap"
                    aria-label={'Ouvrir le journal du ' + fmtDayLong(d.day)}
                    style={{ background: 'transparent', border: 'none', padding: '4px 0', color: 'var(--blue-600)', fontSize: 12.5, fontWeight: 500, cursor: 'pointer', whiteSpace: 'nowrap' }}>
                    Ouvrir le journal ›
                  </button>
                )}
              </div>
            ))}
          </div>
        )}

        {skipped > 0 && (
          <div style={{ fontSize: 12, color: 'var(--fg-2)', lineHeight: 1.5, marginTop: 12 }}>
            {skipped > 1 ? fmtNum(skipped, 0) + ' jours tradés non comparés' : '1 jour tradé non comparé'}
            {' '}({pre ? 'aucune vérification' : 'aucune habitude comptée'} {skipped > 1 ? 'ces jours-là' : 'ce jour-là'}).
          </div>
        )}
      </>
    );
  }

  const smallSample = !tradesUnknown && has && enough && minDays < STATS_HABIT_SMALL_SAMPLE;
  return (
    <StatsHabitCard card="discipline" title="Discipline et résultats" compact={compact}
      sub={pre
        ? 'Tes jours de trading terminés, selon que tes vérifications étaient toutes cochées ou non.'
        : 'Tes jours de trading terminés, selon que tes habitudes du jour atteignaient ton objectif (' + fmtPct(goal, 0) + ') ou non.'}
      right={
        <span style={{ display: 'inline-flex', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
          {smallSample && <span className="pill pill-gray" style={{ fontSize: 11.5, padding: '3px 9px' }}>Échantillon réduit</span>}
          <StatsSegmented value={mode} onChange={setMode} size="xs" ariaLabel="Critère de comparaison"
            options={[{ id: 'pretrade', label: 'Vérifications avant trade' }, { id: 'habits', label: 'Habitudes' }]}/>
        </span>
      }>
      {body}
      <div style={{ fontSize: 11.5, color: 'var(--fg-3)', lineHeight: 1.5, marginTop: 14 }}>
        Comparaison indicative sur tes propres trades{STATS_NB}: elle ne prouve pas de lien de cause.
      </div>
    </StatsHabitCard>
  );
}

// ─── Indicateur (§8.5.1) ─────────────────────────────────────────────
function StatsHabitKpi({ kpi, label, value, sub, color = 'var(--fg)', badge, narrow }) {
  return (
    <div className="card lift" data-kpi={kpi} style={{ padding: narrow ? '15px 14px' : '18px 20px', minWidth: 0 }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '4px 8px', marginBottom: 12, minHeight: 20 }}>
        <span style={{ fontSize: 13, fontWeight: 500, color: 'var(--fg-2)', letterSpacing: '-.005em' }}>{label}</span>
        {badge}
      </div>
      <div className="num" data-kpi-value style={{ fontSize: narrow ? 24 : 30, fontWeight: 700, letterSpacing: '-.024em', color, lineHeight: 1, whiteSpace: 'nowrap' }}>{value}</div>
      {sub && <div data-kpi-sub style={{ fontSize: 11.5, color: 'var(--fg-3)', marginTop: 9, lineHeight: 1.4, overflowWrap: 'anywhere' }}>{sub}</div>}
    </div>
  );
}

// Squelette : 4 indicateurs et la grille, pendant le chargement des habitudes
// ou de leur historique (des chiffres partiels seraient faux).
function StatsHabitsSkeleton({ compact = false }) {
  const bar = (w, h) => <div style={{ width: w, height: h, borderRadius: 6, background: 'var(--bg-elev)' }}/>;
  return (
    <div role="status" aria-busy="true" aria-label="Chargement de tes habitudes" data-habit-skeleton style={{ display: 'flex', flexDirection: 'column', gap: compact ? 12 : 16 }}>
      <div style={{ display: 'grid', gridTemplateColumns: `repeat(${compact ? 2 : 4}, minmax(0, 1fr))`, gap: compact ? 10 : 14 }}>
        {[0, 1, 2, 3].map((i) => (
          <div key={i} className="card" style={{ padding: '18px 20px', display: 'flex', flexDirection: 'column', gap: 12 }}>
            {bar('55%', 10)}{bar('70%', 26)}{bar('85%', 10)}
          </div>
        ))}
      </div>
      <div className="card" style={{ padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 14 }}>
        {bar('34%', 14)}{bar('62%', 10)}
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', gap: compact ? 4 : 8 }}>
          {Array.from({ length: 21 }, (_, i) => <div key={i} style={{ height: compact ? 46 : 68, borderRadius: 8, background: 'var(--bg-elev)' }}/>)}
        </div>
      </div>
    </div>
  );
}

function StatsHabits({ state, range, period, nav, openJournal, compact = false }) {
  const st = state || {};
  const rows = Array.isArray(st.habitRows) ? st.habitRows : STATS_NO_ROWS;
  const ctx = st.habitCtx || null;
  const goalNum = Number(st.habitGoal);
  const goal = Number.isFinite(goalNum) && goalNum > 0 ? goalNum : HABIT_GOAL_DEFAULT;
  const { theme } = useTheme();
  const [bodyRef, bodyW] = useStatsBodyWidth();
  const [filterId, setFilterId] = React.useState('');
  const [selDay, setSelDay] = React.useState(null);
  const calRef = React.useRef(null);
  const fromISO = range.fromISO, toISO = range.toISO;

  // Une habitude filtrée qui disparaît (archivée sans date, supprimée) : retour à « Toutes ».
  React.useEffect(() => {
    if (filterId && !rows.some((h) => h.id === filterId && (!h.archived || h.archivedDay))) setFilterId('');
  }, [filterId, rows]);

  const summary = React.useMemo(() => {
    if (!ctx) return null;
    const cur = habitRangeStats(rows, fromISO, toISO, ctx);
    const evo = habitEvolution(rows, period, fromISO, ctx);
    let best = null;
    for (const h of rows) {
      if (h.archived) continue;
      if (h.section === 'pretrade' && !ctx.tradedDays) continue; // série inconnue sans les trades
      const s = habitStreaks(h, ctx);
      if (!best || (s.current || 0) > best.current) best = { h, current: s.current || 0, best: s.best || 0 };
    }
    return { cur, evo, best };
  }, [rows, ctx, period, fromISO, toISO]);

  const pickHabit = React.useCallback((id) => {
    setFilterId((cur) => (cur === id ? '' : id));
    const el = calRef.current;
    if (el && typeof el.scrollIntoView === 'function') el.scrollIntoView({ behavior: 'smooth', block: 'start' });
  }, []);

  const gapY = compact ? 12 : 16;
  const errorBanner = st.habitsError
    ? <HabitLoadError onRetry={typeof st.reloadHabits === 'function' ? st.reloadHabits : undefined}/>
    : null;
  // Jours tradés : tant que les trades se chargent, les vérifications avant
  // trade ne sont pas comptées — des chiffres partiels seraient faux, on attend.
  // S'ils n'ont jamais pu être chargés, on affiche le reste et on le dit.
  const tradesState = st.habitTradesState || 'ready';
  const ready = !!ctx && !st.habitsLoading && !!st.habitHistoryReady && tradesState !== 'loading';
  // Erreur ≠ compte vide : le bandeau seul tant que rien de fiable n'est chargé.
  if (errorBanner && (!ready || rows.length === 0)) return <div data-habit-stats>{errorBanner}</div>;
  if (!ready) return <StatsHabitsSkeleton compact={compact}/>;

  const active = rows.filter((h) => !h.archived);
  const archivedDated = rows.filter((h) => h.archived && h.archivedDay);
  if (active.length === 0 && archivedDated.length === 0) {
    return (
      <div className="card" data-habit-stats>
        <EmptyState icon={Ico.book} title="Aucune habitude à suivre"
          text="Crée 2 ou 3 habitudes dans Routines : ta régularité s’affichera ici, jour par jour."
          primary={typeof nav === 'function' ? { label: 'Créer mes habitudes', onClick: () => nav('routines') } : null}/>
      </div>
    );
  }

  const today = ctx.today;
  const { cur, evo, best } = summary;
  const yesterday = habitShiftDay(today, -1);
  const first = habitFirstDay(rows);
  const periodStartsToday = !!fromISO && fromISO > yesterday;
  const trackingStartsToday = !periodStartsToday && (!first || first > yesterday);
  const narrowKpi = bodyW > 0 ? bodyW < STATS_FOUR_COLS_MIN : compact;
  // Carte étroite (téléphone, tablette en 4 colonnes) : valeur plus petite, jamais tronquée.
  const kpiCols = narrowKpi ? 2 : 4;
  const smallKpi = bodyW > 0 ? (bodyW - (kpiCols - 1) * (compact ? 10 : 14)) / kpiCols < 230 : compact;
  const narrow = bodyW > 0 ? bodyW < 700 : compact;
  const twoCols = bodyW >= 900;
  const gap = compact ? 10 : 14;

  const goalReached = cur.rate !== null && cur.rate >= goal;
  const delta = evo ? evo.delta : null;
  // « — » : soit la période n'a encore aucun jour terminé, soit rien n'était compté avant.
  // « Tout » : l'évolution ne porte plus sur la Régularité affichée à côté (tout
  // l'historique) mais sur les 30 derniers jours : les deux taux sont écrits.
  const pct0 = (v) => fmtPct(v, 0);
  const evoSub = !evo ? 'aucun jour terminé à comparer'
    : period === 'all' ? (
      evo.cur.rate === null ? 'rien de compté sur les 30 derniers jours'
      : evo.prev.rate === null ? '30 derniers jours' + STATS_NB + ': ' + pct0(evo.cur.rate) + ' · rien de compté les 30 jours d’avant'
      : '30 derniers jours' + STATS_NB + ': ' + pct0(evo.cur.rate) + ' · 30 jours d’avant' + STATS_NB + ': ' + pct0(evo.prev.rate))
    : delta !== null ? 'par rapport ' + evo.range.label
    : evo.cur.rate === null ? 'rien de compté sur la période'
    : period === 'year' ? 'pas de suivi à la même période l’an dernier'
    : 'rien de compté ' + evo.range.label;
  const hasChecks = rows.some((h) => h.section === 'pretrade' && (!h.archived || h.archivedDay));
  const partial = tradesState === 'error' && hasChecks;

  return (
    <div ref={bodyRef} data-habit-stats className="stagger" style={{ display: 'flex', flexDirection: 'column', gap: gapY }}>
      {errorBanner}
      {tradesState === 'error' && (
        <div data-habit-trades-error>
          <HabitLoadError onRetry={typeof st.refreshTrades === 'function' ? st.refreshTrades : undefined}
            text={'Impossible de charger tes trades. ' + (hasChecks ? 'Tes vérifications avant trade ne sont pas comptées pour l’instant.' : 'La comparaison avec tes résultats n’est pas disponible pour l’instant.')}/>
        </div>
      )}
      {/* Rechargement en échec alors que les trades étaient déjà chargés : les
          chiffres restent (charte §7), le bandeau le dit en tête, comme sur l'onglet Trading. */}
      {tradesState !== 'error' && st.tradesError && (
        <div data-habit-trades-stale>
          <HabitLoadError onRetry={typeof st.refreshTrades === 'function' ? st.refreshTrades : undefined}
            text="Impossible de charger tes trades. Vérifie ta connexion."/>
        </div>
      )}

      <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
        <div style={{ display: 'grid', gridTemplateColumns: `repeat(${kpiCols}, minmax(0, 1fr))`, gap }}>
          <StatsHabitKpi kpi="regularite" label="Régularité" narrow={smallKpi}
            value={cur.rate === null ? '—' : fmtPct(cur.rate, 1)}
            badge={goalReached ? <span className="pill pill-green" style={{ fontSize: 11, padding: '2px 8px', whiteSpace: 'nowrap' }}>Objectif atteint</span> : null}
            sub={(cur.planned > 0 ? statsDonePlanned(cur.done, cur.planned) + ' · hors aujourd’hui' : 'rien de compté sur la période') + (partial ? ' · hors vérifications avant trade' : '')}/>
          <StatsHabitKpi kpi="evolution" label={period === 'all' ? 'Évolution sur 30 jours' : 'Évolution'} narrow={smallKpi}
            value={delta === null ? '—' : statsPtsText(delta)}
            color={delta === null || delta === 0 ? 'var(--fg)' : delta > 0 ? 'var(--green)' : 'var(--red)'}
            sub={evoSub}/>
          <StatsHabitKpi kpi="parfaites" label="Journées parfaites" narrow={smallKpi}
            value={cur.countedDays > 0 ? statsOutOf(cur.perfectDays, cur.countedDays) : '—'}
            sub="jours où tout ce qui était prévu est fait"/>
          <StatsHabitKpi kpi="serie" label="Meilleure série en cours" narrow={smallKpi}
            value={best ? statsDaysText(best.current) : '—'}
            sub={!best ? 'aucune habitude active' : best.current > 0 ? best.h.name + ' · record ' + statsDaysText(best.best) : 'aucune série en cours'}/>
        </div>
        {(periodStartsToday || trackingStartsToday) && (
          <div data-habit-help style={{ fontSize: 12.5, color: 'var(--fg-2)', lineHeight: 1.5, padding: '2px 2px 0' }}>
            {periodStartsToday ? 'La période commence aujourd’hui' : 'Ton suivi commence aujourd’hui'}{STATS_NB}: tes chiffres apparaîtront demain, une fois la journée terminée.
          </div>
        )}
      </div>

      <StatsHabitCalendar state={st} rows={rows} ctx={ctx} period={period} range={range} compact={compact} theme={theme}
        filterId={filterId} setFilterId={setFilterId} selDay={selDay} setSelDay={setSelDay}
        openJournal={openJournal} anchorRef={calRef}/>

      {period !== 'week' && (
        <div style={{ display: 'grid', gridTemplateColumns: twoCols ? 'repeat(2, minmax(0, 1fr))' : 'minmax(0, 1fr)', gap: twoCols ? gap : gapY }}>
          <StatsHabitEvolution rows={rows} ctx={ctx} period={period} goal={goal} average={cur.rate} compact={compact}/>
          <StatsHabitWeekdays rows={rows} ctx={ctx} range={range} countedDays={cur.countedDays} compact={compact}/>
        </div>
      )}

      <StatsHabitDetail rows={rows} ctx={ctx} range={range} goal={goal} compact={compact} narrow={narrow}
        filterId={filterId} onPick={pickHabit} tradesKnown={tradesState !== 'error'}/>

      <StatsHabitDiscipline state={st} rows={rows} ctx={ctx} range={range} goal={goal} compact={compact} narrow={narrow}
        nav={nav} openJournal={openJournal}/>
    </div>
  );
}

// ═════════════════════════════════════════════════════════════════════
//  BARRE DE CONTRÔLE (sous-onglets + période) — partagée desktop/mobile
// ═════════════════════════════════════════════════════════════════════
function StatsControls({ view, setView, period, setPeriod, rangeLabel, mobile = false }) {
  const viewOptions = [
    { id: 'trading', label: 'Trading', ico: <span style={{ display: 'inline-flex' }}>{Ico.trades}</span> },
    { id: 'habits',  label: 'Habitudes', ico: <span style={{ display: 'inline-flex' }}>{Ico.book}</span> },
  ];
  // Plage exacte de la période, sous le sélecteur (charte §4).
  const range = <span className="num" style={{ fontSize: 11.5, color: 'var(--fg-3)', whiteSpace: 'nowrap' }}>{rangeLabel}</span>;
  if (mobile) {
    return (
      <div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 16 }}>
        {/* Pleine largeur sur téléphone, bornée sur tablette. */}
        <div style={{ maxWidth: 420 }}>
          <StatsSegmented value={view} onChange={setView} options={viewOptions} kind="tabs" ariaLabel="Vue des statistiques" idPrefix="stats" stretch />
        </div>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
          <div style={{ overflowX: 'auto' }} className="scroll">
            <StatsSegmented value={period} onChange={setPeriod} options={STATS_PERIODS} size="sm" ariaLabel="Période" />
          </div>
          <div style={{ paddingLeft: 2 }}>{range}</div>
        </div>
      </div>
    );
  }
  return (
    <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 12, marginBottom: 20 }}>
      <StatsSegmented value={view} onChange={setView} options={viewOptions} kind="tabs" ariaLabel="Vue des statistiques" idPrefix="stats" />
      <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
        <StatsSegmented value={period} onChange={setPeriod} options={STATS_PERIODS} size="sm" ariaLabel="Période" />
        {range}
      </div>
    </div>
  );
}

// État de vue/période partagé (gère initialView + prop period optionnelle).
// La plage suit « aujourd'hui » dans le fuseau du profil (minuit, changement de fuseau).
const STATS_PERIOD_IDS = ['week', 'month', 'year', 'all'];
function useStatsState(initialView, initialPeriod) {
  const [view, setView] = React.useState(initialView === 'habits' ? 'habits' : 'trading');
  const [period, setPeriod] = React.useState(
    STATS_PERIOD_IDS.includes(initialPeriod) ? initialPeriod : 'month'
  );
  React.useEffect(() => {
    if (initialView === 'habits' || initialView === 'trading') setView(initialView);
  }, [initialView]);
  React.useEffect(() => {
    if (STATS_PERIOD_IDS.includes(initialPeriod)) setPeriod(initialPeriod);
  }, [initialPeriod]);
  const today = todayISO();
  const range = React.useMemo(() => statsPeriodRange(period, today), [period, today]);
  return { view, setView, period, setPeriod, range };
}

// Action « Ajouter un trade » de l'état vide : fenêtre « Ajouter des trades »
// ouverte sur l'onglet Saisie manuelle (charte §5).
function statsAddTradeAction(openImport) {
  return typeof openImport === 'function' ? () => openImport(null, 'manual') : null;
}

// Contenu commun desktop / mobile.
function StatsBody({ state, initialView, periodProp, openImport, nav, openJournal, mobile = false }) {
  const { view, setView, period, setPeriod, range } = useStatsState(initialView, periodProp);
  const trades = (state && Array.isArray(state.trades)) ? state.trades : [];
  const firstDay = useStatsFirstDay(trades);
  const loading = !!(state && state.tradesLoading);
  const error = !!(state && state.tradesError);
  // Premier jour de suivi des habitudes (période « Tout » de la vue Habitudes).
  const habitRows = state && state.habitRows;
  const habitFirst = React.useMemo(
    () => (typeof habitFirstDay === 'function' && Array.isArray(habitRows) ? habitFirstDay(habitRows) : null),
    [habitRows]
  );
  return (
    <>
      <StatsControls view={view} setView={setView} period={period} setPeriod={setPeriod}
        rangeLabel={statsRangeLabel(range, view, firstDay, habitFirst)} mobile={mobile} />
      {/* Panneau de l'onglet choisi (Trading ou Habitudes), relié à son onglet :
          même forme que la fenêtre du journal du jour (idPrefix + « -panel- »).
          Le panneau de l'autre onglet existe aussi, vide et masqué
          (TabPanelStubs, core.jsx) : son onglet le désigne par aria-controls. */}
      <TabPanelStubs idPrefix="stats" options={STATS_VIEW_IDS} value={view} />
      <div role="tabpanel" id={'stats-panel-' + view} aria-labelledby={'stats-tab-' + view}>
        {view === 'trading'
          ? <>
              <StatsLoadError state={state} />
              <StatsTrading trades={trades} range={range} setPeriod={setPeriod} compact={mobile}
                loading={loading} error={error} onAddTrade={statsAddTradeAction(openImport)} onImportFile={typeof openImport === 'function' ? () => openImport(null, 'csv') : null}
                tradesByDay={state && state.tradesByDay} today={state && state.today} nav={nav} openJournal={openJournal} />
            </>
          : <StatsHabits state={state} range={range} period={period} nav={nav} openJournal={openJournal} compact={mobile} />}
      </div>
    </>
  );
}

// ═════════════════════════════════════════════════════════════════════
//  PAGE DESKTOP
// ═════════════════════════════════════════════════════════════════════
function StatsPage({ state, initialView, period: periodProp, openImport, nav, openJournal }) {
  return (
    <div style={{ width: '100%', height: '100%', overflow: 'auto', display: 'flex', flexDirection: 'column' }} className="scroll">
      <PageHeader title={statsTitle()} />
      <div style={{ ...PAGE_BODY, padding: '20px 24px 40px', flex: 1 }}>
        <StatsBody state={state} initialView={initialView} periodProp={periodProp} openImport={openImport} nav={nav} openJournal={openJournal} />
      </div>
    </div>
  );
}

// ═════════════════════════════════════════════════════════════════════
//  PAGE MOBILE
// ═════════════════════════════════════════════════════════════════════
function StatsMobile({ state, initialView, period: periodProp, openImport, nav, openJournal }) {
  return (
    <div style={{ padding: '14px 16px calc(28px + env(safe-area-inset-bottom))' }}>
      <h1 style={{ fontSize: 24, fontWeight: 700, letterSpacing: '-.025em', margin: '0 0 16px' }}>{statsTitle()}</h1>
      <StatsBody state={state} initialView={initialView} periodProp={periodProp} openImport={openImport} nav={nav} openJournal={openJournal} mobile />
    </div>
  );
}

Object.assign(window, { StatsPage, StatsMobile });
