// Tempo — pages : Accueil, Journal quotidien, Trades, fenêtre du jour, détail
// d'un trade, connexion et mot de passe.

// ─── Briques communes de ce fichier ───────────────────────────────

// Mot de passe : longueur minimale unique (charte §7), pour le contrôle, le
// message, le placeholder et minLength. Exposée sur window (Réglages).
const PASSWORD_MIN = 8;
const PASSWORD_HINT = PASSWORD_MIN + ' caractères minimum';
const PASSWORD_TOO_SHORT = 'Le mot de passe doit faire au moins ' + PASSWORD_MIN + ' caractères.';

// Libellés d'écrans : table unique NAV_LABELS (core.jsx, chargé avant ce fichier).
function pg2Nav(id) {
  return NAV_LABELS[id] || {};
}

// Source d'un trade telle qu'affichée (charte §5), jamais le code brut
// (« generic », « mt5 ») : table unique appSourceLabel (app.jsx, lu au rendu).
// Un trade sans source a été saisi à la main.
function tradeSourceLabel(src) {
  return appSourceLabel(src) || 'Saisie manuelle';
}

// « Sam. 26 sept. » : jour court capitalisé + date, dans le fuseau du profil.
function pg2DayTitle(day) {
  const wd = fmtWeekdayShort(day);
  if (wd === '—') return '—';
  return wd.charAt(0).toUpperCase() + wd.slice(1) + ' ' + fmtDayShort(day);
}

// Nombre de trades notés en R : même règle que computeStats (trades-api.jsx),
// R renseigné, fini et non nul.
function pg2RCount(trades) {
  let n = 0;
  for (const t of trades || []) {
    const v = t && t.r_multiple;
    if (v === null || v === undefined || v === '') continue;
    const rv = Number(v);
    if (Number.isFinite(rv) && rv !== 0) n++;
  }
  return n;
}

// Profit factor affiché : « — » sans trade ou avec uniquement des trades nuls,
// « ∞ » sans perte (Infinity conservé), sinon 2 décimales. Même règle que le
// mobile (appPFText, app.jsx).
function pg2PFText(pf, count, wins, losses) {
  if (!count || (!wins && !losses)) return '—';
  return fmtPF(pf, count);
}

// « 1 246 trades » : nombre au format français (charte §1) et accord du mot.
// Formateur partagé fmtCount (format-prefs.jsx), espace ordinaire entre le
// nombre et le mot.
function pg2Count(n, mot) {
  return fmtCount(n, mot);
}

// « 24 G · 22 P · 1 nul » (charte §3), nombres au format français (charte §1 :
// « 2 664 G · 2 336 P », jamais « 2664 G »), comme pgWinLossText (pages.jsx).
function pg2WinLoss(wins, losses, nuls) {
  const n = Number(nuls) || 0;
  return fmtNum(Number(wins) || 0, 0) + ' G · ' + fmtNum(Number(losses) || 0, 0) + ' P'
    + (n > 0 ? ' · ' + fmtNum(n, 0) + ' nul' + (n > 1 ? 's' : '') : '');
}

// Mise en page selon la largeur de la fenêtre (ordinateur) : le contenu passe
// en deux colonnes plutôt que de tronquer un montant (charte §9).
function usePg2MinWidth(px) {
  const query = '(min-width: ' + px + 'px)';
  const read = () => { try { return window.matchMedia(query).matches; } catch (e) { return true; } };
  const [ok, setOk] = React.useState(read);
  React.useEffect(() => {
    let mq;
    try { mq = window.matchMedia(query); } catch (e) { return undefined; }
    const on = () => setOk(mq.matches);
    on();
    if (mq.addEventListener) mq.addEventListener('change', on); else if (mq.addListener) mq.addListener(on);
    return () => { if (mq.removeEventListener) mq.removeEventListener('change', on); else if (mq.removeListener) mq.removeListener(on); };
  }, [query]);
  return ok;
}

// Feuille pleine largeur sous 900 px (charte §8) : useIsMobile (app.jsx).
function usePg2Mobile() {
  if (typeof useIsMobile === 'function') return useIsMobile();
  return typeof window !== 'undefined' && window.innerWidth < 900;
}

// Erreur de chargement des trades (charte §7) : ce n'est pas un compte vide.
// Les données déjà chargées restent affichées, avec ce bandeau et Réessayer.
// state.tradesError est facultatif : sans lui, rien ne s'affiche.
function Pg2LoadError({ state, style }) {
  if (!state || !state.tradesError) return null;
  return (
    <div role="alert" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', padding: '10px 14px', borderRadius: 10, background: 'var(--red-soft)', border: '1px solid var(--line)', color: 'var(--red-text)', fontSize: 13, ...style }}>
      <span>Impossible de charger tes trades. Vérifie ta connexion.</span>
      {typeof state.refreshTrades === 'function' && (
        <button type="button" onClick={() => state.refreshTrades()} className="btn tap" style={{ color: 'var(--fg)' }}>{Ico.refresh} Réessayer</button>
      )}
    </div>
  );
}

// Squelette de liste (trades, jours) : uniquement pendant le premier
// chargement (charte §7), même rendu que les squelettes des autres écrans.
function Pg2RowsSkeleton({ rows = 4, label = 'Chargement…', gap = 20, style }) {
  const bar = (w, h) => <div style={{ width: w, height: h, borderRadius: 6, background: 'var(--bg-elev)', flexShrink: 0 }}></div>;
  return (
    <div role="status" aria-busy="true" aria-label={label} style={{ display: 'flex', flexDirection: 'column', gap, padding: '8px 4px', ...style }}>
      {Array.from({ length: rows }).map((_, i) => (
        <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
          {bar('22%', 10)}{bar('14%', 12)}<div style={{ flex: 1 }}></div>{bar('16%', 14)}
        </div>
      ))}
    </div>
  );
}

// Trades non chargés (échec, aucune donnée en mémoire) : ce n'est pas un
// compte vide, on n'invite donc pas à ajouter un premier trade. Le bandeau
// Pg2LoadError, en haut de page, porte le bouton Réessayer.
function Pg2Unavailable({ text = 'Tes trades s’afficheront ici dès que la connexion sera rétablie.', style }) {
  return <EmptyState text={text} compact style={{ padding: '24px 0', ...style }}/>;
}

// Pastille d'état des marchés de l'Accueil. Source unique : sessionStatusInTz
// (format-prefs.jsx), chaque place dans son propre fuseau. Même règle que
// l'Accueil mobile (app.jsx) : New York, puis Londres, puis Tokyo.
function homeMarketPill(mk) {
  if (mk.newyork) return { text: 'Session de New York ouverte', open: true };
  if (mk.london) return { text: 'Session de Londres ouverte', open: true };
  if (mk.tokyo) return { text: 'Session de Tokyo ouverte', open: true };
  if (mk.cmeOpen) return { text: 'CME ouvert · hors sessions principales', open: true };
  return { text: 'Marchés fermés', open: false };
}

// ─── ACCUEIL ──────────────────────────────────────────────────────
function Home({ nav, navStats, openJournal, openImport, openTrade, state }) {
  const t = useLiveTime();
  // Raccourcis vers la section Statistiques (vue trading / habitudes). Inertes si navStats absent.
  const goStatsTrading = navStats ? () => navStats('trading') : (nav ? () => nav('stats') : undefined);
  const { agenda, toggleAgenda, stats, tradesLoading } = state;
  const trades = Array.isArray(state.trades) ? state.trades : [];
  const wide = usePg2MinWidth(1200);
  // Trades récents : Entrée et Sortie seulement quand la carte a la place de
  // les montrer sans tronquer le P&L — pleine largeur dès 1000 px, ou à côté
  // des raccourcis dès 1600 px.
  const roomy = usePg2MinWidth(1600);
  const mid = usePg2MinWidth(1000);
  const recentCols = (wide ? roomy : mid) ? PG2_COLS_COMPACT : PG2_COLS_HOME;

  // Horloges : l'heure du profil, puis Londres, New York et Tokyo, chacune dans
  // son fuseau, avec l'état de sa séance (sessionStatusInTz, format-prefs.jsx).
  // Si le fuseau du profil est celui d'une place (profil New York), l'horloge
  // du profil porte l'état de cette séance, qui n'est pas répétée.
  // La pastille CME reste dans l'en-tête de page.
  const tz = window.__appTz || 'Europe/Paris';
  const mk = sessionStatusInTz(tz, t);
  const heroPill = homeMarketPill(mk);
  const mine = mk.sessions.find(s => s.tz === tz);
  const clocks = [{ key: 'me', c: 'Ton heure · ' + tzLabel(tz), time: tzTimeIn(t, tz), on: mine ? mine.open : false, label: mine ? mine.label : null }].concat(
    ['london', 'newyork', 'tokyo']
      .map(k => mk.sessions.find(s => s.key === k))
      .filter(s => s && s.tz !== tz)
      .map(s => ({ key: s.key, c: s.name, time: tzTimeIn(t, s.tz), on: s.open, label: s.label }))
  );

  // Grille des horloges : toutes sur une ligne quand la largeur mesurée le
  // permet (≈ 215 px par horloge), sinon deux par ligne.
  const [clockRef, clockW] = usePgWidth();
  const clockOneRow = clockW > 0 && clockW >= clocks.length * 215 + (clocks.length - 1) * 22;
  const clockCols = clockOneRow ? `repeat(${clocks.length}, auto)` : 'repeat(2, auto)';
  // Nombre impair d'horloges sur deux colonnes (profil à Londres, New York ou
  // Tokyo : 3 horloges) : « Ton heure » occupe toute la première ligne, les
  // deux autres se partagent la seconde — jamais une horloge seule en bas.
  const clockSpanFirst = !clockOneRow && clocks.length % 2 === 1;

  // « Samedi 26 septembre · Semaine 39 », dans le fuseau du profil.
  const dateLabel = buildDateLabel();

  // Greeting name: real display name (profile) with email/Trader fallbacks (see state.displayName).
  // Prénom du profil, seulement une fois le profil chargé (pas de « Bonjour, demo »
  // tiré de l'e-mail, remplacé une seconde plus tard par le vrai prénom).
  const greeting = state && state.profileReady ? (state.displayName || '') : '';
  const n = stats.count || 0;
  const net = Number(stats.netPnl) || 0;
  const nuls = Number.isFinite(stats.breakeven) ? stats.breakeven : Math.max(0, n - (stats.wins || 0) - (stats.losses || 0));
  const rCount = React.useMemo(
    () => (Number.isFinite(stats.rCount) ? stats.rCount : pg2RCount(trades)),
    [trades, stats.rCount]
  );
  const recent = trades.slice(0, 5);
  // Premier chargement : squelettes, jamais l'état « compte vide ». Échec de
  // chargement sans données : ce n'est pas un compte vide non plus (charte §7),
  // le bandeau Pg2LoadError propose Réessayer.
  const firstLoad = !!tradesLoading && n === 0;
  const loadFailed = !tradesLoading && !!state.tradesError && n === 0;
  const noData = firstLoad || loadFailed;

  return (
    <div className="scroll" style={{ width: '100%', height: '100%', overflow: 'auto' }}>
      <PageHeader title={pg2Nav('home').titre || 'Accueil'}/>

      <div style={{ ...PAGE_BODY, padding: '24px 24px 48px' }}>
        <Pg2LoadError state={state} style={{ marginBottom: 16 }}/>
        {/* Appareil et profil à des heures différentes : bandeau partagé avec
            l'Accueil mobile (AppTzNotice, app.jsx). */}
        {typeof AppTzNotice === 'function' && <AppTzNotice state={state} style={{ marginBottom: 16 }}/>}

        {/* ── Hero — big personal card ── */}
        <div className="stagger" style={{ display: 'grid', gridTemplateColumns: wide ? 'minmax(0, 1.65fr) minmax(0, 1fr)' : 'minmax(0, 1fr)', gap: 16, marginBottom: 16 }}>
          {/* Hero left — gradient + personal greeting. .on-dark : contour de
              focus clair sur ce bandeau toujours sombre (charte §8). */}
          <div className="on-dark" style={{
            position: 'relative', overflow: 'hidden',
            background: 'linear-gradient(135deg, #0f172a 0%, #1e293b 60%, #0c4a6e 100%)',
            borderRadius: 18, padding: '32px 36px', color: '#fff',
            minHeight: 260,
          }}>
            {/* shimmer */}
            <div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 1, background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.25), transparent)', animation: 'shimmer 4s var(--ease) infinite' }}></div>
            {/* halo */}
            <div style={{ position: 'absolute', top: -80, right: -80, width: 280, height: 280, borderRadius: '50%', background: 'radial-gradient(circle, rgba(59,130,246,0.30), transparent 70%)', pointerEvents: 'none' }}></div>

            <div style={{ position: 'relative' }}>
              <div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 10, marginBottom: 14 }}>
                {heroPill.open ? (
                  <span className="pill" style={{ background: 'rgba(34,197,94,0.18)', color: '#86efac', border: '1px solid rgba(34,197,94,0.35)' }}>
                    <span style={{ width: 5, height: 5, borderRadius: '50%', background: '#22c55e', animation: 'pulse-dot 2s var(--ease) infinite' }}></span>
                    {heroPill.text}
                  </span>
                ) : (
                  <span className="pill" style={{ background: 'rgba(255,255,255,0.10)', color: 'rgba(255,255,255,0.78)', border: '1px solid rgba(255,255,255,0.18)' }}>
                    <span style={{ width: 5, height: 5, borderRadius: '50%', background: 'rgba(255,255,255,0.4)' }}></span>
                    {heroPill.text}
                  </span>
                )}
                <span style={{ fontSize: 11.5, color: 'rgba(255,255,255,0.72)', letterSpacing: '-.005em', whiteSpace: 'nowrap' }}>{dateLabel}</span>
              </div>

              <div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', flexWrap: 'wrap', gap: 24 }}>
                <div style={{ minWidth: 0, flex: '1 1 260px' }}>
                  <h1 style={{ fontSize: 38, fontWeight: 700, letterSpacing: '-.025em', margin: 0, lineHeight: 1.05 }}>Bonjour{greeting ? ', ' + greeting : ''}</h1>
                  <p style={{ marginTop: 12, marginBottom: 0, fontSize: 14, color: 'rgba(255,255,255,0.78)', lineHeight: 1.55, maxWidth: 460 }}>
                    {firstLoad
                      ? <>Chargement de ton journal…</>
                      : loadFailed
                        ? <>Ton journal n’a pas pu être chargé pour le moment.</>
                        : n === 0
                          ? <>Tempo est prêt. Ajoute ton premier trade ou importe un fichier de ton courtier pour démarrer ton journal.</>
                          : <>Tu as enregistré <b style={{ color: '#fff' }}>{pg2Count(n, 'trade')}</b> · <span style={{ whiteSpace: 'nowrap' }}>taux de réussite {fmtPct(stats.winRate, 1)}</span>{rCount > 0 && <> · <span style={{ whiteSpace: 'nowrap' }}>R moyen {fmtR(stats.avgR)}</span></>}</>}
                  </p>
                </div>
                <div
                  role={goStatsTrading ? 'button' : undefined}
                  tabIndex={goStatsTrading ? 0 : undefined}
                  onClick={goStatsTrading}
                  onKeyDown={goStatsTrading ? (e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); goStatsTrading(); } }) : undefined}
                  className={goStatsTrading ? 'tap' : ''}
                  title={goStatsTrading ? 'Voir les statistiques détaillées' : undefined}
                  style={{ flexShrink: 0, textAlign: 'right', cursor: goStatsTrading ? 'pointer' : 'default', borderRadius: 8 }}>
                  <span style={{ fontSize: 11, letterSpacing: '.08em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.72)', fontWeight: 500 }}>P&amp;L net</span>
                  <div className="num" style={{ fontSize: 34, fontWeight: 700, letterSpacing: '-.025em', color: n === 0 || net === 0 ? '#fff' : net > 0 ? '#86efac' : '#fda4af', marginTop: 8, lineHeight: 1, whiteSpace: 'nowrap' }}>
                    {n === 0 ? '—' : fmtMoney(net, true)}
                  </div>
                </div>
              </div>

              {/* Horloges et séances : grille régulière (4 sur une ligne si la place le
                  permet, sinon 2 + 2), jamais une horloge seule sur sa ligne. */}
              <div ref={clockRef} style={{ display: 'grid', gridTemplateColumns: clockCols, justifyContent: 'start', columnGap: 22, rowGap: 10, marginTop: 24, paddingTop: 20, borderTop: '1px solid rgba(255,255,255,0.10)' }}>
                {clocks.map((s, i) => (
                  <div key={s.key} style={{ display: 'flex', alignItems: 'center', gap: 8, whiteSpace: 'nowrap', gridColumn: clockSpanFirst && i === 0 ? '1 / -1' : undefined }}>
                    <span style={{ fontSize: 10.5, fontWeight: 500, letterSpacing: '.08em', color: 'rgba(255,255,255,0.72)', textTransform: 'uppercase' }}>{s.c}</span>
                    <span className="mono" style={{ fontSize: 13, fontWeight: 500, color: '#fff' }}>{s.time}</span>
                    {s.label && (
                      <React.Fragment>
                        <span aria-hidden="true" style={{ width: 6, height: 6, borderRadius: '50%', background: s.on ? '#22c55e' : 'rgba(248,113,113,0.7)' }}></span>
                        <span style={{ fontSize: 10.5, fontWeight: 500, color: s.on ? '#86efac' : 'rgba(255,255,255,0.72)' }}>{s.label}</span>
                      </React.Fragment>
                    )}
                  </div>
                ))}
              </div>
            </div>
          </div>

          {/* Vérifications avant trade — même carte que sur mobile (HomePretradeCard, plus bas) */}
          <HomePretradeCard state={state} nav={nav} openImport={openImport}/>
        </div>

        {/* ── 4 indicateurs : quatre colonnes, ou 2 × 2 quand la place manque ── */}
        <div className="stagger" style={{ display: 'grid', gridTemplateColumns: wide ? 'repeat(4, minmax(0, 1fr))' : 'repeat(2, minmax(0, 1fr))', gap: 14, marginBottom: 16 }}>
          <QuickStat label="P&L net" value={net} isMoney pos={n > 0 && net > 0} neg={n > 0 && net < 0}
            loading={firstLoad}
            display={n ? undefined : '—'}
            sub={n ? pg2Count(n, 'trade') : (noData ? null : 'aucun trade')}/>
          <QuickStat label="Taux de réussite" value={stats.winRate} decimals={1} suffix="%"
            loading={firstLoad}
            display={n ? undefined : '—'}
            sub={n ? pg2WinLoss(stats.wins, stats.losses, nuls) : (noData ? null : 'aucun trade')}/>
          <QuickStat label="R moyen" value={stats.avgR} suffix="R" pos={rCount > 0 && stats.avgR > 0} neg={rCount > 0 && stats.avgR < 0}
            loading={firstLoad}
            display={n && rCount ? undefined : '—'}
            sub={!n ? (noData ? null : 'aucun trade') : rCount ? pg2Count(rCount, 'trade') + ' en R' : 'R non renseigné'}/>
          <QuickStat label="Profit factor" value={stats.profitFactor} decimals={2}
            loading={firstLoad}
            display={n && (stats.wins || stats.losses) && Number.isFinite(stats.profitFactor) ? undefined : pg2PFText(stats.profitFactor, n, stats.wins, stats.losses)}
            sub={n ? 'sur tous les trades' : (noData ? null : 'aucun trade')}/>
        </div>

        {/* ── Agenda du jour + Habitudes + Actualités ── */}
        <div className="stagger" style={{ display: 'grid', gridTemplateColumns: wide ? 'minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1.1fr)' : 'repeat(2, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>

          {/* Agenda live */}
          <div className="card lift" style={{ padding: '22px 22px', minWidth: 0 }}>
            <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 14 }}>
              <CardTitle style={{ fontSize: 14 }}>Agenda du jour</CardTitle>
              <button type="button" onClick={() => nav('routines')} className="tap" style={{ background: 'transparent', border: 'none', color: 'var(--blue-600)', fontSize: 11.5, fontWeight: 500, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 4, fontFamily: 'inherit' }}>Modifier{Ico.chev}</button>
            </div>
            {/* Agenda non lu : le message, jamais « Aucun événement aujourd’hui » ;
                les événements déjà chargés restent affichés sous le message.
                Agenda pas encore lu (state.agendaLoaded faux) : un squelette,
                la phrase « Aucun événement » n'est dite qu'une fois la réponse
                arrivée (charte §7). */}
            <HomeAgendaError state={state} style={{ marginBottom: agenda.length ? 10 : 0 }}/>
            {agenda.length > 0 ? (
              <AgendaTimeline now={t} agenda={agenda} toggleAgenda={toggleAgenda} onLostError={state.notifyWriteError}/>
            ) : state.agendaError ? null : state.agendaLoaded === false ? (
              <HomeHabitSkeleton label="Chargement de ton agenda"/>
            ) : (
              <div style={{ fontSize: 12, color: 'var(--fg-3)', padding: '10px 0' }}>Aucun événement aujourd’hui — ajoute-en depuis Routines.</div>
            )}
          </div>

          {/* Habitudes — même carte que sur mobile (HomeHabitsCard, plus bas) */}
          <HomeHabitsCard state={state} nav={nav} navStats={navStats}/>

          {/* Actualités — flux RSS en direct (carte commune ordinateur / mobile, news.jsx) */}
          <div className="card lift" style={{ padding: '22px 22px', minWidth: 0, gridColumn: wide ? 'auto' : '1 / -1' }}>
            <NewsBriefing/>
          </div>
        </div>

        {/* ── Trades récents + raccourcis ── */}
        <div className="stagger" style={{ display: 'grid', gridTemplateColumns: wide ? 'minmax(0, 1fr) 320px' : 'minmax(0, 1fr)', gap: 16 }}>
          <div className="card" style={{ padding: '20px 22px', minWidth: 0 }}>
            <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 12, marginBottom: 14 }}>
              <div>
                <CardTitle style={{ fontSize: 14 }}>Trades récents</CardTitle>
                {recent.length > 0 && <div style={{ fontSize: 11.5, color: 'var(--fg-3)', marginTop: 4 }}>Clique sur une ligne pour ouvrir le détail</div>}
              </div>
              <button type="button" onClick={() => nav('trades')} className="btn tap" style={{ color: 'var(--fg)' }}>
                Tous les trades <span style={{ color: 'var(--fg-3)' }}>{Ico.chev}</span>
              </button>
            </div>
            {firstLoad ? (
              <Pg2RowsSkeleton rows={5} label="Chargement de tes derniers trades"/>
            ) : loadFailed ? (
              <Pg2Unavailable/>
            ) : recent.length === 0 ? (
              <EmptyState icon={Ico.trades} title={EMPTY_TRADES_TITLE} text={EMPTY_TRADES_TEXT} compact/>
            ) : (
              <TradeTable trades={recent} columns={recentCols} openTrade={openTrade} openJournal={openJournal}
                widths={recentCols === PG2_COLS_HOME ? PG2_WIDTHS_HOME : undefined}/>
            )}
          </div>

          <div style={{ display: 'grid', gridTemplateColumns: wide ? 'minmax(0, 1fr)' : 'repeat(2, minmax(0, 1fr))', gap: 14, alignContent: 'start' }}>
            {/* Raccourci de navigation : la période par défaut (« Ce mois »), comme le menu. */}
            <NavTile nav={nav} to="stats" onClick={navStats ? () => navStats('trading', 'month') : undefined}/>
            <NavTile nav={nav} to="dashboard"/>
            <NavTile nav={nav} to="daily"/>
            <NavTile nav={nav} to="strategy"/>
          </div>
        </div>

      </div>
    </div>
  );
}

// ─── Accueil : cartes « Vérifications avant trade » et « Habitudes » ───
// Définies UNE fois ici, utilisées telles quelles par Home (ordinateur) et par
// HomeMobile (app.jsx, chargé après) : `compact` ne change que les dimensions.
// Aucun calcul de série, de taux ni de date dans ce fichier : tout vient de
// useAppState et du moteur de design/state.jsx (docs/spec-habitudes.md §5, §8.3, §8.4).
const HOME_NBSP = ' ';

// Coche du jour depuis l'Accueil. toggleHabit (state.jsx) est optimiste et
// rétablit la case en cas d'échec : ici on ne garde que l'écriture en cours
// (aria-busy) et le message à afficher sous la ligne.
function useHomeHabitToggle(state) {
  const [busy, setBusy] = React.useState({});
  const [fail, setFail] = React.useState(null); // { id, text }
  const pending = React.useRef({});
  const alive = React.useRef(true);
  React.useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
  const toggle = async (id) => {
    if (pending.current[id] || typeof state.toggleHabit !== 'function') return;
    pending.current[id] = true;
    setFail(null);
    setBusy(b => ({ ...b, [id]: true }));
    let res = null;
    try { res = await state.toggleHabit(id); } catch (e) { res = { error: e }; }
    delete pending.current[id];
    if (!alive.current) {
      // Accueil quitté pendant l'envoi : l'échec part vers l'avis commun de
      // l'application (state.notifyWriteError), en nommant l'habitude.
      if (res && res.error) {
        const row = (state.habitRows || []).find(h => h.id === id);
        const text = 'La coche de «' + HOME_NBSP + (row && row.name ? row.name : 'cette habitude') + HOME_NBSP + '» n’a pas été enregistrée. ' + sbErrorMessage(res.error);
        if (typeof state.notifyWriteError === 'function') state.notifyWriteError(text);
        else console.warn('[Tempo] ' + text);
      }
      return;
    }
    setBusy(b => { const n = { ...b }; delete n[id]; return n; });
    if (res && res.error) setFail({ id, text: sbErrorMessage(res.error) });
  };
  return { busy, fail, toggle };
}

// Une ligne à cocher : toute la ligne est cliquable, le rôle et le clavier sont
// portés par la case (HabitCheck). Nom barré une fois coché, comme l'agenda.
// La case dessinée s'aligne sur le bord de la carte : sa zone de clic (32 px,
// 44 px sur mobile) déborde dans la marge.
function HomeHabitRow({ item, hook, compact, right, divider }) {
  const done = !!item.d;
  const fail = hook.fail && hook.fail.id === item.id ? hook.fail.text : null;
  return (
    <div data-habit-row={item.id} style={{ borderBottom: divider ? '1px solid var(--line)' : 'none' }}>
      <div onClick={() => hook.toggle(item.id)} className="tap" style={{
        display: 'flex', alignItems: 'center', gap: compact ? 0 : 6, marginLeft: compact ? -12 : -6,
        minHeight: compact ? 44 : 36, cursor: 'pointer',
      }}>
        <HabitCheck checked={done} busy={!!hook.busy[item.id]} onToggle={() => hook.toggle(item.id)} label={item.t}/>
        <span style={{
          flex: 1, minWidth: 0, padding: '4px 0', fontSize: 13, lineHeight: 1.35, letterSpacing: '-.005em', overflowWrap: 'anywhere',
          color: done ? 'var(--fg-3)' : 'var(--fg)', textDecoration: done ? 'line-through' : 'none',
        }}>{item.t}</span>
        {right}
      </div>
      {fail && <div role="status" style={{ fontSize: 11.5, lineHeight: 1.4, color: 'var(--red-text)', padding: '0 0 8px' }}>{fail}</div>}
    </div>
  );
}

// Pastille d'avancement : « 1 sur 2 », ou verte avec coche quand tout est fait.
// Rien à compter : pas de pastille.
function HomeHabitPill({ done, total, allText }) {
  if (!total) return null;
  const base = { padding: '3px 9px', fontSize: 11.5, whiteSpace: 'nowrap', flexShrink: 0 };
  if (done >= total) return <span className="pill pill-green" data-habit-pill="all" style={base}>{Ico.check}{allText}</span>;
  return <span className="pill pill-gray num" data-habit-pill="part" style={base}>{done} sur {total}</span>;
}

// Trois lignes de squelette, uniquement pendant le premier chargement.
function HomeHabitSkeleton({ label }) {
  const bar = (w, h, r) => <span style={{ display: 'block', width: w, height: h, borderRadius: r, background: 'var(--bg-elev)', flexShrink: 0 }}></span>;
  return (
    <div role="status" aria-busy="true" aria-label={label} style={{ display: 'flex', flexDirection: 'column', gap: 16, padding: '8px 0' }}>
      {['62%', '78%', '48%'].map((w, i) => (
        <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>{bar(20, 20, 6)}{bar(w, 10, 6)}</div>
      ))}
    </div>
  );
}

// Lien d'en-tête ou de ligne (« Modifier › »). Sur mobile, la zone tactile fait 44 px.
function HomeCardLink({ onClick, compact, children }) {
  return (
    <button type="button" onClick={onClick} className="tap" style={{
      display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0, whiteSpace: 'nowrap',
      background: 'transparent', border: 'none', fontFamily: 'inherit', cursor: 'pointer',
      color: 'var(--blue-600)', fontSize: 11.5, fontWeight: 500,
      padding: compact ? '15px 0' : '2px 0', margin: compact ? '-15px 0' : '-2px 0', minHeight: compact ? 44 : undefined, minWidth: compact ? 44 : undefined, justifyContent: compact ? 'flex-end' : undefined,
    }}>{children}{compact ? HOME_NBSP + '›' : Ico.chev}</button>
  );
}

const HOME_CARD_TITLE = { fontSize: 14, fontWeight: 600, letterSpacing: '-.01em' };

// Carte « Vérifications avant trade » (§8.3). Elle informe, elle ne bloque
// pas : « Ajouter un trade » reste toujours actif.
function HomePretradeCard({ state, nav, openImport, compact = false }) {
  const hook = useHomeHabitToggle(state);
  const items = Array.isArray(state.pretradeItems) ? state.pretradeItems : [];
  const done = items.filter(p => p.d).length;
  const left = items.length - done;
  const dayTrades = state.tradesByDay && state.today ? state.tradesByDay[state.today] : null;
  const tradesToday = dayTrades && Array.isArray(dayTrades.trades) ? dayTrades.trades.length : 0;
  const failed = !!state.habitsError;
  const loading = !failed && !!state.habitsLoading && items.length === 0;
  const empty = !failed && !loading && items.length === 0;
  return (
    <div className={'card' + (compact ? '' : ' lift')} data-habit-card="pretrade"
      style={{ padding: compact ? '16px 18px' : '22px 22px', minWidth: 0, display: 'flex', flexDirection: 'column' }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10, minHeight: 24, marginBottom: compact ? 8 : 10 }}>
        <CardTitle style={HOME_CARD_TITLE}>Vérifications avant trade</CardTitle>
        {!loading && <HomeHabitPill done={done} total={items.length} allText="Tout est vérifié"/>}
      </div>
      {failed && (
        <div style={{ marginBottom: items.length ? 10 : 0 }}>
          <HabitLoadError onRetry={state.reloadHabits}/>
        </div>
      )}
      {loading && <HomeHabitSkeleton label="Chargement de tes vérifications"/>}
      {empty && (
        <div style={{ padding: '2px 0 4px' }}>
          <p style={{ fontSize: 12.5, lineHeight: 1.5, color: 'var(--fg-2)', margin: '0 0 12px' }}>Ajoute les points à contrôler avant chaque trade{HOME_NBSP}: stop placé, risque, annonces…</p>
          <button type="button" onClick={() => nav && nav('routines')} className="btn tap" style={{ color: 'var(--fg)' }}>Ajouter mes vérifications</button>
        </div>
      )}
      {items.length > 0 && (
        <React.Fragment>
          <p style={{ fontSize: 12, lineHeight: 1.45, color: 'var(--fg-2)', margin: '0 0 8px' }}>À valider avant ta première position du jour.</p>
          {tradesToday > 0 && left > 0 && (
            <div role="status" data-habit-alert style={{
              fontSize: 12, lineHeight: 1.45, color: 'var(--amber-text)', background: 'var(--amber-soft)',
              borderRadius: 8, padding: '8px 10px', marginBottom: 6,
            }}>
              Tu as déjà {tradesToday}{HOME_NBSP}trade{tradesToday > 1 ? 's' : ''} aujourd’hui et il reste {left}{HOME_NBSP}vérification{left > 1 ? 's' : ''}.
            </div>
          )}
          <div>
            {items.map(p => <HomeHabitRow key={p.id} item={p} hook={hook} compact={compact} divider/>)}
          </div>
        </React.Fragment>
      )}
      <button type="button" onClick={() => openImport && openImport(null, 'manual')} className="btn btn-blue tap"
        style={{ marginTop: 14, width: '100%', minHeight: compact ? 44 : undefined, justifyContent: 'center', color: '#fff' }}>
        {Ico.plus} Ajouter un trade
      </button>
    </div>
  );
}

// Pied de la carte « Habitudes » : la semaine en cours, de lundi à dimanche.
// Chaque jour terminé porte sa fraction (faites/prévues) et la couleur de son
// niveau ; aujourd'hui est cerclé de bleu avec son compte du moment (discret :
// il n'entre dans aucun taux) ; les jours à venir sont en pointillés.
// Mêmes lignes et mêmes règles que Statistiques › Habitudes › Cette semaine,
// que le clic ouvre : habitudes ET vérifications avant trade. Ce périmètre est
// écrit sous le titre, car le haut de la carte ne liste que les habitudes.
function HomeHabitWeek({ state, navStats }) {
  const { theme } = useTheme();
  const today = state.today;
  const ctx = state.habitCtx;
  const rows = Array.isArray(state.habitRows) ? state.habitRows : [];
  // Jours tradés inconnus : les vérifications ne sont pas comptées. En cours de
  // chargement, la bande attend ; en échec, elle dit ce qu'elle exclut.
  const tradesState = state.habitTradesState || 'ready';
  const withChecks = rows.some(h => h.section === 'pretrade' && (!h.archived || h.archivedDay));
  const ready = !!state.habitHistoryReady && !(withChecks && tradesState === 'loading');
  const scope = !withChecks ? ''
    : tradesState === 'error' ? 'Habitudes seulement' + HOME_NBSP + ': tes trades ne sont pas chargés, tes vérifications ne sont pas comptées.'
    : 'Habitudes et vérifications avant trade, faites sur prévues.';
  const week = React.useMemo(() => {
    if (!today || !ctx) return null;
    const start = habitWeekStart(today);
    const days = [];
    for (let i = 0; i < 7; i++) {
      const iso = habitShiftDay(start, i);
      days.push({ iso, tally: habitDayTally(rows, iso, ctx) });
    }
    return { days, stats: habitRangeStats(rows, start, habitShiftDay(today, -1), ctx) };
  }, [rows, ctx, today]);
  if (!week) return null;
  const rateText = week.stats.rate === null ? '—' : fmtPct(week.stats.rate, 0);
  const open = typeof navStats === 'function' ? () => navStats('habits', 'week') : null;
  const body = (
    <React.Fragment>
      <span style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10, marginBottom: scope ? 2 : 8 }}>
        <span style={{ fontSize: 11.5, fontWeight: 500, color: 'var(--fg-2)' }}>Semaine en cours</span>
        <span title="Régularité de la semaine, de lundi à hier."
          style={{ fontSize: 11.5, color: 'var(--fg-2)', whiteSpace: 'nowrap' }}>
          Régularité{HOME_NBSP}
          {ready
            ? <b className="num" data-habit-week-rate style={{ fontWeight: 600, color: 'var(--fg)' }}>{rateText}</b>
            : <span aria-hidden="true" style={{ display: 'inline-block', width: 28, height: 9, borderRadius: 5, background: 'var(--bg-elev)' }}></span>}
        </span>
      </span>
      {scope && <span data-habit-week-scope style={{ display: 'block', fontSize: 11, lineHeight: 1.4, color: 'var(--fg-3)', marginBottom: 8 }}>{scope}</span>}
      <span style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', gap: 4 }}>
        {week.days.map((d, i) => {
          const t = d.tally;
          const isToday = t.state === 'today';
          const dayName = fmtDayLong(d.iso);
          let look, text = '', tip, ink;
          if (t.state === 'future') {
            look = { background: 'transparent', border: '1px dashed var(--line-2)' };
            tip = dayName + ' · à venir';
          } else if (isToday) {
            look = { background: 'var(--bg-card)', border: '1.5px solid var(--blue)', boxShadow: '0 0 0 2px rgba(59,130,246,.16)' };
            // Compte du moment, comme la case du jour des Statistiques (« 3/3 · en cours »).
            if (t.planned > 0) { text = t.done + '/' + t.planned; ink = 'var(--fg-2)'; }
            tip = dayName + ' · aujourd’hui, en cours · ' + (t.planned > 0 ? t.done + ' sur ' + t.planned : 'rien de prévu');
          } else if (!ready) {
            look = { background: 'var(--bg-elev)', border: '1px solid transparent' };
            tip = dayName;
          } else if (t.state === 'counted') {
            look = habitLevelStyle(t.level, theme);
            text = t.done + '/' + t.planned;
            tip = dayName + ' · ' + t.done + ' sur ' + t.planned + ' · ' + fmtPct((t.done / t.planned) * 100, 0);
          } else {
            look = habitLevelStyle(null, theme);
            tip = dayName + ' · rien de prévu';
          }
          return (
            <span key={d.iso} data-day={d.iso} data-state={!ready && !isToday && t.state !== 'future' ? 'loading' : t.state}
              data-level={t.level !== null && t.level !== undefined && ready ? t.level : undefined} title={tip}
              style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 5, minWidth: 0 }}>
              <span style={{ fontSize: 10, lineHeight: 1, fontWeight: isToday ? 700 : 500, color: isToday ? 'var(--blue-600)' : 'var(--fg-3)' }}>{FR_WEEKDAYS_2[i]}</span>
              <span className="num" style={{
                display: 'flex', alignItems: 'center', justifyContent: 'center', boxSizing: 'border-box',
                width: '100%', height: 26, borderRadius: 6, fontSize: 10, fontWeight: 600, lineHeight: 1, whiteSpace: 'nowrap',
                ...look, ...(ink ? { color: ink } : null),
              }}>{text}</span>
            </span>
          );
        })}
      </span>
    </React.Fragment>
  );
  const wrap = { display: 'block', width: '100%', marginTop: 14, padding: '12px 0 0', borderTop: '1px solid var(--line)' };
  if (!open) return <div data-habit-week style={wrap}>{body}</div>;
  return (
    <button type="button" data-habit-week onClick={open} className="tap"
      aria-label={'Semaine en cours' + (withChecks && tradesState !== 'error' ? ', habitudes et vérifications avant trade' : '') + HOME_NBSP + ': régularité ' + (ready ? rateText : '—') + '. Ouvrir les statistiques de la semaine.'}
      style={{ ...wrap, background: 'transparent', border: 'none', borderTop: wrap.borderTop, borderRadius: 0, textAlign: 'left', color: 'var(--fg)', fontFamily: 'inherit', cursor: 'pointer' }}>
      {body}
    </button>
  );
}

// Carte « Habitudes » (§8.4) : ce qui est prévu aujourd'hui, à cocher, puis la
// semaine en cours.
function HomeHabitsCard({ state, nav, navStats, compact = false }) {
  const hook = useHomeHabitToggle(state);
  const groups = Array.isArray(state.habits) ? state.habits : [];
  const total = groups.reduce((s, g) => s + g.items.length, 0);
  const dueGroups = groups.map(g => ({ g: g.g, sec: g.sec, items: g.items.filter(it => it.due) })).filter(g => g.items.length > 0);
  const due = dueGroups.reduce((s, g) => s + g.items.length, 0);
  const done = dueGroups.reduce((s, g) => s + g.items.filter(it => it.d).length, 0);
  const failed = !!state.habitsError;
  const loading = !failed && !!state.habitsLoading && total === 0;
  const empty = !failed && !loading && total === 0;
  const ready = !!state.habitHistoryReady;
  const today = state.today;
  const ctx = state.habitCtx;
  const rows = Array.isArray(state.habitRows) ? state.habitRows : [];
  const goRoutines = () => nav && nav('routines');

  // Oublis d'hier : même périmètre que la case d'hier de la bande « Semaine en
  // cours » juste en dessous (habitudes et vérifications avant trade), pour que
  // les deux chiffres se répondent (« 2 non cochées sur 6 » et « 4/6 »).
  // Prochain jour prévu : habitudes de cette carte (Matin, Journée, Soir, actives).
  const extra = React.useMemo(() => {
    const out = { missed: 0, planned: 0, next: null };
    if (!today || !ctx) return out;
    const y = habitDayTally(rows, habitShiftDay(today, -1), ctx);
    if (y.state === 'counted') { out.missed = y.planned - y.done; out.planned = y.planned; }
    const own = rows.filter(h => !h.archived && h.section !== 'pretrade');
    if (own.length === 0) return out;
    for (let n = 1; n <= 7; n++) {
      const iso = habitShiftDay(today, n);
      if (own.some(h => habitIsDue(h, iso, ctx))) { out.next = { iso, n }; break; }
    }
    return out;
  }, [rows, ctx, today]);
  const tradesPending = state.habitTradesState === 'loading' && rows.some(h => h.section === 'pretrade' && (!h.archived || h.archivedDay));
  const missed = ready && !tradesPending ? extra.missed : 0;
  const weekdayLong = (iso) => FR_WEEKDAYS_LONG[FR_WEEKDAYS_SHORT.indexOf(fmtWeekdayShort(iso))] || '';
  const showLinks = total > 0;

  return (
    <div className={'card' + (compact ? '' : ' lift')} data-habit-card="habits" style={{ padding: compact ? '16px 18px' : '22px 22px', minWidth: 0 }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10, marginBottom: compact ? 12 : 14 }}>
        <CardTitle style={HOME_CARD_TITLE}>Habitudes</CardTitle>
        {showLinks && (
          <div style={{ display: 'inline-flex', alignItems: 'center', gap: compact ? 14 : 12 }}>
            {typeof navStats === 'function' && <HomeCardLink onClick={() => navStats('habits')} compact={compact}>Statistiques</HomeCardLink>}
            <HomeCardLink onClick={goRoutines} compact={compact}>Modifier</HomeCardLink>
          </div>
        )}
      </div>
      {failed && (
        <div style={{ marginBottom: total ? 12 : 0 }}>
          <HabitLoadError onRetry={state.reloadHabits}/>
        </div>
      )}
      {loading && <HomeHabitSkeleton label="Chargement de tes habitudes"/>}
      {empty && (
        <div style={{ padding: '2px 0 4px' }}>
          <p style={{ fontSize: 12.5, lineHeight: 1.5, color: 'var(--fg-2)', margin: '0 0 12px' }}>Choisis 2 ou 3 gestes à cocher chaque jour.</p>
          <button type="button" onClick={goRoutines} className="btn tap" style={{ color: 'var(--fg)' }}>Créer mes habitudes</button>
        </div>
      )}
      {total > 0 && (
        <React.Fragment>
          {due > 0 ? (
            <React.Fragment>
              <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
                <HomeHabitPill done={done} total={due} allText="Tout est fait"/>
                <div role="progressbar" aria-label="Habitudes faites aujourd’hui" aria-valuemin={0} aria-valuemax={due} aria-valuenow={done}
                  aria-valuetext={done + ' sur ' + due}
                  style={{ flex: 1, minWidth: 40, height: 4, borderRadius: 2, background: 'var(--bg-elev)', overflow: 'hidden' }}>
                  <div style={{ width: (done / due) * 100 + '%', height: '100%', borderRadius: 2, background: 'var(--green)', transition: 'width .25s var(--ease)' }}></div>
                </div>
              </div>
              {dueGroups.map((g, gi) => (
                <div key={g.sec || g.g} style={{ marginTop: gi > 0 ? 10 : 0 }}>
                  <div style={{ fontSize: 10, fontWeight: 600, letterSpacing: '.08em', color: 'var(--fg-3)', textTransform: 'uppercase', marginBottom: 2 }}>{g.g}</div>
                  {g.items.map(it => (
                    <HomeHabitRow key={it.id} item={it} hook={hook} compact={compact}
                      right={ready && it.streak >= 2 ? (
                        <span className="num" data-habit-streak={it.streak}
                          title={'Série en cours' + HOME_NBSP + ': ' + it.streak + HOME_NBSP + 'jours · record ' + it.best + HOME_NBSP + 'jour' + (it.best > 1 ? 's' : '')}
                          style={{ fontSize: 11, color: 'var(--fg-3)', whiteSpace: 'nowrap', flexShrink: 0, paddingLeft: 6 }}>série{HOME_NBSP}{it.streak}{HOME_NBSP}j</span>
                      ) : null}/>
                  ))}
                </div>
              ))}
            </React.Fragment>
          ) : (
            <p data-habit-rest style={{ fontSize: 12.5, lineHeight: 1.5, color: 'var(--fg-2)', margin: '0 0 2px' }}>
              Rien de prévu aujourd’hui ({weekdayLong(today)}).
              {extra.next ? ' Tes habitudes reprennent ' + (extra.next.n === 1 ? 'demain' : weekdayLong(extra.next.iso)) + '.' : ''}
            </p>
          )}
          {missed > 0 && (
            <div data-habit-yesterday style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10, marginTop: 10, fontSize: 12, lineHeight: 1.45, color: 'var(--fg-2)' }}>
              <span className="num" style={{ minWidth: 0, textWrap: 'balance' }}>Hier{HOME_NBSP}: {missed}{HOME_NBSP}{missed > 1 ? 'non cochées' : 'non cochée'} sur{HOME_NBSP}{extra.planned}.</span>
              <HomeCardLink onClick={goRoutines} compact={compact}>Compléter</HomeCardLink>
            </div>
          )}
          <HomeHabitWeek state={state} navStats={navStats}/>
        </React.Fragment>
      )}
    </div>
  );
}

// Carte d'indicateur (Accueil ordinateur et mobile). La couleur vient
// uniquement du conteneur (vert pour un gain, rouge pour une perte, var(--fg)
// sinon) : les compteurs animés en héritent. Formats : AnimatedMoney finit sur
// fmtMoney ; AnimatedNumber avec suffix « % » → fmtPct, « R » → fmtR.
// loading (facultatif) : squelette pendant le premier chargement.
function QuickStat({ label, value, isMoney, pos, neg, decimals, suffix, sub, display, loading }) {
  return (
    <div className="card lift" style={{ padding: '18px 20px', minWidth: 0 }} aria-busy={loading ? 'true' : undefined}>
      <div style={{ fontSize: 12, color: 'var(--fg-2)', marginBottom: 12, fontWeight: 500 }}>{label}</div>
      <div style={{ fontSize: 26, fontWeight: 700, letterSpacing: '-.022em', color: loading ? 'var(--fg)' : pos ? 'var(--green)' : (neg ? 'var(--red)' : 'var(--fg)'), lineHeight: 1, whiteSpace: 'nowrap' }}>
        {loading
          ? <span aria-hidden="true" style={{ display: 'inline-block', width: '62%', maxWidth: 140, height: 24, borderRadius: 6, background: 'var(--bg-elev)', verticalAlign: 'top' }}></span>
          : display != null
            ? <span className="num">{display}</span>
            : isMoney
              ? <AnimatedMoney value={value} size={26} weight={700}/>
              : <AnimatedNumber value={value} decimals={decimals || 0} suffix={suffix} size={26} weight={700}/>}
      </div>
      {sub && <div style={{ fontSize: 11.5, color: 'var(--fg-3)', marginTop: 10 }}>{sub}</div>}
    </div>
  );
}

// Agenda du jour non lu (state.agendaError) : même message et même bouton que
// la carte Agenda de Routines (charte §7 : une lecture en échec n'est pas un
// agenda vide). Carte « Agenda du jour » de l'Accueil, ordinateur et mobile.
function HomeAgendaError({ state, style }) {
  const mobile = usePg2Mobile();
  if (!state || !state.agendaError) return null;
  return (
    <div role="alert" data-agenda-error style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 10, padding: '7px 10px', borderRadius: 8, background: 'var(--red-soft)', border: '1px solid var(--line)', color: 'var(--red-text)', fontSize: 12, ...style }}>
      <span style={{ flex: '1 1 160px', minWidth: 0 }}>Impossible de charger ton agenda. Vérifie ta connexion.</span>
      {typeof state.reloadAgenda === 'function' && (
        <button type="button" onClick={() => state.reloadAgenda()} className="btn tap" style={{ padding: mobile ? '0 12px' : '4px 10px', minHeight: mobile ? 44 : 0, fontSize: 12, color: 'var(--fg)' }}>Réessayer</button>
      )}
    </div>
  );
}

// Agenda du jour de l'Accueil (ordinateur et mobile). toggleAgenda (state.jsx)
// est optimiste et rétablit la case en cas d'échec : le message est affiché ici,
// seul point commun aux deux écrans (charte §7 : un échec n'est jamais silencieux).
function AgendaTimeline({ now, agenda, toggleAgenda, onLostError }) {
  const events = agenda || [];
  const [fail, setFail] = React.useState('');
  const alive = React.useRef(true);
  React.useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
  const toggle = async (ev) => {
    if (typeof toggleAgenda !== 'function') return;
    setFail('');
    let res = null;
    try { res = await toggleAgenda(ev.id); } catch (e) { res = { error: e }; }
    if (!(res && res.error)) return;
    if (!alive.current) {
      // Accueil quitté pendant l'envoi : avis commun de l'application.
      if (typeof onLostError === 'function') onLostError('La coche de l’événement «' + HOME_NBSP + (ev.e || 'Événement') + HOME_NBSP + '» n’a pas été enregistrée. ' + sbErrorMessage(res.error));
      return;
    }
    setFail('Impossible d’enregistrer «' + HOME_NBSP + (ev.e || 'Événement') + HOME_NBSP + '». ' + sbErrorMessage(res.error));
  };
  // Heure courante dans le fuseau du profil, comme les heures saisies dans
  // l'agenda (le prop now ne sert qu'au rafraîchissement).
  const [nh, nm] = tzNow().split(':').map(Number);
  const nowMin = nh * 60 + nm;
  return (
    <div style={{ display: 'flex', flexDirection: 'column' }}>
      {events.map((e, i) => {
        // Heure « HH:MM » saisie dans le fuseau du profil ; illisible → pas de surbrillance.
        const [h, m] = String(e.h || '').split(':').map(Number);
        const eventMin = h * 60 + m;
        const isActive = Number.isFinite(eventMin) && Math.abs(eventMin - nowMin) <= 30 && !e.done;
        return (
          <div key={e.id} className="agenda-row" style={{ display: 'flex', alignItems: 'flex-start', gap: 14, padding: '8px 0' }}>
            <span className="mono" style={{ fontSize: 11.5, color: e.done ? 'var(--fg-3)' : 'var(--fg-2)', fontWeight: 500, minWidth: 38, paddingTop: 2 }}>{e.h || '—'}</span>
            <div style={{ width: 10, display: 'flex', flexDirection: 'column', alignItems: 'center', paddingTop: 2 }}>
              {/* Zone de clic : 20 px autour du point, 44 px sous 900 px de large
                  (.agenda-hit, index.html) ; les marges négatives la laissent
                  sans effet sur la mise en page. */}
              <button type="button" onClick={() => toggle(e)} className="tap agenda-hit"
                role="checkbox" aria-checked={!!e.done}
                aria-label={(e.e || 'Événement') + (e.h ? ' à ' + e.h : '')}
                title={e.done ? 'Marquer comme à faire' : 'Marquer comme fait'}
                style={{ padding: 0, border: 'none', background: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, borderRadius: '50%' }}>
                <span style={{
                  width: 8, height: 8, borderRadius: '50%', boxSizing: 'border-box',
                  background: isActive ? 'var(--blue)' : (e.done ? 'var(--fg-4)' : 'var(--bg-card)'),
                  border: isActive ? '2px solid var(--blue-soft)' : '1px solid var(--line-2)',
                  boxShadow: isActive ? '0 0 0 3px rgba(37,99,235,0.20)' : 'none',
                }}></span>
              </button>
              {i < events.length - 1 && <div style={{ width: 1, flex: 1, background: 'var(--line)', marginTop: 4, minHeight: 12 }}></div>}
            </div>
            <div style={{ flex: 1, paddingBottom: 4, minWidth: 0 }}>
              <span style={{ fontSize: 13, color: e.done ? 'var(--fg-3)' : 'var(--fg)', textDecoration: e.done ? 'line-through' : 'none', letterSpacing: '-.005em' }}>{e.e}</span>
              {e.tag && <span style={{ marginLeft: 8, padding: '1px 7px', borderRadius: 4, background: 'var(--bg-elev)', color: 'var(--fg-2)', fontSize: 10, fontWeight: 500 }}>{typeof agendaTagLabel === 'function' ? agendaTagLabel(e.tag) : e.tag}</span>}
            </div>
          </div>
        );
      })}
      {fail && <div role="alert" style={{ fontSize: 11.5, lineHeight: 1.4, color: 'var(--red-text)', padding: '4px 0 0', overflowWrap: 'anywhere' }}>{fail}</div>}
    </div>
  );
}

// ─── Tableau de trades (Accueil, fenêtre du jour, page Trades) ────
// Un seul composant, colonnes de la charte (§5) : Date · Symbole · Sens ·
// Entrée · Sortie · Quantité · Stratégie · R · P&L. Chaque écran choisit les
// siennes. En-têtes en casse de phrase : les capitales viennent du CSS.
// `width` : gabarit de la colonne. `fit` : gabarit quand les lignes sont
// solidaires de l'en-tête (PG2_SUBGRID), la largeur d'une colonne se calculant
// alors sur toutes les lignes :
//   Date : jamais plus étroite que la date la plus longue (« 30 sept. · 16:45 »
//     ne se tronque pas quand les montants prennent la place) ;
//   Entrée, Sortie, Quantité : juste leur contenu (max-content) — la place
//     qu'elles n'utilisent pas revient à la Date et à la Stratégie ;
//   P&L : toujours son contenu en entier (un montant à 7 chiffres ne déborde
//     pas sur le R, charte §9), avec un plancher de 92 px (`minWidth`).
const PG2_TRADE_COLUMNS = {
  date:     { label: 'Date',      width: 'minmax(96px, 132px)', fit: 'minmax(max-content, 132px)' },
  symbol:   { label: 'Symbole',   width: 'minmax(64px, 96px)' },
  side:     { label: 'Sens',      width: '40px' },
  entry:    { label: 'Entrée',    width: 'minmax(64px, 104px)', fit: 'minmax(64px, max-content)', align: 'right' },
  exit:     { label: 'Sortie',    width: 'minmax(64px, 104px)', fit: 'minmax(64px, max-content)', align: 'right' },
  lots:     { label: 'Quantité',  width: 'minmax(64px, 80px)', fit: 'minmax(64px, max-content)', align: 'right' },
  // Retrait à gauche : la Stratégie ne se lit pas collée à la Quantité (alignée
  // à droite). 112 px au moins, dont 100 utiles : « Breakout VWAP » tient.
  strategy: { label: 'Stratégie', width: 'minmax(112px, 1fr)', padLeft: 12 },
  r:        { label: 'R',         width: 'minmax(64px, 80px)', align: 'right' },
  pnl:      { label: 'P&L',       width: 'minmax(92px, 124px)', fit: 'max-content', minWidth: 92, align: 'right' },
};
const PG2_COLS_COMPACT = ['date', 'symbol', 'side', 'entry', 'exit', 'strategy', 'r', 'pnl'];
const PG2_COLS_FULL = ['date', 'symbol', 'side', 'entry', 'exit', 'lots', 'strategy', 'r', 'pnl'];
const PG2_COLS_NARROW = ['date', 'symbol', 'side', 'pnl'];
const PG2_COLS_HOME = ['date', 'symbol', 'side', 'strategy', 'r', 'pnl'];
// Accueil (carte à côté des raccourcis) : la date « 25 sept. · 18:17 » tient en
// 112 px ; la place gagnée va à la Stratégie, qui ne se tronque plus.
const PG2_WIDTHS_HOME = { date: 'minmax(96px, 112px)' };
// Fenêtre du jour (880 px) : Entrée et Sortie resserrées, la place gagnée va à
// la Stratégie (« Open Reversal » en entier).
const PG2_WIDTHS_DAY = { date: 'minmax(96px, 112px)', entry: 'minmax(64px, 88px)', exit: 'minmax(64px, 88px)' };

// Lignes solidaires de l'en-tête (subgrid) : une colonne en max-content se
// calcule sur TOUTES les lignes, les pastilles et montants restent alignés.
// Navigateur sans subgrid : grille indépendante par ligne (comportement d'avant).
const PG2_SUBGRID = (() => {
  try { return typeof CSS !== 'undefined' && CSS.supports && CSS.supports('grid-template-columns', 'subgrid'); } catch (e) { return false; }
})();

// Enveloppes de structure (role=row, role=cell) d'une ligne de trade : sans
// boîte propre, la ligne-bouton reste l'élément de grille.
const PG2_ROW_WRAP = { display: 'contents' };

function pg2SignClass(v) {
  if (v === null || v === undefined || v === '') return '';
  const n = Number(v);
  return n > 0 ? 'pos' : n < 0 ? 'neg' : '';
}

// Nom d'une ligne de trade pour un lecteur d'écran. La ligne est un bouton : son
// nom remplace son contenu, il dit donc tout ce que la ligne montre, colonne
// nommée, avec les formateurs des cellules (une valeur absente est omise) :
// « Ouvrir le trade GBPUSD du 9 oct. · 18:17 : Vente, entrée 1,11573, sortie
// 1,11411, quantité 0,5, stratégie Breakout VWAP, −0,58 R, P&L −46,40 $ ».
// Le début (« Ouvrir le trade SYMBOLE du DATE ») ne change pas.
function pg2TradeRowLabel(t, cols, showSource) {
  const head = `Ouvrir le trade ${t.symbol || ''} du ${fmtDateTime(t.executed_at)}`;
  const parts = [];
  const add = (prefix, text) => { if (text && text !== '—') parts.push(prefix + text); };
  const digits = priceDigitsOf(t.entry, t.exit_price);
  for (const k of cols) {
    if (k === 'side') add('', t.direction === 'short' ? 'Vente' : 'Achat');
    else if (k === 'entry') add('entrée ', fmtPrice(t.entry, digits));
    else if (k === 'exit') add('sortie ', fmtPrice(t.exit_price, digits));
    else if (k === 'lots') add('quantité ', fmtLots(t.lots));
    else if (k === 'strategy') add('stratégie ', t.setup ? String(t.setup) : '');
    else if (k === 'r') add('', fmtR(t.r_multiple));
    else if (k === 'pnl') add('P&L ', fmtMoney(t.pnl, true));
  }
  if (showSource) add('source ', tradeSourceLabel(t.imported_from));
  return parts.length ? head + '\u00A0: ' + parts.join(', ') : head;
}

// `widths` (optionnel) : largeurs de colonnes propres à un écran, { clé: gabarit
// CSS grid }, qui remplacent celles de PG2_TRADE_COLUMNS (tableaux étroits).
// `ariaLabel` : nom du tableau pour un lecteur d'écran.
// Le tableau est mémorisé (React.memo, TradeTableMemo) : il n'est redessiné que
// si ses props changent. TradeTable, que les écrans appellent, lui ajoute
// `fmtKey`, la signature des préférences d'affichage (devise, fuseau, jour
// courant) : les changer redessine les lignes même si la liste est la même.
// Structure lue : tableau > ligne d'en-têtes (columnheader) puis une ligne par
// trade, dont l'unique cellule contient la ligne-bouton. Les enveloppes
// (row, cell) sont en display: contents : la mise en page (grille, subgrid)
// reste celle de la ligne-bouton.
const TradeTableMemo = React.memo(function TradeTableMemo({ trades, columns, openTrade, openJournal, showSource, chevron, padX = 4, headerStyle, widths, ariaLabel = 'Trades' }) {
  const cols = (columns || PG2_COLS_COMPACT).filter(k => PG2_TRADE_COLUMNS[k]);
  const hasFlex = cols.includes('strategy');
  // Gabarit d'une colonne, dans l'ordre : largeur propre à l'écran (`widths`) ;
  // sans colonne Stratégie, le P&L prend la place restante ; lignes solidaires
  // de l'en-tête → `fit` (largeur du contenu, calculée sur toutes les lignes) ;
  // sinon `width` (grille indépendante par ligne : un max-content désalignerait
  // les colonnes d'une ligne à l'autre).
  const colWidth = (k) => {
    const c = PG2_TRADE_COLUMNS[k];
    if (widths && widths[k]) return widths[k];
    if (k === 'pnl' && !hasFlex) return 'minmax(92px, 1fr)';
    return (PG2_SUBGRID && c.fit) || c.width;
  };
  // Plancher d'une colonne en max-content (P&L) : porté par ses cellules,
  // en-tête compris.
  const colMin = (k) => (PG2_SUBGRID && colWidth(k) === 'max-content' && PG2_TRADE_COLUMNS[k].minWidth) || 0;
  const template = cols.map(colWidth).join(' ') + (chevron ? ' 16px' : '');
  const open = (t) => {
    if (openTrade) return openTrade(t);
    if (openJournal) return openJournal(t.executed_at);
  };
  const cell = (k, t) => {
    switch (k) {
      case 'date': return (
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ fontSize: 12, color: 'var(--fg-2)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{fmtDateTime(t.executed_at)}</div>
          {/* width 0 + minWidth 100 % : la source remplit la colonne sans l'élargir
              (seule la date fixe la largeur minimale de la colonne). */}
          {showSource && <div style={{ fontSize: 10.5, color: 'var(--fg-3)', marginTop: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', width: 0, minWidth: '100%' }}>{tradeSourceLabel(t.imported_from)}</div>}
        </div>
      );
      case 'symbol': return <span style={{ fontSize: 13, fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{t.symbol || '—'}</span>;
      case 'side': {
        const sell = t.direction === 'short';
        return (
          <span className="pill" title={sell ? 'Vente' : 'Achat'} aria-label={sell ? 'Vente' : 'Achat'} style={{
            background: sell ? 'var(--red-soft)' : 'var(--green-soft)',
            color: sell ? 'var(--red-text)' : 'var(--green-text)',
            border: 'none', padding: '2px 0', fontSize: 10.5, fontWeight: 700, borderRadius: 4, width: 22, justifyContent: 'center',
          }}>{sell ? 'V' : 'A'}</span>
        );
      }
      case 'entry': return <span className="mono" style={{ fontSize: 11.5, color: 'var(--fg-2)', whiteSpace: 'nowrap' }}>{fmtPrice(t.entry, priceDigitsOf(t.entry, t.exit_price))}</span>;
      case 'exit': return <span className="mono" style={{ fontSize: 11.5, color: 'var(--fg-2)', whiteSpace: 'nowrap' }}>{fmtPrice(t.exit_price, priceDigitsOf(t.entry, t.exit_price))}</span>;
      case 'lots': return <span className="mono" style={{ fontSize: 11.5, color: 'var(--fg-2)', whiteSpace: 'nowrap' }}>{fmtLots(t.lots)}</span>;
      // Un nom vraiment long reste tronqué : l'infobulle le donne en entier.
      case 'strategy': return <span title={t.setup || undefined} style={{ fontSize: 12.5, color: 'var(--fg-2)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{t.setup || '—'}</span>;
      case 'r': return <span className={'mono ' + pg2SignClass(t.r_multiple)} style={{ fontSize: 12, fontWeight: 500, whiteSpace: 'nowrap' }}>{fmtR(t.r_multiple)}</span>;
      case 'pnl': return <span className={'num ' + pg2SignClass(t.pnl)} style={{ fontSize: 14, fontWeight: 600, letterSpacing: '-.018em', whiteSpace: 'nowrap' }}>{fmtMoney(t.pnl, true)}</span>;
      default: return null;
    }
  };
  const rowGrid = PG2_SUBGRID
    ? { gridColumn: '1 / -1', display: 'grid', gridTemplateColumns: 'subgrid' }
    : { display: 'grid', gridTemplateColumns: template };
  return (
    <div role="table" aria-label={ariaLabel} style={PG2_SUBGRID ? { display: 'grid', gridTemplateColumns: template, columnGap: 8 } : undefined}>
      <div role="row" style={{ ...rowGrid, padding: `8px ${padX}px`, borderBottom: '1px solid var(--line)', gap: 8, ...headerStyle }}>
        {cols.map(k => (
          <span key={k} role="columnheader" style={{ fontSize: 10, fontWeight: 600, color: 'var(--fg-3)', letterSpacing: '.06em', textTransform: 'uppercase', textAlign: PG2_TRADE_COLUMNS[k].align || 'left', whiteSpace: 'nowrap', paddingLeft: PG2_TRADE_COLUMNS[k].padLeft || 0, minWidth: colMin(k) || undefined }}>{PG2_TRADE_COLUMNS[k].label}</span>
        ))}
        {chevron && <span aria-hidden="true"></span>}
      </div>
      {trades.map((t, i) => (
        <div key={t.id || i} role="row" style={PG2_ROW_WRAP}>
          <div role="cell" aria-colspan={cols.length} style={PG2_ROW_WRAP}>
            <div role="button" tabIndex={0} className="tap"
              aria-label={pg2TradeRowLabel(t, cols, showSource)}
              onClick={() => open(t)}
              onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(t); } }}
              onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-soft)'}
              onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
              style={{
                ...rowGrid,
                padding: `12px ${padX}px`, borderBottom: i < trades.length - 1 ? '1px solid var(--line)' : 'none',
                alignItems: 'center', cursor: 'pointer', gap: 8,
                transition: 'background .15s var(--ease)',
              }}>
              {cols.map(k => (
                <div key={k} style={{ minWidth: colMin(k), display: 'flex', justifyContent: PG2_TRADE_COLUMNS[k].align === 'right' ? 'flex-end' : 'flex-start', paddingLeft: PG2_TRADE_COLUMNS[k].padLeft || 0, overflow: k === 'pnl' || k === 'r' ? 'visible' : 'hidden' }}>{cell(k, t)}</div>
              ))}
              {chevron && <span aria-hidden="true" style={{ color: 'var(--fg-3)', display: 'flex', justifyContent: 'flex-end' }}>{Ico.chev}</span>}
            </div>
          </div>
        </div>
      ))}
    </div>
  );
});

function TradeTable(props) {
  const fmtKey = [window.__appCurrency, window.__appTz, todayISO()].join('|');
  return <TradeTableMemo {...props} fmtKey={fmtKey}/>;
}

// Tuile de raccourci : nom et description lus dans NAV_LABELS (core.jsx).
function NavTile({ nav, to, onClick }) {
  const l = pg2Nav(to);
  const title = l.titre || l.menu || '';
  const ico = l.ico || Ico[to] || Ico.chev;
  return (
    <button type="button" onClick={onClick ? onClick : () => nav(to)} className="card lift tap" style={{
      padding: '18px 18px',
      border: '1px solid var(--line)', borderRadius: 12,
      cursor: 'pointer', textAlign: 'left', width: '100%',
      display: 'flex', flexDirection: 'column', gap: 8,
      background: 'var(--bg-card)', color: 'var(--fg)', fontFamily: 'inherit',
    }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%' }}>
        <div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--blue-soft)', color: 'var(--blue-600)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
          {React.cloneElement(ico, { width: 16, height: 16 })}
        </div>
        <span style={{ fontSize: 14, fontWeight: 600, flex: 1, letterSpacing: '-.01em', color: 'var(--fg)' }}>{title}</span>
        <span style={{ color: 'var(--fg-3)' }}>{Ico.chev}</span>
      </div>
      {l.desc && <p style={{ fontSize: 12, color: 'var(--fg-3)', margin: 0, marginLeft: 42, lineHeight: 1.4, letterSpacing: '-.005em' }}>{l.desc}</p>}
    </button>
  );
}

// ─── JOURNAL QUOTIDIEN ────────────────────────────────────────────
const PG2_DAYS_PAGE = 30;

// Liste des jours du Journal quotidien, COMMUNE à l'ordinateur (Daily) et au
// mobile (DailyMobile, app.jsx) : jours tradés, jours avec un journal (notes ou
// humeur) ou des captures, et aujourd'hui en tête. « Tous » montre cette union,
// « Avec trades » les seuls jours tradés. Affichage par pages de 30 jours
// (« Afficher 30 jours de plus »). Le profit factor garde Infinity (jour sans
// perte → « ∞ ») et vaut null quand il n'a pas de sens (que des nuls → « — »).
// Sans la liste des journaux (lecture en échec), les jours tradés restent là.
function useDailyDays(state, tab) {
  const tradesByDay = (state && state.tradesByDay) || {};
  const today = (state && state.today) || todayISO();
  const journalDays = (state && state.journalDays) || null;
  const uid = state && state.user ? state.user.id : null;
  const [limit, setLimit] = React.useState(PG2_DAYS_PAGE);
  React.useEffect(() => {
    if (uid && state && typeof state.loadJournalDays === 'function') state.loadJournalDays();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [uid]);
  React.useEffect(() => { setLimit(PG2_DAYS_PAGE); }, [tab]);

  const all = React.useMemo(() => {
    const set = new Set(Object.keys(tradesByDay));
    if (journalDays) Object.keys(journalDays).forEach(d => set.add(d));
    set.add(today);
    return Array.from(set).sort().reverse().map(day => {
      const data = tradesByDay[day] || { trades: [], pnl: 0, wins: 0, losses: 0 };
      const trades = data.trades || [];
      let totalWin = 0, totalLoss = 0;
      for (const t of trades) {
        const p = Number(t.pnl) || 0;
        if (p > 0) totalWin += p;
        else if (p < 0) totalLoss += Math.abs(p);
      }
      const pnl = Number(data.pnl) || 0;
      return {
        day, // ISO date
        p: pnl, pnl,
        t: trades.length,
        w: data.wins || 0,
        l: data.losses || 0,
        wr: trades.length > 0 ? ((data.wins || 0) / trades.length) * 100 : null,
        pf: totalLoss > 0 ? totalWin / totalLoss : (totalWin > 0 ? Infinity : null),
        journal: (journalDays && journalDays[day]) || null,
      };
    });
  }, [tradesByDay, journalDays, today]);

  const filtered = all.filter(d => tab === 'all' || d.t > 0);
  const visible = filtered.slice(0, limit);
  return {
    visible,
    more: Math.max(0, filtered.length - visible.length),
    showMore: () => setLimit(l => l + PG2_DAYS_PAGE),
    // Rien à montrer : ni trade ni journal (la carte d'aujourd'hui, vide, ne compte pas).
    empty: all.every(d => d.t === 0 && !d.journal),
  };
}

// Repères d'un journal rempli sur la carte d'un jour : « Notes · Humeur 4/5 · 2 captures ».
function dailyJournalBits(j) {
  if (!j) return [];
  const bits = [];
  if (j.notes) bits.push('Notes');
  if (j.mood != null) bits.push('Humeur ' + j.mood + '/5');
  if (j.captures > 0) bits.push(j.captures + ' capture' + (j.captures > 1 ? 's' : ''));
  return bits;
}

// Bouton « Afficher 30 jours de plus » (ordinateur et mobile).
function DailyMoreButton({ more, onMore, style }) {
  if (!more) return null;
  const n = Math.min(more, PG2_DAYS_PAGE);
  return (
    <div style={{ display: 'flex', justifyContent: 'center', ...style }}>
      <button type="button" onClick={onMore} className="btn tap" style={{ color: 'var(--fg)' }}>
        Afficher {n} jour{n > 1 ? 's' : ''} de plus
      </button>
    </div>
  );
}

function Daily({ nav, openJournal, openImport, state }) {
  const [tab, setTab] = React.useState('all');
  const { tradesLoading, today } = state || { tradesLoading: false, today: todayISO() };
  const { visible, more, showMore, empty } = useDailyDays(state, tab);

  const addTrade = () => openImport && openImport(null, 'manual');
  // Rien à afficher : aucun trade ni journal (la carte d'aujourd'hui, vide, est toujours là).
  const noTrades = empty;
  // Aucune carte à l'écran : compte sans rien, ou filtre « Avec trades » sans
  // jour tradé. Tant que les trades se chargent (ou si leur lecture a échoué),
  // on ne peut pas dire « aucun jour avec trades » : squelette, ou message
  // d'indisponibilité.
  const nothingShown = noTrades || visible.length === 0;

  return (
    <div className="scroll" style={{ width: '100%', height: '100%', overflow: 'auto' }}>
      <PageHeader title={pg2Nav('daily').titre || 'Journal quotidien'}/>
      <div style={{ ...PAGE_BODY, padding: '20px 24px 40px' }}>
        <Pg2LoadError state={state} style={{ marginBottom: 16 }}/>
        {/* Filtres et action d'en-tête : seulement quand il y a des jours à filtrer
            (sinon l'état vide porte seul l'action principale). */}
        {!noTrades && <div style={{ marginBottom: 18 }}>
          <FilterBar
            activeTab={tab} setActiveTab={setTab}
            tabs={[
              { id: 'all',      label: 'Tous',     ico: null },
              // Sans icône : l'état actif passe seulement par le style du bouton (comme sur mobile).
              { id: 'verified', label: 'Avec trades', ico: null },
            ]}
            right={[
              <button key="new" type="button" onClick={addTrade} className="btn btn-blue tap" style={{ color: '#fff' }}>{Ico.plus} Ajouter un trade</button>,
            ]}
          />
        </div>}

        {tradesLoading && nothingShown ? (
          <div role="status" aria-busy="true" aria-label="Chargement de tes journaux" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
            {[0, 1, 2].map(i => (
              <div key={i} className="card" style={{ padding: '22px 26px', display: 'flex', alignItems: 'center', gap: 24 }}>
                <div style={{ width: 120, height: 16, borderRadius: 6, background: 'var(--bg-elev)' }}></div>
                <div style={{ flex: 1, display: 'flex', gap: 24 }}>
                  <div style={{ width: 110, height: 22, borderRadius: 6, background: 'var(--bg-elev)' }}></div>
                  <div style={{ width: 60, height: 22, borderRadius: 6, background: 'var(--bg-elev)' }}></div>
                </div>
                <div style={{ width: 90, height: 30, borderRadius: 8, background: 'var(--bg-elev)' }}></div>
              </div>
            ))}
          </div>
        ) : nothingShown && state?.tradesError ? (
          <div className="card" style={{ padding: '16px 24px' }}>
            <Pg2Unavailable text={noTrades
              ? 'Tes journaux s’afficheront ici dès que la connexion sera rétablie.'
              : 'Tes jours de trading s’afficheront ici dès que la connexion sera rétablie.'}/>
          </div>
        ) : noTrades ? (
          <div className="card">
            <EmptyTrades openImport={openImport}/>
          </div>
        ) : visible.length === 0 ? (
          // Filtre « Avec trades » sans aucun jour tradé (le compte n'a que des
          // journaux) : la page le dit, au lieu de rester blanche sous les filtres.
          <div className="card">
            <DailyFilterEmpty onAll={() => setTab('all')}/>
          </div>
        ) : (
          <div className="stagger" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
            {visible.map(d => (
              <DayJournalCard key={d.day} day={d} highlight={d.day === today} openJournal={openJournal} openImport={openImport}/>
            ))}
            <DailyMoreButton more={more} onMore={showMore} style={{ marginTop: 4 }}/>
          </div>
        )}
      </div>
    </div>
  );
}

// Filtre « Avec trades » qui ne retient aucun jour : état vide commun à
// l'ordinateur (Daily) et au mobile (DailyMobile, app.jsx). Une seule action,
// secondaire : « Ajouter un trade » est déjà dans la barre de filtres (charte
// §7 : une action principale par écran).
// `compact` : par défaut, resserré sur mobile comme les autres états vides.
function DailyFilterEmpty({ onAll, compact }) {
  const mobile = usePg2Mobile();
  return (
    <EmptyState icon={Ico.daily} title="Aucun jour avec trades" text="Les jours où tu as tradé s’afficheront ici."
      secondary={{ label: 'Tous les jours', onClick: onAll }} compact={compact == null ? mobile : !!compact}/>
  );
}

// Indicateurs d'un jour : pistes d'au moins 112 px séparées de 16 px, le P&L
// en occupant deux. Les quatre tiennent sur une ligne à partir de 5 pistes.
const PG2_DAY_TRACK = 112;
const PG2_DAY_GAP = 16;
const PG2_DAY_ONE_ROW = 5 * PG2_DAY_TRACK + 4 * PG2_DAY_GAP;
// En pile (P&L au-dessus), les trois autres indicateurs demandent trois pistes
// de 104 px. En dessous (fenêtre de 900 à 989 px, menu latéral déplié), trois
// pistes proportionnées à leur contenu — « Trades » est court, « Taux de
// réussite » porte « 12 G · 10 P » — plutôt que deux pistes et un indicateur
// seul sur une troisième ligne.
const PG2_DAY_STACK_TRACK = 104;
const PG2_DAY_STACK_ROW = 3 * PG2_DAY_STACK_TRACK + 2 * PG2_DAY_GAP;
const PG2_DAY_STACK_TIGHT = 'minmax(0, 0.55fr) minmax(0, 1.2fr) minmax(0, 1fr)';

// Carte d'un jour : les mêmes indicateurs que l'Accueil (P&L, trades, taux de
// réussite avec « G · P », profit factor). La carte mesure la place réelle de
// ses indicateurs (fenêtre étroite, menu latéral déplié ou non) : quand ils ne
// tiennent pas sur une ligne, le P&L occupe la première et les trois autres se
// rangent dessous, côte à côte — plus de ligne bancale (deux indicateurs à
// côté du P&L, le troisième seul en dessous), jamais un montant tronqué.
// Toutes les cartes ayant la même largeur, les colonnes restent alignées d'une
// carte à l'autre.
function DayJournalCard({ day, highlight, openJournal, openImport }) {
  const [zoneRef, zoneW] = usePgWidth();
  // usePgWidth arrondit au pixel : 1 px de marge, pour ne jamais annoncer cinq
  // pistes quand il n'y a la place que pour quatre.
  const stacked = zoneW > 0 && zoneW < PG2_DAY_ONE_ROW + 1;
  const tight = stacked && zoneW < PG2_DAY_STACK_ROW + 1;
  const hasTrades = day.t > 0;
  const tone = day.p > 0 ? 'pos' : (day.p < 0 ? 'neg' : null);
  const nuls = Math.max(0, day.t - (day.w || 0) - (day.l || 0));
  const journalBits = dailyJournalBits(day.journal);
  return (
    <div className="card lift" style={{
      padding: '22px 26px',
      border: highlight ? '1.5px solid var(--blue)' : '1px solid var(--line)',
      boxShadow: highlight ? '0 0 0 3px rgba(59,130,246,0.08)' : 'none',
      // Colonne du jour : 170 px, resserrée (jusqu'à 128 px) quand la carte est
      // étroite, pour laisser la place aux indicateurs. Règle purement CSS : elle
      // ne dépend pas de la largeur mesurée (pas d'oscillation au seuil).
      display: 'grid', gridTemplateColumns: 'minmax(128px, min(170px, 22%)) minmax(0, 1fr) auto',
      gap: 24, alignItems: 'center',
    }}>
      <div style={{ minWidth: 0 }}>
        <div style={{ fontSize: 18, fontWeight: 600, letterSpacing: '-.015em', whiteSpace: 'nowrap' }}>{pg2DayTitle(day.day)}</div>
        {highlight && <div style={{ marginTop: 10 }}><span className="pill pill-blue" style={{ padding: '3px 9px', fontSize: 10.5 }}>Aujourd’hui</span></div>}
        {journalBits.length > 0 && (
          <div style={{ marginTop: 8, fontSize: 11.5, color: 'var(--fg-3)', display: 'inline-flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
            <span style={{ display: 'inline-flex', color: 'var(--blue-600)' }}>{React.cloneElement(Ico.book, { width: 13, height: 13 })}</span>
            {journalBits.join(' · ')}
          </div>
        )}
      </div>

      <div ref={zoneRef} style={{ minWidth: 0 }}>
        {hasTrades ? (
          <div style={{ display: 'grid', gridTemplateColumns: tight ? PG2_DAY_STACK_TIGHT : `repeat(auto-fill, minmax(${stacked ? PG2_DAY_STACK_TRACK : PG2_DAY_TRACK}px, 1fr))`, columnGap: PG2_DAY_GAP, rowGap: 14, alignItems: 'start' }}>
            <DayMetric label="P&L" value={fmtMoney(day.p, true)} tone={tone} big span={stacked ? 'all' : 2}/>
            <DayMetric label="Trades" value={day.t}/>
            <DayMetric label="Taux de réussite" value={fmtPct(day.wr, 1)} sub={pg2WinLoss(day.w, day.l, nuls)}/>
            <DayMetric label="Profit factor" value={fmtPF(day.pf, day.t)}/>
          </div>
        ) : (
          <div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 14 }}>
            <span style={{ fontSize: 13, color: 'var(--fg-2)' }}>{EMPTY_DAY_TITLE}</span>
            {openImport && day.day <= todayISO() && (
              <button type="button" onClick={() => openImport(day.day, 'manual')} className="btn tap" aria-label={'Ajouter un trade au ' + fmtDayLong(day.day)} style={{ color: 'var(--fg)' }}>{Ico.plus} Ajouter un trade</button>
            )}
          </div>
        )}
      </div>

      <div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'flex-end' }}>
        {/* Le nom dit le jour (comme sur téléphone et dans les Statistiques) :
            hors de sa carte, « Journal » seul ne désigne rien. */}
        <button type="button" onClick={() => openJournal && openJournal(day.day)} className="btn btn-outline-blue tap"
          aria-label={'Ouvrir le journal du ' + fmtDayLong(day.day) + (hasTrades ? ', P&L ' + fmtMoney(day.p, true) : '')}
          style={{ whiteSpace: 'nowrap' }}>
          {Ico.book} Journal
        </button>
      </div>
    </div>
  );
}

// `span` : nombre de pistes occupées, ou 'all' pour toute la ligne.
function DayMetric({ label, value, sub, tone, big, span }) {
  const color = tone === 'pos' ? 'var(--green)' : (tone === 'neg' ? 'var(--red)' : 'var(--fg)');
  return (
    <div style={{ minWidth: 0, gridColumn: span === 'all' ? '1 / -1' : span ? 'span ' + span : undefined }}>
      <div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--fg-3)', letterSpacing: '.04em', textTransform: 'uppercase', marginBottom: 6, whiteSpace: 'nowrap' }}>{label}</div>
      <div className="num" style={{ fontSize: big ? 22 : 18, fontWeight: 600, color, letterSpacing: '-.018em', lineHeight: 1, whiteSpace: 'nowrap' }}>{value}</div>
      {sub && <div style={{ fontSize: 11, color: 'var(--fg-3)', marginTop: 5, whiteSpace: 'nowrap' }}>{sub}</div>}
    </div>
  );
}

// ─── Fenêtre du jour : Statistiques / Notes / Trades / Captures ───
function JournalModal({ open, dayISO, onClose, state, openImport, openTrade }) {
  const [tab, setTab] = React.useState('stats');
  const [notes, setNotesLocal] = React.useState('');
  const [mood, setMoodLocal] = React.useState(null);
  const [attachments, setAttachments] = React.useState([]);
  // Lecture du journal : { day, ok } une fois terminée (réussie ou non), null
  // pendant la lecture. Le jour est mémorisé avec le résultat pour que rien de
  // ce qui a été lu pour un jour ne puisse passer pour le journal d'un autre.
  const [loaded, setLoaded] = React.useState(null);
  // Notes ou humeur « non enregistrées » : { day, notes ?, mood ?, last } levé à
  // chaque échec d'enregistrement, baissé au succès (charte §7 : un échec n'est
  // jamais silencieux, et ce qui a été saisi n'est jamais perdu sans le dire).
  const [unsaved, setUnsaved] = React.useState(null);
  const unsavedRef = React.useRef(null);
  // Enregistrements en cours d'attente (fermeture ou changement de jour).
  const [settling, setSettling] = React.useState(false);
  // Même drapeau, lisible tout de suite (avant le rendu suivant) par la saisie,
  // et attente en cours partagée : deux demandes simultanées (flèche puis
  // « Fermer ») attendent la même fin au lieu de se marcher dessus.
  const settlingRef = React.useRef(false);
  const settleRun = React.useRef(null);
  const inflight = React.useRef(new Set());
  // Frappe en attente de sauvegarde automatique : { save } — l'enregistrement
  // du texte tapé, sur le jour où il a été tapé (et avec le compte de ce
  // moment-là). null quand rien n'attend.
  const pendingNotes = React.useRef(null);
  // Numéro du dernier enregistrement de chaque champ (un résultat plus ancien
  // ne fait pas foi) ; epoch change quand la saisie est abandonnée.
  const saveSeq = React.useRef({ notes: 0, mood: 0, epoch: 0 });
  const [reloadKey, setReloadKey] = React.useState(0);
  const [uploading, setUploading] = React.useState(false);
  const [drag, setDrag] = React.useState(false);
  const [previews, setPreviews] = React.useState({}); // file_path → signedUrl
  const [attachError, setAttachError] = React.useState('');
  const [confirmAttach, setConfirmAttach] = React.useState(null); // id de la capture à supprimer
  const [removingAttach, setRemovingAttach] = React.useState(null);
  const [viewer, setViewer] = React.useState(null); // id de la capture ouverte en grand
  // Internal navigation state seeded from the dayISO prop. Updating this with the prev/next
  // arrows shifts the viewed day without closing the modal.
  const [currentDay, setCurrentDay] = React.useState(dayISO || null);
  const inputRef = React.useRef(null);
  const saveTimer = React.useRef(null);
  const urlAsked = React.useRef({}); // file_path → instant de la dernière demande d'adresse signée
  const isMobile = usePg2Mobile();

  // Jour affiché, lisible après une attente (envoi ou suppression de capture) :
  // null quand la fenêtre est fermée.
  const dayRef = React.useRef(null);
  dayRef.current = open ? currentDay : null;
  // Le journal du jour AFFICHÉ est-il lu ? Sinon (lecture en cours ou en échec)
  // rien n'est affiché ni modifiable : ni notes, ni humeur, ni captures. Une
  // saisie sur un journal non lu écraserait ce qui est enregistré.
  const loadedHere = !!(open && currentDay && loaded && loaded.day === currentDay);
  const journalReady = loadedHere && loaded.ok;
  const journalError = loadedHere && !loaded.ok;
  const loadingJournal = !journalReady && !journalError;

  const markUnsaved = (u) => { unsavedRef.current = u; setUnsaved(u); };

  // Enregistre les notes ('notes') ou l'humeur ('mood') d'un jour. Renvoie une
  // promesse (jamais rejetée) de réussite. Un échec lève le drapeau « non
  // enregistré » : le pied de la fenêtre le dit et propose de réessayer
  // (setDayJournal* renvoient { error }).
  const saveJournal = (day, what, value) => {
    const fn = state && (what === 'mood' ? state.setDayJournalMood : state.setDayJournalNotes);
    if (typeof fn !== 'function' || !day) return Promise.resolve(true);
    const seq = ++saveSeq.current[what];
    const epoch = saveSeq.current.epoch;
    const p = (async () => {
      let r;
      try { r = await fn(day, value); } catch (e) { r = { error: e }; }
      const failed = !!(r && r.error);
      if (failed) console.warn('[Tempo] journal du jour :', (r.error && r.error.message) || r.error);
      // Résultat périmé : un enregistrement plus récent du même champ fait foi,
      // ou la saisie a été abandonnée entre-temps.
      if (epoch !== saveSeq.current.epoch || seq !== saveSeq.current[what]) return !failed;
      const cur = unsavedRef.current && unsavedRef.current.day === day ? unsavedRef.current : null;
      if (failed) {
        markUnsaved({ ...(cur || { day }), [what]: value, last: what });
      } else if (cur && what in cur) {
        const next = { ...cur };
        delete next[what];
        const left = 'notes' in next ? 'notes' : 'mood' in next ? 'mood' : null;
        markUnsaved(left ? { ...next, last: left } : null);
      }
      return !failed;
    })();
    inflight.current.add(p);
    p.then(() => { inflight.current.delete(p); });
    return p;
  };

  // Notes en attente de sauvegarde automatique : enregistrées avant de changer
  // de jour ou de fermer. Ce qui part est la frappe en attente elle-même (son
  // jour, son texte), jamais le jour ni le texte du rendu courant : après un
  // changement de jour, ceux-ci sont ceux d'un AUTRE journal.
  const flushNotes = () => {
    if (saveTimer.current) { clearTimeout(saveTimer.current); saveTimer.current = null; }
    const p = pendingNotes.current;
    if (!p) return;
    pendingNotes.current = null;
    p.save();
  };
  const hasPendingSave = () => !!pendingNotes.current || inflight.current.size > 0 || !!unsavedRef.current;
  // Attend que plus rien ne soit en route : une frappe en attente part tout de
  // suite, et on recommence tant qu'il en arrive une ou qu'un enregistrement
  // est en cours.
  const drainSaves = async () => {
    do {
      flushNotes();
      await Promise.all(Array.from(inflight.current));
    } while (pendingNotes.current || inflight.current.size);
  };
  // Termine tout ce qui reste à enregistrer : notes en attente, enregistrements
  // en cours, puis une nouvelle tentative pour ce qui a échoué. Renvoie true
  // quand tout est enregistré. Pendant l'attente, notes et humeur ne sont plus
  // modifiables (settling) : rien ne peut être tapé puis perdu.
  const settleSaves = () => {
    if (settleRun.current) return settleRun.current;
    settlingRef.current = true;
    setSettling(true);
    const run = (async () => {
      try {
        await drainSaves();
        const u = unsavedRef.current;
        if (u) {
          const jobs = [];
          if ('notes' in u) jobs.push(saveJournal(u.day, 'notes', u.notes));
          if ('mood' in u) jobs.push(saveJournal(u.day, 'mood', u.mood));
          await Promise.all(jobs);
          await drainSaves();
        }
        return !unsavedRef.current;
      } finally {
        settleRun.current = null;
        settlingRef.current = false;
        setSettling(false);
      }
    })();
    settleRun.current = run;
    return run;
  };
  // Abandon confirmé (ou fenêtre fermée) : plus rien n'est en attente, et un
  // résultat qui arriverait encore ne relève plus le drapeau.
  const abandonSaves = () => {
    if (saveTimer.current) { clearTimeout(saveTimer.current); saveTimer.current = null; }
    pendingNotes.current = null;
    saveSeq.current.epoch++;
    if (unsavedRef.current) markUnsaved(null);
  };
  // Une saisie non enregistrée (échec, ou enregistrement encore en cours) fait
  // poser la question « Abandonner ta saisie ? » avant de fermer.
  const isDirty = !!(open && (unsaved || settling));
  const dirtyShown = React.useRef(false);
  dirtyShown.current = isDirty;
  // Fermeture demandée par la modale. Saisie déjà signalée comme non
  // enregistrée : on arrive ici par « Abandonner », la fenêtre se ferme. Sinon
  // la fenêtre attend l'enregistrement en cours ; s'il échoue, elle reste
  // ouverte sur le jour, avec le message et « Réessayer » dans le pied.
  const closeJournal = async () => {
    if (dirtyShown.current) { abandonSaves(); if (onClose) onClose(); return; }
    if (!hasPendingSave()) { if (onClose) onClose(); return; }
    const epoch = saveSeq.current.epoch;
    const ok = await settleSaves();
    if (ok && epoch === saveSeq.current.epoch && onClose) onClose();
  };
  const modal = useModalBehavior({ open, onRequestClose: closeJournal, isDirty, labelledBy: 'journal-modal-title' });
  // Croix et bouton « Fermer » : une saisie non enregistrée est d'abord
  // relancée ; si elle passe, la fenêtre se ferme sans rien demander.
  const askClose = async () => {
    // Plus rien en attente : la fenêtre se ferme, même si la question
    // « Abandonner ta saisie ? » est encore à l'écran (elle n'a plus d'objet).
    if (!hasPendingSave()) { if (onClose) onClose(); return; }
    if (unsavedRef.current && !settlingRef.current) {
      const epoch = saveSeq.current.epoch;
      const ok = await settleSaves();
      if (epoch !== saveSeq.current.epoch) return;
      if (ok) { if (onClose) onClose(); return; }
    }
    modal.requestClose();
  };
  // La question « Abandonner ta saisie ? » ne reste pas quand il n'y a plus rien
  // à abandonner (« Réessayer » réussi pendant qu'elle était affichée) : elle
  // est retirée et le focus revient où il était (le bouton « Réessayer », lui,
  // a disparu). Sans cela, la croix et « Fermer » resteraient inertes.
  const staleConfirm = !!(open && !isDirty && modal.confirmDiscard);
  React.useEffect(() => {
    if (staleConfirm) modal.keepEditing();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [staleConfirm]);
  const retrySaves = () => { settleSaves(); };
  const saveError = !unsaved ? ''
    : ('notes' in unsaved && 'mood' in unsaved) ? 'Notes et humeur non enregistrées. Vérifie ta connexion.'
    : 'mood' in unsaved ? 'Humeur non enregistrée. Vérifie ta connexion.'
    : 'Notes non enregistrées. Vérifie ta connexion.';

  // When the modal is (re)opened from a calendar cell, snap currentDay back to that prop.
  React.useEffect(() => {
    if (open && dayISO) setCurrentDay(dayISO);
  }, [open, dayISO]);

  React.useEffect(() => {
    setAttachError(''); setConfirmAttach(null); setRemovingAttach(null); setViewer(null);
    // Le drapeau « non enregistré » n'est PAS effacé au changement de jour : on
    // ne quitte un jour qu'une fois sa saisie enregistrée (shiftDay). Fenêtre
    // fermée : plus rien n'est en attente. Fermeture décidée ailleurs (changement
    // de compte) avec une frappe encore en attente : elle part d'abord, sur son
    // jour et avec le compte du moment de la frappe, au lieu d'être jetée (après
    // « Abandonner », abandonSaves l'a déjà retirée : rien ne part).
    if (!open) { flushNotes(); abandonSaves(); }
  }, [open, currentDay]);

  React.useEffect(() => {
    // Rien du jour précédent ne reste sous le titre du nouveau jour (ni à la
    // réouverture) : notes, humeur, captures et vignettes repartent de zéro. Les
    // notes en attente du jour quitté sont déjà parties (flushNotes).
    setLoaded(null);
    setNotesLocal('');
    setMoodLocal(null);
    setAttachments([]);
    setPreviews({});
    urlAsked.current = {};
    if (!open || !currentDay || !state) return undefined;
    let live = true;
    const day = currentDay;
    (async () => {
      try {
        const [journal, atts] = await Promise.all([
          state.getDayJournal(day),
          state.getDayAttachments(day),
        ]);
        if (!live) return;
        setNotesLocal(journal?.notes || '');
        setMoodLocal(journal?.mood ?? null);
        setAttachments(atts || []);
        setLoaded({ day, ok: true });
        // Adresses signées : vignettes des images, lien des autres fichiers.
        (atts || []).forEach(async (a) => {
          if (!a.file_path) return;
          urlAsked.current[a.file_path] = Date.now();
          const url = await state.getAttachmentUrl(a.file_path);
          if (url && live) setPreviews(prev => ({ ...prev, [a.file_path]: url }));
        });
      } catch (e) {
        console.warn('[Tempo] journal du jour :', (e && e.message) || e);
        if (!live) return;
        // Échec de chargement : on n'affiche pas un journal vide (une saisie
        // écraserait les notes existantes) mais un message avec Réessayer.
        setLoaded({ day, ok: false });
      }
    })();
    return () => { live = false; };
  }, [open, currentDay, reloadKey]);

  if (!open) return null;

  // Shift currentDay by N days (negative for previous, positive for next).
  // Jamais au-delà d'aujourd'hui (fuseau du profil) : on ne tient pas le
  // journal d'un jour qui n'a pas eu lieu (même règle que « Ajouter des trades »).
  const todayDay = todayISO();
  const canNext = !!currentDay && currentDay < todayDay;
  const shiftDay = (delta) => {
    if (!currentDay) return;
    if (delta > 0 && !canNext) return;
    // Parse YYYY-MM-DD as a UTC date to avoid DST surprises on add/subtract, then format back.
    const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(currentDay);
    if (!m) return;
    const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
    d.setUTCDate(d.getUTCDate() + delta);
    const y = d.getUTCFullYear();
    const mo = String(d.getUTCMonth() + 1).padStart(2, '0');
    const da = String(d.getUTCDate()).padStart(2, '0');
    const next = `${y}-${mo}-${da}`;
    const go = () => setCurrentDay(next > todayDay ? todayDay : next);
    if (!hasPendingSave()) { go(); return; }
    // Les notes en attente partent d'abord, pour appartenir au jour quitté ; un
    // enregistrement en échec est relancé. S'il échoue encore, on reste sur le
    // jour : le pied de la fenêtre le dit et propose « Réessayer ».
    const from = currentDay;
    const epoch = saveSeq.current.epoch;
    settleSaves().then((ok) => {
      if (ok && epoch === saveSeq.current.epoch && dayRef.current === from) go();
    });
  };

  const debouncedSaveNotes = (v) => {
    if (!journalReady || settlingRef.current) return;
    setNotesLocal(v);
    if (saveTimer.current) clearTimeout(saveTimer.current);
    const day = currentDay;
    pendingNotes.current = { save: () => saveJournal(day, 'notes', v) };
    saveTimer.current = setTimeout(flushNotes, 500);
  };
  const setMood = (v) => {
    if (!journalReady || settlingRef.current) return;
    setMoodLocal(v);
    saveJournal(currentDay, 'mood', v);
  };
  // « du mar. 6 oct. » : précise le jour d'un message arrivé après un changement de jour.
  const dayMention = (day) => ' du ' + fmtWeekdayShort(day) + ' ' + fmtDayShort(day);
  // Ajoute les captures une par une (l'état « Envoi… » couvre tout le lot).
  // L'envoi appartient au jour où il a été lancé : s'il se termine après un
  // changement de jour, la fenêtre du jour affiché n'est pas touchée (le cache
  // du jour d'origine, lui, est à jour : la capture y sera au retour).
  const addFiles = async (list) => {
    const files = Array.from(list || []);
    if (!files.length || !state || !journalReady) return;
    const day = currentDay;
    const sameDay = () => dayRef.current === day;
    const refused = []; // { name, why }
    // Un seul fichier : message court. Plusieurs : une ligne par refus, qui nomme
    // le fichier. Jour changé entre-temps : le jour concerné est précisé.
    const refusedText = () => refused.map(r => (
      sameDay()
        ? (files.length > 1 ? 'Impossible d’ajouter la capture « ' + r.name + ' » : ' : 'Impossible d’ajouter la capture : ')
        : 'Impossible d’ajouter la capture « ' + r.name + ' » au journal' + dayMention(day) + ' : '
    ) + r.why).join('\n');
    setAttachError('');
    setUploading(true);
    for (const f of files) {
      let res;
      try { res = await state.uploadDayAttachment(day, f); } catch (e) { res = { error: e }; }
      const error = res && res.error;
      if (error || !res || !res.data) {
        console.warn('[Tempo] envoi de capture', error);
        refused.push({ name: (f && f.name) || 'fichier', why: sbErrorMessage(error || {}) });
        if (dayRef.current) setAttachError(refusedText());
        continue;
      }
      const data = res.data;
      if (!sameDay()) continue;
      setAttachments(prev => [data, ...prev.filter(a => a.id !== data.id)]);
      if (data.file_path) {
        urlAsked.current[data.file_path] = Date.now();
        const url = await state.getAttachmentUrl(data.file_path);
        if (url && sameDay()) setPreviews(prev => ({ ...prev, [data.file_path]: url }));
      }
    }
    setUploading(false);
  };
  const pickFiles = () => { if (!uploading && journalReady && inputRef.current) inputRef.current.click(); };
  // Suppression confirmée : la vignette ne disparaît qu'une fois la
  // suppression faite ; en cas d'échec, message en ligne.
  const removeAttach = async (id) => {
    if (removingAttach || !journalReady) return;
    const att = attachments.find(a => a.id === id);
    const day = currentDay;
    setRemovingAttach(id);
    setAttachError('');
    let res;
    try { res = await state.deleteDayAttachment(day, id); } catch (e) { res = { error: e }; }
    const error = res && res.error;
    if (error) console.warn('[Tempo] suppression de capture', error);
    // Jour changé pendant l'attente : la fenêtre affiche un autre journal, on
    // n'y touche pas (seul un échec est signalé, avec le jour concerné).
    if (dayRef.current !== day) {
      if (error && dayRef.current) {
        setAttachError('Impossible de supprimer la capture' + (att && att.file_name ? ' « ' + att.file_name + ' »' : '') + ' du journal' + dayMention(day) + ' : ' + sbErrorMessage(error));
      }
      return;
    }
    setRemovingAttach(null);
    if (error) {
      setAttachError('Impossible de supprimer la capture : ' + sbErrorMessage(error));
      return;
    }
    setConfirmAttach(null);
    setAttachments(prev => prev.filter(a => a.id !== id));
    if (att) setPreviews(prev => { const n = { ...prev }; delete n[att.file_path]; return n; });
  };
  // Une adresse signée dure une heure : si une image ne s'affiche plus, on en
  // redemande une (au plus une fois par minute et par fichier).
  const refreshPreview = async (a) => {
    if (!a || !a.file_path || !state) return;
    if (Date.now() - (urlAsked.current[a.file_path] || 0) < 60000) return;
    urlAsked.current[a.file_path] = Date.now();
    const day = currentDay;
    let url = null;
    try { url = await state.getAttachmentUrl(a.file_path); } catch (e) { url = null; }
    if (url && dayRef.current === day) setPreviews(prev => (prev[a.file_path] === url ? prev : { ...prev, [a.file_path]: url }));
  };
  // La saisie manuelle borne la date à aujourd'hui (import-modal.jsx) : sur un
  // jour à venir (ouvert parce qu'il contient un trade mal daté), « Ajouter un
  // trade à cette date » mentirait. L'action n'y est pas proposée.
  const futureDay = !!currentDay && currentDay > todayDay;
  // Ferme le journal puis ouvre la saisie d'un trade pré-remplie sur ce jour.
  // Une saisie en attente est enregistrée d'abord ; si elle échoue, on reste
  // sur le jour (message et « Réessayer » dans le pied).
  const addTradeHere = async () => {
    const d = currentDay;
    if (hasPendingSave()) {
      const epoch = saveSeq.current.epoch;
      const ok = await settleSaves();
      if (!ok || epoch !== saveSeq.current.epoch || dayRef.current !== d) return;
    }
    if (onClose) onClose();
    if (openImport) openImport(d, 'manual');
  };

  // Statistiques du jour — currentDay, pour que les flèches mettent tout à jour.
  const dayTrades = (state?.tradesByDay?.[currentDay]?.trades) || [];
  const n = dayTrades.length;
  const hasTrades = n > 0;
  // Trades pas encore chargés ou en échec : ne pas conclure « aucun trade ce jour ».
  const tradesUnavailable = !hasTrades && !!(state?.tradesError || state?.tradesLoading)
    && !(Array.isArray(state?.trades) && state.trades.length);
  let netPnl = 0, wins = 0, losses = 0, best = -Infinity, worst = Infinity, totalWin = 0, totalLoss = 0, volume = 0;
  for (const t of dayTrades) {
    const p = Number(t.pnl) || 0;
    netPnl += p;
    if (p > 0) { wins++; totalWin += p; }
    else if (p < 0) { losses++; totalLoss += Math.abs(p); }
    if (p > best)  best = p;
    if (p < worst) worst = p;
    const lots = Number(t.lots);
    if (t.lots != null && t.lots !== '' && Number.isFinite(lots)) volume += lots;
  }
  const nuls = n - wins - losses;
  const pf = totalLoss > 0 ? totalWin / totalLoss : (totalWin > 0 ? Infinity : null);
  // Somme nettoyée du bruit binaire (0,1 + 0,2), sans perdre de décimales :
  // 8 décimales, la précision la plus fine d'une quantité (fmtLots).
  const volumeTotal = Math.round(volume * 1e8) / 1e8;
  const date = pg2DayTitle(currentDay);
  const pad = isMobile ? 12 : 24;
  // Captures et humeur du jour affiché, une fois son journal lu.
  const shownAttachments = journalReady ? attachments : [];
  const shownMood = journalReady ? mood : null;
  const viewerAtt = viewer != null ? shownAttachments.find(a => a.id === viewer) : null;
  const viewerUrl = viewerAtt ? previews[viewerAtt.file_path] : null;
  const captureLocked = uploading || !journalReady;
  const captureDimmed = uploading || journalError;

  return (
    <div {...modal.backdropProps} style={{
      position: 'fixed', inset: 0, zIndex: 200,
      background: 'rgba(15,23,42,0.34)',
      backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)',
      // Fenêtre ancrée en haut (ordinateur) et feuille de hauteur fixe (mobile) :
      // l'en-tête et les onglets ne bougent pas d'un onglet à l'autre.
      display: 'flex', alignItems: isMobile ? 'flex-end' : 'flex-start', justifyContent: 'center',
      padding: isMobile ? 0 : '8vh 40px 40px', animation: 'fade-in .25s var(--ease)',
    }}>
      <div {...modal.dialogProps} aria-labelledby="journal-modal-title" style={{
        width: '100%', maxWidth: isMobile ? '100%' : 880, maxHeight: isMobile ? '92dvh' : '100%',
        height: isMobile ? 'min(92dvh, 720px)' : undefined,
        background: 'var(--bg-card)', borderRadius: isMobile ? '16px 16px 0 0' : 16,
        overflow: 'hidden', display: 'flex', flexDirection: 'column',
        boxShadow: '0 24px 48px rgba(0,0,0,.20)', outline: 'none',
        animation: 'fade-up .35s var(--ease)',
      }}>
        {/* En-tête */}
        <div style={{ padding: isMobile ? '14px 12px 12px' : '20px 24px 16px', borderBottom: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: isMobile ? 8 : 14, minWidth: 0 }}>
            <button type="button" onClick={() => shiftDay(-1)} aria-label="Jour précédent" title="Jour précédent" className="tap" style={{ width: 28, height: 28, flexShrink: 0, borderRadius: 8, border: '1px solid var(--line)', background: 'var(--bg-card)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: 'var(--fg-2)' }}>{Ico.arrL}</button>
            <span id="journal-modal-title" style={{ fontSize: isMobile ? 15.5 : 17, fontWeight: 600, letterSpacing: '-.015em', whiteSpace: 'nowrap' }}>{date}</span>
            <button type="button" onClick={() => shiftDay(1)} disabled={!canNext} aria-label="Jour suivant" title={canNext ? 'Jour suivant' : 'Aujourd’hui est le dernier jour'} className="tap" style={{ width: 28, height: 28, flexShrink: 0, borderRadius: 8, border: '1px solid var(--line)', background: 'var(--bg-card)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: canNext ? 'pointer' : 'not-allowed', color: 'var(--fg-2)' }}>{Ico.arrR}</button>
          </div>
          <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexShrink: 0 }}>
            {/* Ajoute un trade DIRECTEMENT sur ce jour (saisie manuelle pré-remplie). */}
            {!futureDay && <button type="button" onClick={addTradeHere} className="btn btn-blue tap" title="Ajouter un trade à cette date" aria-label="Ajouter un trade" style={{ color: '#fff', whiteSpace: 'nowrap' }}>{Ico.plus}{!isMobile && ' Ajouter un trade'}</button>}
            <button type="button" onClick={askClose} aria-label="Fermer" title="Fermer" className="tap tap-44" style={{ width: 28, height: 28, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--fg-2)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>{Ico.x}</button>
          </div>
        </div>

        {/* Onglets (défilants sur mobile) */}
        <div style={{ padding: `14px ${pad}px 0` }}>
          <SegmentedTabs ariaLabel="Sections du journal du jour" idPrefix="journal-day" value={tab} onChange={setTab} scroll={isMobile}
            style={isMobile ? { width: '100%' } : undefined}
            options={[
              { id: 'stats', label: 'Statistiques' },
              { id: 'notes', label: 'Notes' },
              { id: 'trades', label: 'Trades', count: n },
              { id: 'attach', label: 'Captures', count: shownAttachments.length },
            ]} />
        </div>

        {/* Lecture du journal en échec : dit dans les quatre onglets (l'onglet
            ouvert par défaut est Statistiques), avec Réessayer. */}
        {journalError && (
          <div role="alert" style={{ margin: `14px ${pad}px 0`, display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12, padding: '10px 14px', borderRadius: 10, background: 'var(--red-soft)', border: '1px solid var(--line)', color: 'var(--red-text)', fontSize: 13 }}>
            <span>Impossible de charger le journal de ce jour. Vérifie ta connexion.</span>
            <button type="button" onClick={() => setReloadKey(k => k + 1)} className="btn tap" style={{ color: 'var(--fg)' }}>{Ico.refresh} Réessayer</button>
          </div>
        )}

        {/* Contenu de l'onglet : un seul panneau dessiné, celui de l'onglet actif,
            relié à son onglet (aria-controls côté SegmentedTabs) ; les panneaux
            des autres onglets existent, vides et masqués (TabPanelStubs). */}
        <TabPanelStubs idPrefix="journal-day" options={PG2_JOURNAL_TAB_IDS} value={tab}/>
        <div className="scroll" role="tabpanel" id={'journal-day-panel-' + tab} aria-labelledby={'journal-day-tab-' + tab}
          style={{ flex: 1, overflow: 'auto', padding: `20px ${pad}px` }}>
          {tab === 'stats' && (
            <div>
              <div style={{ fontSize: 14, fontWeight: 600, marginBottom: 14, letterSpacing: '-.01em' }}>Statistiques de la journée</div>
              {hasTrades ? (
                <div style={{ display: 'grid', gridTemplateColumns: isMobile ? 'repeat(2, minmax(0, 1fr))' : 'repeat(4, minmax(0, 1fr))', gap: isMobile ? 10 : 14 }}>
                  <ModalStat label="P&L net" value={fmtMoney(netPnl, true)} pos={netPnl > 0} neg={netPnl < 0}/>
                  <ModalStat label="Trades" value={n}/>
                  <ModalStat label="Taux de réussite" value={fmtPct((wins / n) * 100, 1)}/>
                  <ModalStat label="Profit factor" value={pg2PFText(pf, n, wins, losses)}/>
                  <ModalStat label="Gagnants / Perdants" value={pg2WinLoss(wins, losses, nuls)}/>
                  <ModalStat label="Meilleur trade" value={best > 0 ? fmtMoney(best, true) : '—'} pos={best > 0}/>
                  <ModalStat label="Pire trade" value={worst < 0 ? fmtMoney(worst, true) : '—'} neg={worst < 0}/>
                  <ModalStat label="Quantité totale" value={volumeTotal > 0 ? fmtLots(volumeTotal) + ' ' + (volumeTotal >= 2 ? 'lots' : 'lot') : '—'}/>
                </div>
              ) : tradesUnavailable ? (
                <Pg2Unavailable text="Les trades de ce jour s’afficheront dès que la connexion sera rétablie." style={{ padding: '32px 0' }}/>
              ) : (
                <EmptyState icon={Ico.daily} title={EMPTY_DAY_TITLE} text="Tu peux quand même rédiger tes notes et noter ton humeur." compact/>
              )}
              <div style={{ marginTop: 28, paddingTop: 20, borderTop: '1px solid var(--line)' }}>
                <div style={{ fontSize: 14, fontWeight: 600, marginBottom: 12, letterSpacing: '-.01em' }}>Humeur et énergie</div>
                <MoodBlock mood={shownMood} setMood={setMood} disabled={!journalReady || settling} dimmed={journalError}/>
              </div>
            </div>
          )}
          {tab === 'notes' && (
            <div>
              <div style={{ fontSize: 14, fontWeight: 600, marginBottom: 14, letterSpacing: '-.01em' }}>Notes de la journée</div>
              {loadingJournal ? (
                <div role="status" aria-busy="true" aria-label="Chargement des notes" style={{ minHeight: 220, borderRadius: 10, border: '1px solid var(--line)', background: 'var(--bg-soft)', padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
                  {['72%', '88%', '54%'].map((w, i) => <div key={i} style={{ width: w, height: 10, borderRadius: 5, background: 'var(--bg-elev)' }}></div>)}
                </div>
              ) : journalError ? null : (
                // Lecture en échec : ni zone de saisie ni compteur (le message
                // et « Réessayer » sont sous les onglets).
                <textarea
                  className="input"
                  aria-label="Notes de la journée"
                  // Enregistrement attendu (fermeture, changement de jour,
                  // « Réessayer ») : lecture seule, pour que rien ne soit tapé
                  // puis perdu ou attribué au jour suivant.
                  readOnly={settling} aria-busy={settling ? 'true' : undefined}
                  value={notes} onChange={e => debouncedSaveNotes(e.target.value)}
                  placeholder={'Écris tes notes du jour ici… Qu’est-ce qui s’est bien passé ? Qu’est-ce que tu peux améliorer ?'}
                  style={{
                    minHeight: 220, padding: '14px 16px', borderRadius: 10,
                    fontSize: 13.5, lineHeight: 1.6, resize: 'vertical',
                  }}/>
              )}
              {journalReady && (
                <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 10 }}>
                  {/* Échec d'enregistrement : le message est dans le pied de la fenêtre. */}
                  <span style={{ fontSize: 11.5, color: 'var(--fg-3)' }}>{fmtNum(notes.length, 0)} caractère{notes.length > 1 ? 's' : ''}{saveError ? '' : ' · sauvegarde automatique'}</span>
                </div>
              )}
            </div>
          )}
          {tab === 'trades' && (
            <div>
              <div style={{ fontSize: 14, fontWeight: 600, marginBottom: 14, letterSpacing: '-.01em' }}>Trades du jour</div>
              {hasTrades ? <TradeTable trades={dayTrades} columns={isMobile ? PG2_COLS_NARROW : PG2_COLS_COMPACT} widths={isMobile ? undefined : PG2_WIDTHS_DAY} openTrade={openTrade}/> : tradesUnavailable ? (
                <Pg2Unavailable text="Les trades de ce jour s’afficheront dès que la connexion sera rétablie." style={{ padding: '32px 0' }}/>
              ) : (
                <EmptyState icon={Ico.trades} title={EMPTY_DAY_TITLE} compact
                  text={isMobile || futureDay ? undefined : 'Pour en saisir un à cette date, utilise «\u00A0Ajouter un trade\u00A0» en haut de la fenêtre.'}
                  secondary={isMobile && !futureDay ? { label: 'Ajouter un trade', onClick: addTradeHere, ico: Ico.plus } : null}/>
              )}
            </div>
          )}
          {tab === 'attach' && (
            <div>
              <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 10, marginBottom: 14 }}>
                <div style={{ fontSize: 14, fontWeight: 600, letterSpacing: '-.01em' }}>Captures <span style={{ fontSize: 11, color: 'var(--fg-3)', fontWeight: 500, marginLeft: 6 }}>{shownAttachments.length}</span></div>
                <button type="button" onClick={pickFiles} className="btn btn-outline-blue tap" disabled={captureLocked} style={{ opacity: captureDimmed ? .55 : 1, cursor: captureDimmed ? 'not-allowed' : 'pointer' }}>{Ico.upload} {uploading ? 'Envoi…' : 'Ajouter une capture'}</button>
                <input ref={inputRef} type="file" accept={DAY_ATTACHMENT_MIME.join(',')} multiple style={{ display: 'none' }}
                  onChange={e => { const files = Array.from(e.target.files || []); e.target.value = ''; addFiles(files); }}/>
              </div>

              {attachError && (
                <div role="alert" style={{ marginBottom: 12, padding: '8px 12px', borderRadius: 8, background: 'var(--red-soft)', color: 'var(--red-text)', fontSize: 12.5, border: '1px solid var(--line)', whiteSpace: 'pre-line', overflowWrap: 'anywhere' }}>{attachError}</div>
              )}

              {/* preventDefault même zone inactive : un fichier lâché ici ne doit
                  jamais remplacer la page par son contenu. */}
              <div
                role="button" tabIndex={0}
                aria-label="Ajouter des captures d’écran"
                aria-disabled={captureLocked ? 'true' : undefined}
                onDragOver={e => { e.preventDefault(); if (!captureLocked) setDrag(true); }}
                onDragLeave={() => setDrag(false)}
                onDrop={e => { e.preventDefault(); setDrag(false); if (!captureLocked) addFiles(e.dataTransfer.files); }}
                onClick={pickFiles}
                onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pickFiles(); } }}
                style={{
                  border: '1.5px dashed ' + (drag ? 'var(--blue)' : 'var(--line-2)'),
                  background: drag ? 'var(--blue-soft)' : 'var(--bg-soft)',
                  borderRadius: 12, padding: '32px 20px', textAlign: 'center',
                  color: 'var(--fg-2)', cursor: uploading ? 'progress' : (journalError ? 'not-allowed' : 'pointer'), transition: 'all .15s var(--ease)',
                  opacity: journalError ? .55 : 1,
                }}>
                <svg width="36" height="36" viewBox="0 0 42 42" style={{ margin: '0 auto 10px', color: drag ? 'var(--blue)' : 'var(--fg-3)' }}>
                  <rect x="6" y="8" width="30" height="22" rx="3" fill="none" stroke="currentColor" strokeWidth="1.5"/>
                  <circle cx="14" cy="16" r="2.5" fill="none" stroke="currentColor" strokeWidth="1.5"/>
                  <path d="M6 26l8-8 6 6 4-4 12 10" fill="none" stroke="currentColor" strokeWidth="1.5"/>
                </svg>
                <div style={{ fontSize: 13.5, fontWeight: 600, color: drag ? 'var(--blue-600)' : 'var(--fg)', marginBottom: 4 }}>
                  {drag ? 'Relâche pour ajouter' : (uploading ? 'Envoi en cours…' : isMobile ? 'Choisis tes captures d’écran' : 'Glisse tes captures d’écran ici')}
                </div>
                <div style={{ fontSize: 11.5, color: 'var(--fg-2)', lineHeight: 1.45 }}>{isMobile ? '' : 'ou clique · '}PNG, JPEG, WebP, GIF ou HEIC · 10 Mo max. · plusieurs fichiers possibles</div>
              </div>

              {shownAttachments.length > 0 && (
                <div style={{ marginTop: 14, display: 'grid', gridTemplateColumns: isMobile ? 'repeat(2, minmax(0, 1fr))' : 'repeat(3, minmax(0, 1fr))', gap: 10 }}>
                  {shownAttachments.map(a => {
                    const previewUrl = previews[a.file_path];
                    const isImage = (a.mime_type || '').startsWith('image/');
                    const confirming = confirmAttach === a.id;
                    const removing = removingAttach === a.id;
                    return (
                      <div key={a.id} style={{ position: 'relative', borderRadius: 10, border: '1px solid var(--line)', overflow: 'hidden', background: 'var(--bg-soft)' }}>
                        <div style={{ height: 110, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'linear-gradient(135deg, var(--bg-elev), var(--bg-soft))', color: 'var(--fg-3)' }}>
                          {isImage && previewUrl ? (
                            // Une vignette de 110 px ne se lit pas : un clic l'ouvre en grand.
                            <button type="button" onClick={() => setViewer(a.id)} className="tap"
                              aria-label={'Ouvrir la capture ' + a.file_name} title="Ouvrir en grand"
                              style={{ width: '100%', height: '100%', padding: 0, border: 'none', background: 'transparent', cursor: 'zoom-in', display: 'block', fontFamily: 'inherit', color: 'inherit' }}>
                              <img src={previewUrl} alt={a.file_name} onError={() => refreshPreview(a)} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}/>
                            </button>
                          ) : previewUrl ? (
                            // Fichier qui n'est pas une image (ancien PDF) : ouvert par le navigateur.
                            <a href={previewUrl} target="_blank" rel="noopener noreferrer" className="tap"
                              aria-label={'Ouvrir ' + a.file_name + ' dans un nouvel onglet'} title="Ouvrir dans un nouvel onglet"
                              style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'inherit' }}>
                              {React.cloneElement(Ico.data, { width: 28, height: 28 })}
                            </a>
                          ) : (
                            React.cloneElement(Ico.data, { width: 28, height: 28 })
                          )}
                        </div>
                        <div style={{ padding: '8px 10px', borderTop: '1px solid var(--line)' }}>
                          <div style={{ fontSize: 11.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{a.file_name}</div>
                          <div style={{ fontSize: 10, color: 'var(--fg-3)', marginTop: 2 }}>{fmtNum(Number(a.size_bytes) / 1024, 0)}{' '}ko</div>
                        </div>
                        {confirming ? (
                          <div role="group" aria-label={'Supprimer cette capture ?'} style={{ position: 'absolute', inset: 0, background: 'rgba(15,23,42,0.86)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, padding: 10, textAlign: 'center' }}>
                            <span style={{ fontSize: 12.5, fontWeight: 600, color: '#fff' }}>Supprimer cette capture{' '}?</span>
                            <span style={{ fontSize: 11, color: 'rgba(255,255,255,0.82)' }}>Elle sera effacée définitivement.</span>
                            <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
                              <button type="button" onClick={() => removeAttach(a.id)} disabled={removing} className="btn tap" style={{ background: 'var(--sell-bg, #dc2626)', borderColor: 'var(--sell-bg, #dc2626)', color: '#fff', padding: '5px 10px', fontSize: 12, opacity: removing ? .55 : 1, cursor: removing ? 'not-allowed' : 'pointer' }}>{removing ? 'Suppression…' : 'Supprimer'}</button>
                              <button type="button" onClick={() => setConfirmAttach(null)} disabled={removing} className="btn tap" style={{ color: 'var(--fg)', padding: '5px 10px', fontSize: 12, opacity: removing ? .55 : 1, cursor: removing ? 'not-allowed' : 'pointer' }}>Annuler</button>
                            </div>
                          </div>
                        ) : (
                          <button type="button" onClick={(e) => { e.stopPropagation(); setAttachError(''); setConfirmAttach(a.id); }} aria-label="Supprimer la capture" title="Supprimer la capture" className="tap" style={{
                            position: 'absolute', top: 6, right: 6,
                            width: 26, height: 26, borderRadius: 6, border: 'none',
                            background: 'rgba(15,23,42,0.65)', color: '#fff', cursor: 'pointer',
                            display: 'flex', alignItems: 'center', justifyContent: 'center',
                          }}>{Ico.x}</button>
                        )}
                      </div>
                    );
                  })}
                </div>
              )}
            </div>
          )}
        </div>

        {/* Question « Abandonner ta saisie ? » : seulement quand des notes ou une
            humeur n'ont pas pu être enregistrées (ou le sont encore). */}
        {modal.confirmDiscard && (
          <div style={{ padding: isMobile ? '0 12px 12px' : '0 24px 14px' }}>
            <ModalDiscardConfirm modal={modal} detail={settling && !unsaved
              ? 'L’enregistrement est encore en cours.'
              : 'Ce qui n’a pas pu être enregistré sera perdu.'}/>
          </div>
        )}

        {/* Pied */}
        <div style={{ padding: isMobile ? '12px 12px calc(12px + env(safe-area-inset-bottom))' : '14px 24px', borderTop: '1px solid var(--line)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10, background: 'var(--bg-soft)' }}>
          {saveError ? (
            <div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 10, minWidth: 0 }}>
              <span role="alert" style={{ fontSize: 11, color: 'var(--red-text)' }}>{saveError}</span>
              <button type="button" onClick={retrySaves} disabled={settling} className="btn tap" style={{ padding: isMobile ? '0 12px' : '4px 10px', minHeight: isMobile ? 44 : 0, fontSize: 12, color: 'var(--fg)' }}>{settling ? 'Enregistrement…' : 'Réessayer'}</button>
            </div>
          ) : settling
            ? <span role="status" style={{ fontSize: 11, color: 'var(--fg-3)' }}>Enregistrement…</span>
            : <span style={{ fontSize: 11, color: 'var(--fg-3)' }}>Tes notes et ton humeur sont enregistrées automatiquement.</span>}
          <button type="button" onClick={askClose} className="btn tap" style={{ color: 'var(--fg)', flexShrink: 0 }}>Fermer</button>
        </div>
      </div>
      <CaptureViewer open={!!(viewerAtt && viewerUrl)} name={viewerAtt ? viewerAtt.file_name : ''} url={viewerUrl || ''}
        onClose={() => setViewer(null)} onImageError={() => refreshPreview(viewerAtt)}/>
    </div>
  );
}

// Capture ouverte en grand, par-dessus la fenêtre du jour : image entière
// (jamais rognée), bornée à l'écran. Modale à part entière (useModalBehavior) :
// Échap ne ferme qu'elle, le focus y reste puis revient sur la vignette.
function CaptureViewer({ open, name, url, onClose, onImageError }) {
  const modal = useModalBehavior({ open, onRequestClose: onClose, isDirty: false, labelledBy: 'capture-viewer-title' });
  const [broken, setBroken] = React.useState(false);
  React.useEffect(() => { setBroken(false); }, [open, url]);
  if (!open) return null;
  return (
    <div {...modal.backdropProps} style={{
      position: 'fixed', inset: 0, zIndex: 230, background: 'rgba(15,23,42,0.88)',
      display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16,
      animation: 'fade-in .2s var(--ease)',
    }}>
      <div {...modal.dialogProps} aria-labelledby="capture-viewer-title" style={{ display: 'flex', flexDirection: 'column', gap: 10, minWidth: 'min(320px, 100%)', maxWidth: '100%', maxHeight: '100%', outline: 'none' }}>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 10 }}>
          <span id="capture-viewer-title" style={{ flex: '1 1 120px', minWidth: 0, fontSize: 13, fontWeight: 600, color: '#fff', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{name}</span>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0, marginLeft: 'auto' }}>
            <a href={url} target="_blank" rel="noopener noreferrer" className="btn tap" style={{ color: 'var(--fg)', textDecoration: 'none' }}>Ouvrir dans un nouvel onglet</a>
            <button type="button" onClick={modal.requestClose} aria-label="Fermer" title="Fermer" className="btn tap tap-44" style={{ color: 'var(--fg)', padding: 0, width: 32, height: 32, justifyContent: 'center' }}>{Ico.x}</button>
          </div>
        </div>
        {broken ? (
          <div role="alert" style={{ padding: '28px 20px', borderRadius: 8, background: 'var(--bg-card)', color: 'var(--fg-2)', fontSize: 13, lineHeight: 1.5, textAlign: 'center', maxWidth: 420, alignSelf: 'center' }}>
            Impossible d’afficher cette capture ici. Essaie de l’ouvrir dans un nouvel onglet.
          </div>
        ) : (
          <img src={url} alt={name} onError={() => { setBroken(true); if (onImageError) onImageError(); }}
            style={{ alignSelf: 'center', maxWidth: '100%', maxHeight: 'calc(100dvh - 90px)', objectFit: 'contain', borderRadius: 8, background: 'var(--bg-soft)' }}/>
        )}
      </div>
    </div>
  );
}

function ModalStat({ label, value, pos, neg, text }) {
  return (
    <div className="card" style={{ padding: '14px 16px', background: 'var(--bg-soft)', border: '1px solid var(--line)', minWidth: 0 }}>
      {/* Le libellé passe à la ligne plutôt que d'être tronqué (« Gagnants / Perdants » en 320 px). */}
      <div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--fg-3)', letterSpacing: '.04em', textTransform: 'uppercase', marginBottom: 6, lineHeight: 1.3 }}>{label}</div>
      <div className="num" style={{ fontSize: 18, fontWeight: 600, letterSpacing: '-.018em', color: pos ? 'var(--green)' : (neg ? 'var(--red)' : 'var(--fg)'), whiteSpace: text ? 'normal' : 'nowrap', overflowWrap: text ? 'anywhere' : 'normal' }}>{value}</div>
    </div>
  );
}

// Humeur de 1 à 5. Re-cliquer sur l'humeur active la retire (une humeur posée
// par erreur n'est pas définitive). `disabled` : journal du jour non lu,
// aucune saisie possible (ou enregistrement attendu) ; `dimmed` : lecture en
// échec, boutons estompés. Chaque bouton dit ce qu'il note (« Humeur 1 sur 5,
// frustré ») : l'échelle écrite dessous est reprise dans les noms.
const PG2_MOOD_WORDS = { 1: 'frustré', 3: 'neutre', 5: 'excellent' };
function MoodBlock({ mood, setMood, disabled = false, dimmed = false }) {
  const m = mood;
  return (
    <div>
      <div role="group" aria-label="Humeur et énergie, de 1 (frustré) à 5 (excellent)" style={{ display: 'flex', gap: 8, opacity: dimmed ? .55 : 1 }}>
        {[1, 2, 3, 4, 5].map(n => (
          <button key={n} type="button" onClick={() => setMood(m === n ? null : n)} disabled={disabled} className="tap" aria-pressed={m === n}
            aria-label={'Humeur ' + n + ' sur 5' + (PG2_MOOD_WORDS[n] ? ', ' + PG2_MOOD_WORDS[n] : '')}
            title={m === n ? 'Cliquer à nouveau pour retirer' : undefined} style={{
            flex: 1, padding: '12px 0', border: '1.5px solid ' + (m === n ? 'var(--blue)' : 'var(--line)'),
            background: m === n ? 'var(--blue-soft)' : 'var(--bg-card)',
            color: m === n ? 'var(--blue-600)' : 'var(--fg-2)',
            borderRadius: 9, fontSize: 18, fontWeight: 600, cursor: dimmed ? 'not-allowed' : 'pointer', fontFamily: 'inherit',
            letterSpacing: '-.01em',
          }}>{n}</button>
        ))}
      </div>
      <div aria-hidden="true" style={{ display: 'flex', justifyContent: 'space-between', marginTop: 8, fontSize: 11, color: 'var(--fg-3)' }}>
        <span>Frustré</span>
        <span>Neutre</span>
        <span>Excellent</span>
      </div>
    </div>
  );
}

// ─── Détail d'un trade (voir / modifier / supprimer) ──────────────

// Date et heure d'un trade pour <input type="datetime-local">, dans le fuseau
// du profil (jamais celui du navigateur).
function pg2DtLocal(iso) {
  if (!iso) return '';
  const d = new Date(iso);
  if (isNaN(d.getTime())) return '';
  return localDayInTz(d) + 'T' + tzTimeIn(d);
}

// Valeurs du formulaire d'édition, au format français (virgule), relues par
// parseDecimalFr. Une seule source pour l'ouverture, « Annuler » et la
// détection d'une saisie non enregistrée.
function pg2TradeForm(trade) {
  const t = trade || {};
  const shown = (v, f) => {
    if (v === null || v === undefined || v === '') return '';
    const s = f(v);
    return s === '—' ? '' : s;
  };
  const dec = currencyDecimals(window.__appCurrency);
  const dur = Number(t.duration_min);
  return {
    symbol: t.symbol || '',
    direction: t.direction === 'short' ? 'short' : 'long',
    entry: shown(t.entry, v => fmtPrice(v)),
    exit_price: shown(t.exit_price, v => fmtPrice(v)),
    lots: shown(t.lots, v => fmtLots(v)),
    pnl: shown(t.pnl, v => fmtNum(v, dec)),
    r_multiple: shown(t.r_multiple, v => fmtNum(v, 2)),
    // Date et heure séparées, dans le fuseau du profil (mêmes champs qu'à l'ajout).
    trade_date: pg2DtLocal(t.executed_at).slice(0, 10),
    trade_time: pg2DtLocal(t.executed_at).slice(11, 16),
    duration: t.duration_min != null && Number.isFinite(dur) && dur > 0 ? String(Math.round(dur)) : '',
    setup: t.setup || '',
    notes: t.notes || '',
  };
}

// Champs numériques du formulaire d'édition.
const PG2_NUM_KEYS = ['entry', 'exit_price', 'lots', 'pnl', 'r_multiple'];

// Message « nombre invalide » d'un champ : celui de l'ajout (tradeNumFieldError,
// import-modal.jsx), pour que la même faute de frappe donne le même message à
// l'ajout et à l'édition (charte §5).
function pg2NumFieldError(key) {
  return tradeNumFieldError(key);
}

// Clé de comparaison d'un nom de stratégie : casse et espaces de bord ignorés
// (même règle que tradeStrategyCanonical, import-modal.jsx).
function pg2StratKey(name) {
  return String(name == null ? '' : name).trim().toLowerCase();
}

// Après l'écriture d'un trade : la ligne modifiée (ou retirée) est appliquée à
// la liste en mémoire quand l'état le permet (state.applyTradeChange,
// state.jsx), au lieu de relire tout l'historique — une requête par millier de
// trades. Repli sur la relecture complète quand la ligne écrite n'est pas
// connue ou que l'état n'offre pas cette mise à jour.
async function pg2AfterTradeWrite(state, change) {
  if (!state) return;
  const usable = change && (change.removeId != null || (change.upsert && change.upsert.id != null));
  if (usable && typeof state.applyTradeChange === 'function') { await state.applyTradeChange(change); return; }
  if (typeof state.refreshTrades === 'function') await state.refreshTrades();
}

function TradeDetailModal({ open, trade, onClose, state }) {
  const [mode, setMode] = React.useState('view'); // 'view' | 'edit'
  const [busy, setBusy] = React.useState(false);
  const [error, setError] = React.useState('');
  const [fieldErr, setFieldErr] = React.useState(null); // { field, msg } : erreur de saisie sous le champ
  const [confirmDelete, setConfirmDelete] = React.useState(false);
  const [form, setForm] = React.useState(() => pg2TradeForm(null));
  const fieldRefs = React.useRef({});
  const sending = React.useRef(false);
  const isMobile = usePg2Mobile();

  const isDirty = !!(open && trade && mode === 'edit'
    && JSON.stringify(form) !== JSON.stringify(pg2TradeForm(trade)));
  const modal = useModalBehavior({ open: !!(open && trade), onRequestClose: onClose, isDirty, labelledBy: 'trade-detail-title' });

  // Réinitialise à l'ouverture, à la fermeture et au changement de trade.
  React.useEffect(() => {
    if (!open) {
      setMode('view'); setError(''); setFieldErr(null); setConfirmDelete(false); setBusy(false);
      sending.current = false;
      return;
    }
    if (trade) setForm(pg2TradeForm(trade));
  }, [open, trade?.id]);

  // Stratégies déjà existantes SANS ce trade : table strategies (relue à
  // l'ouverture) et noms portés par les AUTRES trades (useTradeStrategyOptions,
  // import-modal.jsx). C'est contre elles que la saisie est ramenée à une
  // graphie existante : un nom que ce trade est seul à porter n'est pas une
  // stratégie « existante », sinon corriger sa casse serait impossible.
  // Hooks appelés avant tout retour conditionnel.
  const shown = !!(open && trade);
  const tradeId = trade ? trade.id : null;
  const allTrades = state && state.trades;
  const otherTrades = React.useMemo(() => (
    shown && Array.isArray(allTrades) ? allTrades.filter(t => !t || t.id !== tradeId) : allTrades
  ), [shown, tradeId, allTrades]);
  const canonNames = useTradeStrategyOptions(shown, otherTrades);
  // Suggestions du champ Stratégie : les mêmes qu'à l'ajout, donc avec le nom
  // de ce trade s'il est seul à le porter.
  const ownSetup = trade && trade.setup ? String(trade.setup) : '';
  const strategyNames = React.useMemo(() => {
    const k = pg2StratKey(ownSetup);
    if (!k || canonNames.some(n => pg2StratKey(n) === k)) return canonNames;
    return canonNames.concat(ownSetup).sort((a, b) => a.localeCompare(b, 'fr'));
  }, [canonNames, ownSetup]);

  // Focus au changement de mode (charte §8 : le focus va au premier champ et
  // ne quitte pas la modale). Le pied est démonté d'un mode à l'autre (clés
  // « view » / « edit ») : sans cela le focus retomberait sur <body>.
  //   lecture → édition : champ Symbole (au doigt : la boîte de dialogue, pour
  //                       ne pas ouvrir le clavier d'office) ;
  //   édition → lecture : bouton « Modifier », si le focus a quitté la modale.
  // Dans un requestAnimationFrame : un focus posé pendant l'appui sur Entrée
  // enverrait la suite de la frappe dans le champ, donc le formulaire.
  const editBtnRef = React.useRef(null);
  const shownMode = open && trade ? mode : null;
  const prevMode = React.useRef(shownMode);
  React.useEffect(() => {
    const was = prevMode.current;
    prevMode.current = shownMode;
    if (!shownMode || !was || was === shownMode) return undefined;
    const raf = requestAnimationFrame(() => {
      const box = modal.dialogRef && modal.dialogRef.current;
      if (!box) return;
      if (shownMode === 'edit') {
        let coarse = false;
        try { coarse = window.matchMedia('(pointer: coarse)').matches; } catch (e) {}
        const field = coarse ? null : fieldRefs.current.symbol;
        (field || box).focus({ preventScroll: true });
      } else if (!box.contains(document.activeElement) || document.activeElement === box) {
        (editBtnRef.current || box).focus({ preventScroll: true });
      }
    });
    return () => cancelAnimationFrame(raf);
  }, [shownMode]);

  if (!open || !trade) return null;

  const upd = (k) => (e) => {
    const v = e.target.value;
    setForm(f => ({ ...f, [k]: v }));
    if (error) setError('');
    if (fieldErr && (fieldErr.field === k || (k === 'trade_time' && fieldErr.field === 'trade_date'))) setFieldErr(null);
  };
  const refFor = (k) => (el) => { fieldRefs.current[k] = el; };
  // Erreur de saisie : message sous le champ concerné, focus sur ce champ
  // (même comportement qu'à l'ajout).
  const failField = (field, msg) => {
    setFieldErr({ field, msg });
    requestAnimationFrame(() => {
      const el = fieldRefs.current[field];
      if (el && typeof el.focus === 'function') el.focus();
    });
  };
  // Colonne duration_min : présente dans la ligne lue si la migration durée a été exécutée.
  const hasDurationColumn = Object.prototype.hasOwnProperty.call(trade, 'duration_min');

  const submitEdit = async (e) => {
    if (e && e.preventDefault) e.preventDefault();
    if (sending.current) return;
    setError(''); setFieldErr(null);
    const init = pg2TradeForm(trade);
    // Un champ n'est contrôlé — et envoyé — que s'il a été modifié. Une valeur
    // d'origine que le formulaire ne sait pas reproduire à l'identique (P&L
    // importé à 3 décimales, quantité très petite, date illisible…) ne doit
    // jamais empêcher d'enregistrer un autre champ. Mêmes contrôles, même ordre
    // et mêmes messages qu'à l'ajout (ManualTradeForm, import-modal.jsx).
    const changed = (k) => form[k] !== init[k];
    const symbol = String(form.symbol || '').trim().toUpperCase();
    if (changed('symbol') && !symbol) { failField('symbol', 'Indique le symbole, par exemple ES, NQ ou EURUSD.'); return; }
    const values = {};
    for (const k of PG2_NUM_KEYS) {
      if (!changed(k)) continue;
      const v = parseDecimalFr(form[k]);
      if (v === INVALID_NUM) { failField(k, pg2NumFieldError(k)); return; }
      values[k] = v;
    }
    if (changed('lots') && values.lots != null && values.lots <= 0) { failField('lots', 'La quantité doit être supérieure à 0.'); return; }
    if (changed('pnl')) {
      if (values.pnl == null) { failField('pnl', TRADE_MSG_PNL_REQUIRED); return; }
      const decErr = tradePnlDecimalsError(values.pnl);
      if (decErr) { failField('pnl', decErr); return; }
    }
    let executedAt = null;
    const whenChanged = changed('trade_date') || changed('trade_time');
    if (whenChanged) {
      const d = String(form.trade_date || '').trim();
      const h = String(form.trade_time || '').trim();
      if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) { failField('trade_date', 'Date ou heure invalide.'); return; }
      // Une heure vidée n'est pas remplacée par midi à l'insu de l'utilisateur.
      if (!h) { failField('trade_time', TRADE_MSG_TIME_REQUIRED); return; }
      // near : pendant l'heure répétée du retour à l'heure d'hiver, l'heure
      // saisie désigne le plus tardif des deux passages déjà écoulés (même
      // règle que la saisie manuelle, import-modal.jsx).
      executedAt = zonedToUtcISO(d, h, undefined, { near: Date.now() + 60000 });
      if (!executedAt) { failField('trade_date', 'Date ou heure invalide.'); return; }
      if (d > todayISO() || Date.parse(executedAt) > Date.now() + 60000) {
        failField('trade_date', 'Ce trade est dans le futur\u00A0: vérifie la date et l’heure.'); return;
      }
    }
    let durationMin = null;
    if (changed('duration')) {
      durationMin = parseDurationMin(form.duration);
      if (Number.isNaN(durationMin)) { failField('duration', 'Durée invalide\u00A0: écris des minutes (90) ou 1\u00A0h\u00A030.'); return; }
      if (durationMin != null && durationMin <= 0) { failField('duration', 'La durée doit être supérieure à 0.'); return; }
    }
    // Limites de longueur de la base, sur les seuls textes modifiés.
    // Stratégie : graphie déjà existante quand seuls la casse ou les espaces de
    // bord diffèrent, comme à l'ajout (le lien trade ↔ stratégie est le nom exact).
    // « Existante » s'entend sans ce trade (canonNames) : la table ou un autre
    // trade la porte → sa graphie l'emporte ; ce trade seul → la saisie fait foi.
    const setup = changed('setup') ? tradeStrategyCanonical(form.setup, canonNames) : '';
    // La saisie retombe sur la valeur déjà enregistrée : rien à envoyer pour la
    // stratégie. Si seule la casse a été retouchée, ce n'est pas ignoré en
    // silence : le champ reprend la graphie conservée et le dit (rien n'est
    // envoyé ; « Enregistrer » de nouveau envoie les autres changements).
    const setupSame = changed('setup') && setup === (trade.setup || '');
    if (setupSame && setup && String(form.setup || '').trim() !== setup) {
      setForm(f => ({ ...f, setup }));
      failField('setup', 'Cette stratégie existe déjà, écrite «\u00A0' + setup + '\u00A0»\u00A0: c’est cette écriture qui est conservée. Pour la renommer partout, passe par l’écran ' + (pg2Nav('strategy').titre || 'Stratégies') + '.');
      return;
    }
    const tooLong = tradeTextLimitError({
      symbol: changed('symbol') ? symbol : '',
      setup,
      notes: changed('notes') ? form.notes : '',
    });
    if (tooLong) { failField(tooLong.field, tooLong.msg); return; }

    // Seuls les champs modifiés sont envoyés : une valeur non touchée n'est
    // jamais réécrite (pas d'arrondi involontaire d'un prix ou d'un R).
    const patch = {};
    if (changed('symbol')) patch.symbol = symbol;
    if (changed('direction')) patch.direction = form.direction === 'short' ? 'short' : 'long';
    for (const k of PG2_NUM_KEYS) {
      if (changed(k)) patch[k] = values[k];
    }
    if (whenChanged) patch.executed_at = executedAt;
    if (changed('duration')) {
      if (hasDurationColumn) patch.duration_min = durationMin;
      else console.warn('[Tempo] Colonne duration_min absente de la table trades (migration durée non exécutée) : durée non enregistrée.');
    }
    if (changed('setup') && !setupSame) patch.setup = setup || null;
    if (changed('notes')) patch.notes = form.notes || null;

    if (Object.keys(patch).length === 0) { setMode('view'); return; }

    sending.current = true;
    setBusy(true);
    try {
      const { data: saved, error: err } = await window.updateTrade(trade.id, patch);
      if (err) {
        console.warn('[Tempo] updateTrade', err);
        setError('Impossible d’enregistrer le trade : ' + sbErrorMessage(err));
        return;
      }
      // La ligne renvoyée par l'écriture remplace l'ancienne en mémoire.
      await pg2AfterTradeWrite(state, { upsert: saved });
      setMode('view');
      if (onClose) onClose();
    } catch (err) {
      console.warn('[Tempo] updateTrade', err);
      setError('Impossible d’enregistrer le trade : ' + sbErrorMessage(err));
    } finally {
      sending.current = false;
      setBusy(false);
    }
  };

  const doDelete = async () => {
    if (sending.current) return;
    sending.current = true;
    setBusy(true); setError('');
    try {
      const { error: err } = await window.deleteTrade(trade.id);
      if (err) {
        console.warn('[Tempo] deleteTrade', err);
        setError('Impossible de supprimer le trade : ' + sbErrorMessage(err));
        return;
      }
      await pg2AfterTradeWrite(state, { removeId: trade.id });
      if (onClose) onClose();
    } catch (err) {
      console.warn('[Tempo] deleteTrade', err);
      setError('Impossible de supprimer le trade : ' + sbErrorMessage(err));
    } finally {
      sending.current = false;
      setBusy(false);
    }
  };

  const cancelEdit = () => { setForm(pg2TradeForm(trade)); setMode('view'); setError(''); setFieldErr(null); };

  const pnl = Number(trade.pnl) || 0;
  const hasR = trade.r_multiple !== null && trade.r_multiple !== undefined && trade.r_multiple !== '' && Number.isFinite(Number(trade.r_multiple));
  const r = Number(trade.r_multiple);
  const sell = trade.direction === 'short';
  const duration = Number(trade.duration_min);
  const busyStyle = busy ? { opacity: .55, cursor: 'not-allowed' } : null;
  return (
    <div {...modal.backdropProps} style={{
      position: 'fixed', inset: 0, zIndex: 220,
      background: 'rgba(15,23,42,0.40)',
      backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)',
      display: 'flex', alignItems: isMobile ? 'flex-end' : 'center', justifyContent: 'center',
      padding: isMobile ? 0 : 24, animation: 'fade-in .25s var(--ease)',
    }}>
      <div {...modal.dialogProps} aria-labelledby="trade-detail-title" style={{
        width: '100%', maxWidth: isMobile ? '100%' : 640, maxHeight: isMobile ? '92dvh' : '92%',
        background: 'var(--bg-card)', borderRadius: isMobile ? '16px 16px 0 0' : 16,
        overflow: 'hidden', display: 'flex', flexDirection: 'column',
        boxShadow: '0 28px 56px rgba(0,0,0,.24)', outline: 'none',
        animation: 'fade-up .35s var(--ease)',
      }}>
        {/* En-tête */}
        <div style={{ padding: isMobile ? '14px 12px' : '18px 22px', borderBottom: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
          <div style={{ minWidth: 0 }}>
            <div id="trade-detail-title" style={{ fontSize: 15, fontWeight: 700, letterSpacing: '-.015em' }}>{trade.symbol || '—'} · <span style={{ color: sell ? 'var(--red-text)' : 'var(--green-text)', fontWeight: 600 }}>{sell ? 'Vente' : 'Achat'}</span></div>
            <div style={{ fontSize: 11.5, color: 'var(--fg-3)', marginTop: 2 }}>{fmtDateTime(trade.executed_at)} · {tradeSourceLabel(trade.imported_from)}</div>
          </div>
          <button type="button" onClick={modal.requestClose} aria-label="Fermer" title="Fermer" className="tap tap-44" style={{ width: 28, height: 28, flexShrink: 0, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--fg-2)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>{Ico.x}</button>
        </div>

        {/* Contenu */}
        <div className="scroll" style={{ flex: 1, overflow: 'auto', padding: isMobile ? '16px 12px' : '18px 22px' }}>
          {error && (
            <div role="alert" style={{ marginBottom: 12, padding: '8px 12px', borderRadius: 8, background: 'var(--red-soft)', color: 'var(--red-text)', fontSize: 12.5, border: '1px solid var(--line)' }}>{error}</div>
          )}

          {mode === 'view' && (
            <div>
              <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 12, marginBottom: 16 }}>
                <ModalStat label="P&L" value={fmtMoney(trade.pnl, true)} pos={pnl > 0} neg={pnl < 0}/>
                <ModalStat label="R multiple" value={fmtR(trade.r_multiple)} pos={hasR && r > 0} neg={hasR && r < 0}/>
                <ModalStat label="Entrée" value={fmtPrice(trade.entry, priceDigitsOf(trade.entry, trade.exit_price))}/>
                <ModalStat label="Sortie" value={fmtPrice(trade.exit_price, priceDigitsOf(trade.entry, trade.exit_price))}/>
                <ModalStat label="Quantité" value={trade.lots !== null && trade.lots !== undefined && trade.lots !== '' && Number.isFinite(Number(trade.lots)) ? fmtLots(trade.lots) + ' ' + (Math.abs(Number(trade.lots)) >= 2 ? 'lots' : 'lot') : '—'}/>
                <ModalStat label="Stratégie" value={trade.setup || '—'} text/>
                {trade.duration_min != null && Number.isFinite(duration) && duration > 0 && (
                  <ModalStat label="Durée" value={fmtDuration(duration)}/>
                )}
              </div>
              {trade.notes && (
                <div style={{ marginBottom: 12 }}>
                  <div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--fg-3)', letterSpacing: '.06em', textTransform: 'uppercase', marginBottom: 6 }}>Notes</div>
                  <div style={{ fontSize: 13, lineHeight: 1.55, color: 'var(--fg-2)', whiteSpace: 'pre-wrap', padding: '10px 12px', borderRadius: 8, background: 'var(--bg-soft)', border: '1px solid var(--line)' }}>{trade.notes}</div>
                </div>
              )}
            </div>
          )}

          {mode === 'edit' && (
            <form id="trade-edit-form" onSubmit={submitEdit} noValidate>
              {/* Mêmes champs, même ordre et mêmes aides qu'à l'ajout (TradeFields, import-modal.jsx). */}
              <TradeFields idPrefix="trade-edit-" form={form} onField={upd} err={fieldErr} refFor={refFor}
                strategies={strategyNames} isMobile={isMobile}/>
              {/* Bouton caché : Entrée valide le formulaire depuis un champ. */}
              <button type="submit" disabled={busy} style={{ display: 'none' }}/>
            </form>
          )}
        </div>

        {/* Pied */}
        <div style={{ padding: isMobile ? '12px 12px calc(12px + env(safe-area-inset-bottom))' : '14px 22px', borderTop: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 10, background: 'var(--bg-soft)' }}>
          {modal.confirmDiscard ? (
            <ModalDiscardConfirm modal={modal} style={{ width: '100%' }}/>
          ) : mode === 'view' ? (
            confirmDelete ? (
              <div role="group" aria-label={'Supprimer le trade ?'} style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 10, width: '100%' }}>
                <span style={{ fontSize: 12.5, color: 'var(--fg)', flex: '1 1 240px' }}>
                  Supprimer le trade {trade.symbol || ''} du {fmtDayShort(trade.executed_at)}{' '}? Il disparaîtra de tes statistiques.
                </span>
                <div style={{ display: 'flex', gap: 8, marginLeft: 'auto' }}>
                  <button type="button" onClick={doDelete} disabled={busy} className="btn tap" style={{ background: 'var(--sell-bg, #dc2626)', color: '#fff', borderColor: 'var(--sell-bg, #dc2626)', ...busyStyle }}>{busy ? 'Suppression…' : 'Supprimer'}</button>
                  <button type="button" onClick={() => setConfirmDelete(false)} disabled={busy} className="btn tap" style={{ color: 'var(--fg)', ...busyStyle }}>Annuler</button>
                </div>
              </div>
            ) : (
              // Clés « view » / « edit » : React démonte le pied au changement de
              // mode. Sans elles, le bouton « Modifier » (type=button) serait
              // recyclé en « Enregistrer » (type=submit) pendant son propre clic,
              // et le navigateur enverrait aussitôt le formulaire.
              <React.Fragment key="view">
                <button type="button" onClick={() => { setError(''); setConfirmDelete(true); }} className="tap tap-44" style={{ background: 'transparent', border: 'none', color: 'var(--red-text)', fontSize: 12.5, fontWeight: 500, cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}>Supprimer</button>
                <div style={{ display: 'flex', gap: 8 }}>
                  <button type="button" onClick={modal.requestClose} className="btn tap" style={{ color: 'var(--fg)' }}>Fermer</button>
                  <button ref={editBtnRef} type="button" onClick={() => { setError(''); setMode('edit'); }} className="btn btn-blue tap" style={{ color: '#fff' }}>Modifier</button>
                </div>
              </React.Fragment>
            )
          ) : (
            <React.Fragment key="edit">
              <span style={{ fontSize: 11, color: 'var(--fg-3)', flex: '1 1 220px' }}>Les changements sont enregistrés quand tu cliques sur Enregistrer.</span>
              <div style={{ display: 'flex', gap: 8, marginLeft: 'auto' }}>
                <button type="button" onClick={cancelEdit} disabled={busy} className="btn tap" style={{ color: 'var(--fg)', ...busyStyle }}>Annuler</button>
                <button type="submit" form="trade-edit-form" disabled={busy} className="btn btn-blue tap" style={{ color: '#fff', ...busyStyle }}>{busy ? 'Enregistrement…' : 'Enregistrer'}</button>
              </div>
            </React.Fragment>
          )}
        </div>
      </div>
    </div>
  );
}

// ─── Page Trades ──────────────────────────────────────────────────
// La liste est affichée par pages de 200 trades (« Afficher 200 trades de
// plus »), comme le Journal quotidien l'est par pages de 30 jours : dessiner
// 5 000 lignes d'un coup gèle l'écran. Pas de fenêtre de défilement : les
// lignes n'ont pas toutes la même hauteur et partagent leurs colonnes (subgrid).
const PG2_TRADES_PAGE = 200;

// Pagination COMMUNE à l'ordinateur (TradesPage) et au téléphone (TradesMobile,
// app.jsx). `resetKey` (le symbole filtré) : quand il change, on repart de la
// première page. La liste elle-même peut changer (ajout, modification,
// suppression) sans faire remonter l'affichage. Renvoie :
//   visible  — les trades à dessiner (référence stable tant que rien ne change) ;
//   more     — nombre de trades pas encore affichés ;
//   showMore — affiche la page suivante, puis donne le focus à la première
//              ligne ajoutée (le bouton disparaît à la dernière page : le focus
//              ne doit pas retomber sur <body>) ;
//   listRef  — à poser sur le conteneur de la liste (recherche de cette ligne).
function useTradesPaging(list, resetKey, rowSelector = '[role="button"][aria-label^="Ouvrir le trade"]') {
  const all = Array.isArray(list) ? list : [];
  const [st, setSt] = React.useState({ key: resetKey, shown: PG2_TRADES_PAGE });
  const shown = st.key === resetKey ? st.shown : PG2_TRADES_PAGE;
  const visible = React.useMemo(() => (all.length > shown ? all.slice(0, shown) : all), [all, shown]);
  const listRef = React.useRef(null);
  const focusFrom = React.useRef(null);
  const showMore = () => {
    focusFrom.current = visible.length;
    setSt({ key: resetKey, shown: shown + PG2_TRADES_PAGE });
  };
  React.useEffect(() => {
    const from = focusFrom.current;
    focusFrom.current = null;
    if (from == null || !listRef.current) return;
    const row = listRef.current.querySelectorAll(rowSelector)[from];
    if (row && typeof row.focus === 'function') row.focus();
  }, [shown]);
  return { visible, more: Math.max(0, all.length - visible.length), showMore, listRef };
}

// Bouton « Afficher 200 trades de plus » (ordinateur et téléphone).
function TradesMoreButton({ more, onMore, style }) {
  if (!more) return null;
  return (
    <div style={{ display: 'flex', justifyContent: 'center', ...style }}>
      <button type="button" onClick={onMore} className="btn tap" style={{ color: 'var(--fg)' }}>
        Afficher {pg2Count(Math.min(more, PG2_TRADES_PAGE), 'trade')} de plus
      </button>
    </div>
  );
}

// Fonction reçue d'un parent, rendue stable d'un rendu à l'autre (elle appelle
// toujours la dernière version) : un composant mémorisé qui la reçoit n'est
// pas redessiné parce que le parent en a recréé une. undefined reste undefined.
function usePg2StableFn(fn) {
  const ref = React.useRef(fn);
  ref.current = fn;
  const stable = React.useCallback((...args) => (ref.current ? ref.current(...args) : undefined), []);
  return fn ? stable : undefined;
}

const PG2_TRADES_HEADER = { background: 'var(--bg-soft)', padding: '12px 18px' };

function TradesPage({ nav, openJournal, openImport, openTrade, state }) {
  const [symbolFilter, setSymbolFilter] = React.useState('');
  const [refreshing, setRefreshing] = React.useState(false);
  const { tradesLoading, refreshTrades } = state || { tradesLoading: false };
  const trades = Array.isArray(state?.trades) ? state.trades : [];

  const filteredTrades = React.useMemo(() => {
    if (!symbolFilter) return trades;
    return trades.filter(t => (t.symbol || '') === symbolFilter);
  }, [trades, symbolFilter]);

  const allSymbols = React.useMemo(() => {
    const s = new Set();
    for (const t of trades) if (t.symbol) s.add(t.symbol);
    return Array.from(s).sort();
  }, [trades]);

  // Le symbole filtré n'existe plus (dernier trade supprimé ou renommé) : le
  // filtre retombe sur « Tous les symboles », au lieu d'une liste vide sous une
  // liste déroulante qui n'a plus l'option. Pas pendant un chargement ni après
  // un échec : la liste des symboles n'y est pas fiable.
  const tradesFailed = !!(state && state.tradesError);
  React.useEffect(() => {
    if (symbolFilter && !tradesLoading && !tradesFailed && !allSymbols.includes(symbolFilter)) setSymbolFilter('');
  }, [allSymbols, symbolFilter, tradesLoading, tradesFailed]);

  const refresh = async () => {
    if (!refreshTrades || refreshing) return;
    setRefreshing(true);
    try { await refreshTrades(); } finally { setRefreshing(false); }
  };
  const addTrade = () => openImport && openImport(null, 'manual');
  const importFile = () => openImport && openImport(null, 'csv');
  const count = filteredTrades.length;
  // Seule une page de la liste est dessinée ; le compteur, lui, porte sur toute
  // la liste. Ouvrir ou fermer une fiche (état d'App) ne redessine pas les
  // lignes : TradeTable est mémorisé et reçoit des props stables.
  const paging = useTradesPaging(filteredTrades, symbolFilter);
  const onOpenTrade = usePg2StableFn(openTrade);
  const onOpenJournal = usePg2StableFn(openJournal);
  // Colonnes selon la largeur : Quantité puis Entrée et Sortie disparaissent
  // avant que le P&L ne soit tronqué (charte §9).
  // Entrée et Sortie à partir de 1024 px : en dessous, avec un P&L à 7 chiffres,
  // le tableau dépasserait la carte de quelques pixels.
  const full = usePg2MinWidth(1100);
  const mid = usePg2MinWidth(1024);
  const tableCols = full ? PG2_COLS_FULL : mid ? PG2_COLS_COMPACT : PG2_COLS_HOME;

  return (
    <div className="scroll" style={{ width: '100%', height: '100%', overflow: 'auto' }}>
      <PageHeader title={pg2Nav('trades').titre || 'Trades'}/>
      <div style={{ ...PAGE_BODY, padding: '20px 24px 32px' }}>
        <Pg2LoadError state={state} style={{ marginBottom: 16 }}/>
        {/* Barre d'outils : seulement quand il y a des trades à filtrer (sinon
            l'état vide porte les actions « Ajouter un trade » et « Importer un fichier »). */}
        {trades.length > 0 && <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12, marginBottom: 16 }}>
          <span style={{ fontSize: 13, color: 'var(--fg-2)', fontWeight: 500 }}>
            {pg2Count(count, 'trade') + (symbolFilter ? ' sur ' + fmtNum(trades.length, 0) : '')}
          </span>
          <div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
            <select value={symbolFilter} onChange={e => setSymbolFilter(e.target.value)} aria-label="Filtrer par symbole" className="input" style={{ width: 170, padding: '7px 10px', fontSize: 12 }}>
              <option value="">Tous les symboles</option>
              {allSymbols.map(s => <option key={s} value={s}>{s}</option>)}
            </select>
            <button type="button" onClick={refresh} disabled={refreshing} className="btn tap" style={{ color: 'var(--fg)', opacity: refreshing ? .55 : 1, cursor: refreshing ? 'not-allowed' : 'pointer' }}>{Ico.refresh} Actualiser</button>
            <button type="button" onClick={importFile} className="btn tap" style={{ color: 'var(--fg)' }}>{Ico.upload} Importer un fichier</button>
            <button type="button" onClick={addTrade} className="btn btn-blue tap" style={{ color: '#fff' }}>{Ico.plus} Ajouter un trade</button>
          </div>
        </div>}

        {tradesLoading && trades.length === 0 ? (
          <div className="card" style={{ padding: '14px 18px' }}>
            <Pg2RowsSkeleton rows={8} label="Chargement de tes trades" gap={24}/>
          </div>
        ) : trades.length === 0 && state?.tradesError ? (
          <div className="card" style={{ padding: '16px 24px' }}>
            <Pg2Unavailable/>
          </div>
        ) : trades.length === 0 ? (
          <div className="card">
            <EmptyTrades openImport={openImport}/>
          </div>
        ) : count === 0 ? (
          <div className="card">
            <EmptyState title="Aucun trade ne correspond à ce filtre" text="Choisis un autre symbole ou affiche-les tous."
              secondary={{ label: 'Tous les symboles', onClick: () => setSymbolFilter('') }}/>
          </div>
        ) : (
          <div ref={paging.listRef}>
            <div className="card" style={{ padding: 0, overflow: 'hidden' }}>
              <TradeTable trades={paging.visible} columns={tableCols} openTrade={onOpenTrade} openJournal={onOpenJournal}
                showSource chevron padX={18} headerStyle={PG2_TRADES_HEADER}/>
            </div>
            <TradesMoreButton more={paging.more} onMore={paging.showMore} style={{ marginTop: 14 }}/>
          </div>
        )}
      </div>
    </div>
  );
}

// ─── Connexion (authentification Supabase) ────────────────────────
const PG2_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

// Titre présent pour un lecteur d'écran sans être dessiné : le sélecteur
// « Connexion / Créer un compte » affiche déjà ces mots.
const PG2_VISUALLY_HIDDEN = {
  position: 'absolute', width: 1, height: 1, margin: -1, padding: 0, border: 0,
  overflow: 'hidden', clip: 'rect(0 0 0 0)', clipPath: 'inset(50%)', whiteSpace: 'nowrap',
};
const PG2_JOURNAL_TAB_IDS = [{ id: 'stats' }, { id: 'notes' }, { id: 'trades' }, { id: 'attach' }];
const PG2_LOGIN_TABS = [
  { id: 'login', label: 'Connexion' },
  { id: 'signup', label: 'Créer un compte' },
];

// notice : avertissement à afficher au-dessus du formulaire (par exemple après
// une suppression de compte dont la dernière étape a échoué, app.jsx).
function Login({ onLogin, notice }) {
  const [mode, setMode] = React.useState('login'); // 'login' | 'signup' | 'reset'
  const [email, setEmail] = React.useState('');
  const [password, setPassword] = React.useState('');
  const [loading, setLoading] = React.useState(false);
  const [error, setError] = React.useState('');
  const [info, setInfo] = React.useState('');
  // Consentement CGU/confidentialité (obligatoire à l'inscription) + modal légale.
  const [consent, setConsent] = React.useState(false);
  const [legalKind, setLegalKind] = React.useState(null); // 'cgu' | 'cgv' | 'privacy' | null
  const isMobile = usePg2Mobile();

  // Lien entrant depuis la landing (landing.html) :
  //   ./index.html?mode=signup → ouvre directement le formulaire d'inscription.
  // (Les hash #cgu / #cgv / #confidentialite sont lus dans App — app.jsx, qui
  // enveloppe Login — pour que la modale légale s'ouvre aussi bien sur cet écran
  // que quand l'utilisateur est déjà connecté.)
  // Lecture unique au montage ; on retire ensuite la query (replaceState) en
  // conservant le hash éventuel (Supabase #access_token=…, ou #cgu / #cgv lu par App).
  React.useEffect(() => {
    try {
      const params = new URLSearchParams(window.location.search || '');
      if (params.get('mode') === 'signup') {
        setMode('signup');
        if (window.history && typeof window.history.replaceState === 'function') {
          window.history.replaceState(null, '', window.location.pathname + (window.location.hash || ''));
        }
      }
    } catch (e) { /* URL illisible : on ignore */ }
  }, []);

  const submit = async (e) => {
    e.preventDefault();
    if (loading) return;
    setError(''); setInfo('');
    if (!window.sb) {
      console.warn('[Tempo] window.sb absent : client d’authentification non chargé.');
      setError('Service momentanément indisponible. Recharge la page.');
      return;
    }
    const cleanEmail = (email || '').trim();
    if (!cleanEmail) { setError('Entre ton email.'); return; }
    if (!PG2_EMAIL_RE.test(cleanEmail)) { setError('Adresse email invalide. Vérifie ta saisie.'); return; }
    setLoading(true);
    try {
      if (mode === 'login') {
        if (!password) { setError('Entre ton mot de passe.'); setLoading(false); return; }
        const { error } = await window.sb.auth.signInWithPassword({ email: cleanEmail, password });
        if (error) { setError(sbErrorMessage(error)); setLoading(false); return; }
        setLoading(false);
        if (onLogin) onLogin();
      } else if (mode === 'signup') {
        if (!password || password.length < PASSWORD_MIN) { setError(PASSWORD_TOO_SHORT); setLoading(false); return; }
        if (!consent) { setError('Merci d’accepter les conditions d’utilisation et la politique de confidentialité pour créer ton compte.'); setLoading(false); return; }
        const { data, error } = await window.sb.auth.signUp({
          email: cleanEmail, password,
          options: {
            emailRedirectTo: window.location.origin,
            // Preuve de consentement (RGPD) : horodatage + version des documents acceptés.
            // user_metadata est modifiable par l'utilisateur : la preuve opposable est copiée
            // côté serveur (table public.consents, horodatage now()) par le trigger
            // on_auth_user_created_consent — voir supabase-migration-securite.sql.
            data: { accepted_terms_at: new Date().toISOString(), accepted_terms_version: window.LEGAL_VERSION || null },
          },
        });
        if (error) { setError(sbErrorMessage(error)); setLoading(false); return; }
        setLoading(false);
        if (data?.session) { if (onLogin) onLogin(); return; }
        setInfo('Compte créé. Confirme ton email pour activer ton compte, puis reviens te connecter ici.');
        setMode('login');
      } else if (mode === 'reset') {
        const { error } = await window.sb.auth.resetPasswordForEmail(cleanEmail, {
          redirectTo: window.location.origin,
        });
        if (error) { setError(sbErrorMessage(error)); setLoading(false); return; }
        setLoading(false);
        setInfo('Si un compte existe avec cet email, tu vas recevoir un lien de réinitialisation dans quelques minutes.');
        setMode('login');
      }
    } catch (err) {
      console.warn('[Tempo] auth submit', err);
      setError(sbErrorMessage(err));
      setLoading(false);
    }
  };

  // Titre (h1) du mode affiché. « Mot de passe oublié ? » et « ← Retour à la
  // connexion » disparaissent en s'activant : le focus va alors au titre du
  // nouvel écran (annoncé par un lecteur d'écran, sans ouvrir le clavier d'un
  // téléphone) au lieu de retomber sur <body>. Connexion ↔ Créer un compte : le
  // sélecteur reste à l'écran, le focus ne bouge pas. Jamais au montage ni sur
  // ?mode=signup : seulement après un changement demandé ici.
  const titleRef = React.useRef(null);
  const focusTitle = React.useRef(false);
  const switchMode = (m) => {
    if (m !== mode && (m === 'reset' || mode === 'reset')) focusTitle.current = true;
    setMode(m); setError(''); setInfo('');
  };
  React.useEffect(() => {
    if (!focusTitle.current) return;
    focusTitle.current = false;
    if (titleRef.current) titleRef.current.focus({ preventScroll: true });
  }, [mode]);

  return (
    <main style={{ width: '100%', minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg-soft)', padding: isMobile ? '24px var(--m-gutter, 16px)' : 24 }}>
      <form onSubmit={submit} noValidate style={{ width: 380, maxWidth: '100%', padding: isMobile ? '28px 20px' : '36px 32px', background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--line)', boxShadow: '0 12px 32px rgba(15,23,42,.08)' }}>
        <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8, marginBottom: 24 }}>
          <div style={{ width: 44, height: 44, borderRadius: 12, background: 'var(--blue)', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
            <div style={{ width: 2, height: 18, background: '#fff' }}></div>
            <div style={{ position: 'absolute', width: 14, height: 2, background: '#fff', top: 13 }}></div>
          </div>
          <span style={{ fontSize: 22, fontWeight: 700, letterSpacing: '-.025em', color: 'var(--blue-600)' }}>Tempo</span>
          <span style={{ fontSize: 12, color: 'var(--fg-3)' }}>Ton journal de trading personnel</span>
        </div>

        {mode === 'reset' ? (
          // Mot de passe oublié : un titre et une phrase disent ce que fait
          // « Envoyer le lien », à la place des onglets (aucun n'est actif ici).
          <div style={{ textAlign: 'center', marginBottom: 18 }}>
            <h1 ref={titleRef} tabIndex={-1} style={{ fontSize: 16, fontWeight: 600, letterSpacing: '-.01em', color: 'var(--fg)', margin: '0 0 6px', outline: 'none' }}>Mot de passe oublié</h1>
            <p style={{ fontSize: 12.5, color: 'var(--fg-2)', lineHeight: 1.5, margin: 0 }}>Entre l’email de ton compte{'\u00A0'}: tu recevras un lien pour choisir un nouveau mot de passe.</p>
          </div>
        ) : (
          // Un h1 par mode (non dessiné : le sélecteur affiche déjà ces mots),
          // puis le sélecteur commun SegmentedTabs : l'option active est
          // exposée (aria-selected) et reliée au bloc des champs (tabpanel).
          <div style={{ display: 'flex', justifyContent: 'center', marginBottom: 18 }}>
            <h1 ref={titleRef} tabIndex={-1} style={PG2_VISUALLY_HIDDEN}>{mode === 'signup' ? 'Créer un compte' : 'Connexion'}</h1>
            <SegmentedTabs ariaLabel="Connexion ou création de compte" idPrefix="login" size="sm"
              value={mode} onChange={switchMode} options={PG2_LOGIN_TABS}/>
          </div>
        )}

        {mode !== 'reset' && <TabPanelStubs idPrefix="login" options={PG2_LOGIN_TABS} value={mode}/>}
        <div role={mode === 'reset' ? undefined : 'tabpanel'} id={mode === 'reset' ? undefined : 'login-panel-' + mode}
          aria-labelledby={mode === 'reset' ? undefined : 'login-tab-' + mode}
          style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
          <div>
            <label htmlFor="login-email" style={{ fontSize: 12, fontWeight: 500, color: 'var(--fg-2)', display: 'block', marginBottom: 6 }}>Email</label>
            <input id="login-email" className="input" value={email} onChange={e => setEmail(e.target.value)} type="email" autoComplete="email" placeholder="toi@example.com" required/>
          </div>
          {mode !== 'reset' && (
            <div>
              <label htmlFor="login-password" style={{ fontSize: 12, fontWeight: 500, color: 'var(--fg-2)', display: 'block', marginBottom: 6 }}>Mot de passe</label>
              <input id="login-password" className="input" value={password} onChange={e => setPassword(e.target.value)} type="password"
                autoComplete={mode === 'signup' ? 'new-password' : 'current-password'}
                placeholder={mode === 'signup' ? PASSWORD_HINT : ''}
                minLength={mode === 'signup' ? PASSWORD_MIN : undefined} required/>
            </div>
          )}

          {mode === 'signup' && (
            <label style={{ display: 'flex', gap: 8, alignItems: 'flex-start', fontSize: 11.5, color: 'var(--fg-2)', lineHeight: 1.5, cursor: 'pointer' }}>
              <input type="checkbox" checked={consent} onChange={e => setConsent(e.target.checked)} style={{ marginTop: 2, flexShrink: 0, accentColor: 'var(--blue)' }}/>
              <span>
                J’accepte les{' '}
                <button type="button" className="tap" onClick={() => setLegalKind('cgu')} style={{ background: 'transparent', border: 'none', padding: 0, cursor: 'pointer', color: 'var(--blue-600)', fontSize: 11.5, fontWeight: 600, fontFamily: 'inherit' }}>conditions d’utilisation</button>
                {' '}et la{' '}
                <button type="button" className="tap" onClick={() => setLegalKind('privacy')} style={{ background: 'transparent', border: 'none', padding: 0, cursor: 'pointer', color: 'var(--blue-600)', fontSize: 11.5, fontWeight: 600, fontFamily: 'inherit' }}>politique de confidentialité</button>
                , et je comprends que le trading comporte un risque élevé de perte en capital.
              </span>
            </label>
          )}

          {notice ? (
            <div role="alert" data-login-notice style={{ fontSize: 12, lineHeight: 1.5, color: 'var(--fg)', background: 'var(--bg-elev)', border: '1px solid var(--line)', padding: '8px 10px', borderRadius: 8, overflowWrap: 'anywhere' }}>
              {notice}
            </div>
          ) : null}
          {error && (
            <div role="alert" style={{ fontSize: 12, color: 'var(--red-text)', background: 'var(--red-soft)', border: '1px solid var(--line)', padding: '8px 10px', borderRadius: 8 }}>
              {error}
            </div>
          )}
          {info && (
            <div role="status" style={{ fontSize: 12, color: 'var(--green-text)', background: 'var(--green-soft)', border: '1px solid var(--line)', padding: '8px 10px', borderRadius: 8 }}>
              {info}
            </div>
          )}

          <button type="submit" disabled={loading} className="btn btn-blue tap" style={{ width: '100%', justifyContent: 'center', padding: '11px 0', marginTop: 4, color: '#fff', opacity: loading ? .55 : 1, cursor: loading ? 'not-allowed' : 'pointer' }}>
            {loading ? 'Patiente…' : (mode === 'login' ? 'Se connecter' : mode === 'signup' ? 'Créer mon compte' : 'Envoyer le lien')}
          </button>

          {mode === 'login' && (
            <button type="button" onClick={() => switchMode('reset')} className="tap" style={{ background: 'transparent', border: 'none', color: 'var(--blue-600)', fontSize: 12, fontWeight: 500, cursor: 'pointer', padding: 6, fontFamily: 'inherit' }}>
              Mot de passe oublié{' '}?
            </button>
          )}
          {mode === 'reset' && (
            <button type="button" onClick={() => switchMode('login')} className="tap" style={{ background: 'transparent', border: 'none', color: 'var(--fg-3)', fontSize: 12, fontWeight: 500, cursor: 'pointer', padding: 6, fontFamily: 'inherit' }}>
              ← Retour à la connexion
            </button>
          )}
        </div>

        <div style={{ marginTop: 18, paddingTop: 16, borderTop: '1px solid var(--line)', textAlign: 'center' }}>
          <span style={{ fontSize: 11.5, color: 'var(--fg-3)' }}>
            {mode === 'signup' ? 'Tu as déjà un compte ?' : 'Pas encore de compte ?'}
          </span>{' '}
          <button type="button" onClick={() => switchMode(mode === 'signup' ? 'login' : 'signup')} className="tap" style={{ background: 'transparent', border: 'none', padding: 0, fontSize: 11.5, color: 'var(--blue-600)', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>
            {mode === 'signup' ? 'Se connecter' : 'Créer un compte'}
          </button>
        </div>

        {/* Avertissement risque + liens légaux, visibles avant toute connexion. */}
        <div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'center' }}>
          {typeof window.TradingDisclaimer === 'function' && <window.TradingDisclaimer compact/>}
          {mode !== 'signup' && typeof window.LegalLinks === 'function' && <window.LegalLinks compact/>}
          {/* Retour vers la vitrine publique (landing.html). */}
          <a href="./landing.html" style={{ fontSize: 11, color: 'var(--fg-3)', fontWeight: 500, textDecoration: 'none' }}>
            Découvrir Tempo
          </a>
        </div>
      </form>
      {legalKind && typeof window.LegalModal === 'function' && (
        <window.LegalModal kind={legalKind} onClose={() => setLegalKind(null)}/>
      )}
    </main>
  );
}

// ─── Mot de passe : formulaire partagé (récupération + Réglages) ─────
// Appelle sb.auth.updateUser({ password }) — requiert une session active
// (session normale en Réglages, session de récupération après le lien email).
// requireCurrent : en Réglages, exige le mot de passe actuel (vérifié via signInWithPassword)
// avant la mise à jour, pour qu'une session laissée ouverte ne permette pas de prendre le
// contrôle du compte. Sans la prop (écran de récupération), pas de mot de passe actuel.
function PasswordForm({ onSuccess, submitLabel, requireCurrent }) {
  const [current, setCurrent] = React.useState('');
  const [pw, setPw] = React.useState('');
  const [pw2, setPw2] = React.useState('');
  const [loading, setLoading] = React.useState(false);
  const [error, setError] = React.useState('');
  const [ok, setOk] = React.useState('');

  const submit = async (e) => {
    e.preventDefault();
    if (loading) return;
    setError(''); setOk('');
    if (!window.sb) {
      console.warn('[Tempo] window.sb absent : client d’authentification non chargé.');
      setError('Service momentanément indisponible. Recharge la page.');
      return;
    }
    if (requireCurrent && !current) { setError('Entre ton mot de passe actuel.'); return; }
    if (!pw || pw.length < PASSWORD_MIN) { setError(PASSWORD_TOO_SHORT); return; }
    if (pw !== pw2) { setError('Les deux mots de passe ne correspondent pas.'); return; }
    setLoading(true);
    try {
      if (requireCurrent) {
        const { data: u } = await window.sb.auth.getUser();
        const { error: e0 } = await window.sb.auth.signInWithPassword({ email: u?.user?.email || '', password: current });
        if (e0) { setError('Mot de passe actuel incorrect.'); setLoading(false); return; }
      }
      const { error } = await window.sb.auth.updateUser({ password: pw });
      if (error) { setError(sbErrorMessage(error)); setLoading(false); return; }
      setLoading(false);
      setCurrent(''); setPw(''); setPw2('');
      setOk('Mot de passe mis à jour.');
      if (onSuccess) onSuccess();
    } catch (err) {
      console.warn('[Tempo] updateUser password', err);
      setError(sbErrorMessage(err));
      setLoading(false);
    }
  };

  return (
    <form onSubmit={submit} noValidate style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
      {requireCurrent && (
        <div>
          <label htmlFor="pw-current" style={{ fontSize: 12, fontWeight: 500, color: 'var(--fg-2)', display: 'block', marginBottom: 6 }}>Mot de passe actuel</label>
          <input id="pw-current" className="input" value={current} onChange={e => setCurrent(e.target.value)} type="password" autoComplete="current-password" required/>
        </div>
      )}
      <div>
        <label htmlFor="pw-new" style={{ fontSize: 12, fontWeight: 500, color: 'var(--fg-2)', display: 'block', marginBottom: 6 }}>Nouveau mot de passe</label>
        <input id="pw-new" className="input" value={pw} onChange={e => setPw(e.target.value)} type="password" autoComplete="new-password" placeholder={PASSWORD_HINT} minLength={PASSWORD_MIN} required/>
      </div>
      <div>
        <label htmlFor="pw-confirm" style={{ fontSize: 12, fontWeight: 500, color: 'var(--fg-2)', display: 'block', marginBottom: 6 }}>Confirme le mot de passe</label>
        <input id="pw-confirm" className="input" value={pw2} onChange={e => setPw2(e.target.value)} type="password" autoComplete="new-password" minLength={PASSWORD_MIN} required/>
      </div>
      {error && (
        <div role="alert" style={{ fontSize: 12, color: 'var(--red-text)', background: 'var(--red-soft)', border: '1px solid var(--line)', padding: '8px 10px', borderRadius: 8 }}>{error}</div>
      )}
      {ok && (
        <div role="status" style={{ fontSize: 12, color: 'var(--green-text)', background: 'var(--green-soft)', border: '1px solid var(--line)', padding: '8px 10px', borderRadius: 8 }}>{ok}</div>
      )}
      <button type="submit" disabled={loading} className="btn btn-blue tap" style={{ justifyContent: 'center', padding: '10px 0', color: '#fff', opacity: loading ? .55 : 1, cursor: loading ? 'not-allowed' : 'pointer' }}>
        {loading ? 'Patiente…' : (submitLabel || 'Mettre à jour le mot de passe')}
      </button>
    </form>
  );
}

// ─── Écran « nouveau mot de passe » (lien de récupération email) ─────
// Affiché par App quand Supabase émet l'événement PASSWORD_RECOVERY : le clic
// sur le lien email connecte l'utilisateur avec une session de récupération ;
// cet écran lui fait définir son nouveau mot de passe avant d'entrer dans Tempo.
function UpdatePassword({ onDone }) {
  const [done, setDone] = React.useState(false);
  const isMobile = usePg2Mobile();
  return (
    <div style={{ width: '100%', minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg-soft)', padding: isMobile ? '24px var(--m-gutter, 16px)' : 24 }}>
      <div style={{ width: 380, maxWidth: '100%', padding: isMobile ? '28px 20px' : '36px 32px', background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--line)', boxShadow: '0 12px 32px rgba(15,23,42,.08)' }}>
        <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8, marginBottom: 22 }}>
          <div style={{ width: 44, height: 44, borderRadius: 12, background: 'var(--blue)', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
            <div style={{ width: 2, height: 18, background: '#fff' }}></div>
            <div style={{ position: 'absolute', width: 14, height: 2, background: '#fff', top: 13 }}></div>
          </div>
          <span style={{ fontSize: 20, fontWeight: 700, letterSpacing: '-.02em' }}>Nouveau mot de passe</span>
          <span style={{ fontSize: 12, color: 'var(--fg-3)', textAlign: 'center', lineHeight: 1.5 }}>
            {done ? 'C’est fait ! Tu peux entrer dans Tempo.' : 'Choisis ton nouveau mot de passe pour finaliser la récupération.'}
          </span>
        </div>
        {done ? (
          <button type="button" onClick={onDone} className="btn btn-blue tap" style={{ width: '100%', justifyContent: 'center', padding: '11px 0', color: '#fff' }}>
            Continuer vers Tempo
          </button>
        ) : (
          <React.Fragment>
            <PasswordForm onSuccess={() => setDone(true)}/>
            <button type="button" onClick={onDone} className="tap" style={{ width: '100%', background: 'transparent', border: 'none', color: 'var(--fg-3)', fontSize: 12, fontWeight: 500, cursor: 'pointer', padding: 8, marginTop: 8, fontFamily: 'inherit' }}>
              Plus tard
            </button>
          </React.Fragment>
        )}
      </div>
    </div>
  );
}

// ─── Date du jour ─────────────────────────────────────────────────
// « Samedi 26 septembre · Semaine 39 » : aujourd'hui dans le fuseau du profil
// (todayISO), ou le jour de l'instant d donné, dans ce même fuseau.
function buildDateLabel(d) {
  const day = d ? localDayInTz(d) : todayISO();
  const s = fmtDayLong(day);
  if (s === '—') return '';
  return s.charAt(0).toUpperCase() + s.slice(1) + ' · ' + fmtWeekLabel(day);
}

Object.assign(window, {
  Home, Daily, DayJournalCard, JournalModal, TradesPage, TradeTable, Login,
  TradeDetailModal, PasswordForm, UpdatePassword, QuickStat, PASSWORD_MIN,
  tradeSourceLabel, homeMarketPill, buildDateLabel,
  HomePretradeCard, HomeHabitsCard, HomeAgendaError, HomeHabitSkeleton,
  useDailyDays, dailyJournalBits, DailyMoreButton, DailyFilterEmpty, pg2WinLoss,
  PG2_TRADES_PAGE, useTradesPaging, TradesMoreButton, pg2TradeRowLabel,
});
