// Tempo — App shell (responsive, no design canvas wrapper)

function useIsMobile() {
  const [m, setM] = React.useState(() => typeof window !== 'undefined' && window.innerWidth < 900);
  React.useEffect(() => {
    const fit = () => setM(window.innerWidth < 900);
    window.addEventListener('resize', fit);
    return () => window.removeEventListener('resize', fit);
  }, []);
  return m;
}

// Purge des données personnelles conservées dans le localStorage (URL secrète
// du calendrier ICS, notes « Plan trade », contexte fondamental, réglages de
// backtest…) afin qu'un autre compte ouvert ensuite sur le même navigateur ne
// puisse pas les lire. On conserve les préférences d'appareil non personnelles
// (thème, proxy économique, menu replié) ; la clé de
// session Supabase (sb-…-auth-token) est gérée par supabase-js lui-même.
const LOCAL_KEEP_KEYS = ['tempo:theme', 'tempo:econProxy', 'tempo:sidebar'];
function purgeLocalTempoData() {
  try {
    Object.keys(localStorage)
      .filter(k => k.startsWith('tempo:') && !LOCAL_KEEP_KEYS.includes(k))
      .forEach(k => localStorage.removeItem(k));
  } catch (e) { /* stockage indisponible : rien à purger */ }
  // Les écrans encore montés (Backtest : brouillon écrit au démontage) ne
  // doivent plus rien réécrire après la purge.
  try { window.dispatchEvent(new Event('tempo:purge')); } catch (e) {}
}

function useAuthSession() {
  const [session, setSession] = React.useState(null);
  const [checked, setChecked] = React.useState(false);
  // Lien de récupération de mot de passe : Supabase connecte l'utilisateur avec
  // une session de récupération et émet PASSWORD_RECOVERY — on affiche alors
  // l'écran « nouveau mot de passe » avant d'entrer dans l'app.
  const [recovery, setRecovery] = React.useState(false);
  React.useEffect(() => {
    if (!window.sb) { setChecked(true); return; }
    let live = true;
    window.sb.auth.getSession().then(({ data }) => {
      if (!live) return;
      setSession(data?.session || null);
      setChecked(true);
    });
    const { data: sub } = window.sb.auth.onAuthStateChange((evt, sess) => {
      if (evt === 'PASSWORD_RECOVERY') setRecovery(true);
      // Session expirée ou déconnexion depuis un autre onglet : même purge
      // locale que le bouton « Se déconnecter ».
      if (evt === 'SIGNED_OUT') purgeLocalTempoData();
      setSession(sess || null);
    });
    return () => { live = false; try { sub.subscription.unsubscribe(); } catch (e) {} };
  }, []);
  const clearRecovery = React.useCallback(() => setRecovery(false), []);
  return { session, checked, recovery, clearRecovery };
}

// Clé de sessionStorage posée par deleteMyAccount (account-api.jsx) quand
// l'identifiant de connexion n'a pas pu être supprimé, et texte affiché alors
// sur l'écran de connexion.
const APP_DELETE_NOTICE_KEY = 'tempo:deleteNotice';
function appDeleteNoticeText() {
  return 'Tes données sont effacées, mais ton identifiant de connexion n’a pas pu être supprimé automatiquement. Écris à '
    + (window.LEGAL_CONTACT || 'contact@tempo-trading.com')
    + ' pour le faire supprimer, ou reconnecte-toi et relance la suppression depuis Réglages.';
}

// Catches render-time errors anywhere in the tree so one broken screen shows a
// graceful fallback instead of white-screening the whole app.
class ErrorBoundary extends React.Component {
  constructor(props) { super(props); this.state = { err: null }; }
  static getDerivedStateFromError(err) { return { err }; }
  componentDidCatch(err, info) { console.error('[Tempo] UI error:', err, info); }
  render() {
    if (this.state.err) {
      return (
        <div style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', padding: 24, background: 'var(--bg-app)', color: 'var(--fg)' }}>
          <div style={{ maxWidth: 420, textAlign: 'center' }}>
            <div style={{ fontSize: 18, fontWeight: 700, marginBottom: 8 }}>Une erreur est survenue</div>
            <p style={{ fontSize: 13, color: 'var(--fg-3)', lineHeight: 1.5, marginBottom: 18 }}>
              Cette section a rencontré un problème. Tes données sont en sécurité. Recharge la page pour continuer.
            </p>
            <button onClick={() => window.location.reload()} className="btn btn-blue tap">Recharger Tempo</button>
          </div>
        </div>
      );
    }
    return this.props.children;
  }
}

// ─── Avis d'écriture non aboutie ──────────────────────────────────
// Une écriture lente peut échouer après que l'écran d'où elle est partie a été
// quitté : son message local n'a plus d'écran. L'échec arrive alors ici
// (state.notifyWriteError, state.jsx) et reste affiché sur tous les écrans,
// au-dessus du contenu, jusqu'à ce qu'on le ferme (charte §7 : un échec
// produit toujours un message). state.writeNotices : [{ id, text }], le plus
// récent en premier, trois au plus.
function AppWriteNotices({ state, mobile = false }) {
  const list = state && Array.isArray(state.writeNotices) ? state.writeNotices : [];
  if (!list.length) return null;
  const close = (id) => { if (typeof state.dismissWriteNotice === 'function') state.dismissWriteNotice(id); };
  return (
    <div data-write-notices="" style={{
      // Mobile : collé en haut de la zone défilante, toujours visible.
      position: mobile ? 'sticky' : 'static', top: 0, zIndex: 30, flexShrink: 0,
      display: 'flex', flexDirection: 'column', gap: 8,
      padding: mobile ? '8px var(--m-gutter, 16px)' : '12px 24px 0',
      background: 'var(--bg-app)',
    }}>
      {list.map(n => (
        <div key={n.id} role="alert" style={{
          display: 'flex', alignItems: 'flex-start', gap: 10,
          padding: mobile ? '4px 4px 4px 14px' : '8px 8px 8px 14px', borderRadius: 10,
          background: 'var(--red-soft)', border: '1px solid var(--red)', color: 'var(--fg)',
        }}>
          <span style={{ flex: 1, minWidth: 0, fontSize: 12.5, lineHeight: 1.45, padding: mobile ? '9px 0' : '4px 0', overflowWrap: 'anywhere' }}>{n.text}</span>
          <button type="button" onClick={() => close(n.id)} className="tap" aria-label="Fermer" title="Fermer" style={{
            width: mobile ? 44 : 28, height: mobile ? 44 : 28, flexShrink: 0, borderRadius: 8, border: 'none', background: 'transparent',
            cursor: 'pointer', color: 'var(--fg)', display: 'flex', alignItems: 'center', justifyContent: 'center',
          }}>{Ico.x}</button>
        </div>
      ))}
    </div>
  );
}

// ─── Nouvelle version en ligne ────────────────────────────────────
// Tempo ne recharge pas tout seul un onglet déjà ouvert : après une mise en
// ligne, cet onglet continue de faire tourner l'ancien code (et, par exemple,
// d'importer des fichiers avec les anciennes règles). La version en ligne se
// lit dans sw.js (const VERSION, changée à chaque mise en ligne) : elle est
// relevée une première fois au démarrage, puis relue au retour sur l'onglet
// (au plus une fois toutes les cinq minutes). Si elle a changé, un bandeau le
// dit sur tous les écrans et propose de recharger — jamais de rechargement
// forcé, une saisie peut être en cours. La fenêtre d'import refait ce contrôle
// avant d'écrire (import-modal.jsx). Hors ligne ou fichier illisible : rien
// n'est conclu.
const APP_VERSION_FILE = 'sw.js';
const APP_VERSION_GAP_MS = 5 * 60 * 1000;
const APP_VERSION_TIMEOUT_MS = 4000;
const APP_VERSION_TEXT = 'Une nouvelle version de Tempo est en ligne. Recharge la page pour l’utiliser.';
const APP_VERSION_IMPORT_TEXT = 'Une nouvelle version de Tempo est en ligne\u00A0: recharge la page, puis relance l’import. Rien n’a été importé.';
const appVersionState = { boot: '', stale: false, lastAt: 0, pending: null, subs: new Set() };

function appVersionParse(text) {
  const m = /\bVERSION\s*=\s*['"]([^'"]+)['"]/.exec(String(text == null ? '' : text));
  return m ? m[1] : '';
}

// Version en ligne à cet instant, ou '' si elle n'a pas pu être lue.
async function appVersionRead() {
  let timer = null;
  try {
    const ctl = typeof AbortController === 'function' ? new AbortController() : null;
    if (ctl) timer = setTimeout(() => ctl.abort(), APP_VERSION_TIMEOUT_MS);
    const res = await fetch(APP_VERSION_FILE, { cache: 'no-store', signal: ctl ? ctl.signal : undefined });
    if (!res || !res.ok) return '';
    return appVersionParse(await res.text());
  } catch (e) {
    return '';
  } finally {
    if (timer) clearTimeout(timer);
  }
}

// appVersionCheck(force) → promesse d'un booléen : vrai si la version en ligne
// n'est plus celle de cet onglet. Ne rejette jamais. `force` : relit tout de
// suite, sans attendre les cinq minutes (avant un import de fichier).
function appVersionCheck(force) {
  const s = appVersionState;
  if (s.stale) return Promise.resolve(true);
  if (s.pending) return s.pending;
  const now = Date.now();
  if (!force && s.lastAt && Math.abs(now - s.lastAt) < APP_VERSION_GAP_MS) return Promise.resolve(false);
  s.lastAt = now;
  s.pending = appVersionRead().then((v) => {
    s.pending = null;
    if (v) {
      if (!s.boot) s.boot = v;
      else if (v !== s.boot) {
        s.stale = true;
        Array.from(s.subs).forEach(fn => { try { fn(); } catch (e) { /* abonné démonté */ } });
      }
    }
    return s.stale;
  });
  return s.pending;
}

function AppVersionNotice({ mobile = false }) {
  const [stale, setStale] = React.useState(appVersionState.stale);
  React.useEffect(() => {
    const sub = () => setStale(appVersionState.stale);
    appVersionState.subs.add(sub);
    sub();
    appVersionCheck(false); // premier passage : relève la version de cet onglet
    const onBack = () => { if (document.visibilityState !== 'hidden') appVersionCheck(false); };
    document.addEventListener('visibilitychange', onBack);
    window.addEventListener('focus', onBack);
    window.addEventListener('online', onBack);
    return () => {
      appVersionState.subs.delete(sub);
      document.removeEventListener('visibilitychange', onBack);
      window.removeEventListener('focus', onBack);
      window.removeEventListener('online', onBack);
    };
  }, []);
  if (!stale) return null;
  return (
    <div data-version-notice="" style={{ flexShrink: 0, padding: mobile ? '8px var(--m-gutter, 16px) 0' : '12px 24px 0', background: 'var(--bg-app)' }}>
      <div role="status" style={{
        display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px 12px',
        padding: '8px 8px 8px 14px', borderRadius: 10,
        background: 'var(--blue-soft)', border: '1px solid var(--blue-border)', color: 'var(--fg)',
      }}>
        <span style={{ flex: '1 1 200px', minWidth: 0, fontSize: 12.5, lineHeight: 1.45 }}>{APP_VERSION_TEXT}</span>
        <button type="button" onClick={() => window.location.reload()} className="btn btn-blue tap" style={{ fontSize: 12, color: '#fff', flexShrink: 0 }}>Recharger</button>
      </div>
    </div>
  );
}

// Cadre du contenu d'un écran sur ordinateur : les avis au-dessus, l'écran dans
// la place restante. La structure ne change pas quand un avis apparaît ou
// disparaît (l'écran n'est jamais remonté) ; l'écran garde une boîte aux
// dimensions connues (ses hauteurs en 100 % restent justes).
function AppRouteFrame({ state, children }) {
  return (
    <div style={{ height: '100%', display: 'flex', flexDirection: 'column', minWidth: 0 }}>
      <AppVersionNotice/>
      <AppWriteNotices state={state}/>
      <div style={{ flex: '1 1 0', minHeight: 0, position: 'relative' }}>
        <div style={{ position: 'absolute', inset: 0 }}>{children}</div>
      </div>
    </div>
  );
}

// ─── Appareil et profil à des heures différentes ──────────────────
// Tempo date tout (jour courant, trades, coches, agenda) dans le fuseau du
// profil (charte §4). Quand l'appareil n'est pas à la même heure (voyage,
// compte créé hors du fuseau enregistré), l'Accueil le dit et propose
// d'adopter l'heure de l'appareil ; « Garder » mémorise le couple (appareil,
// profil) sur cet appareil pour ne plus le reproposer.
// Deux fuseaux « à la même heure » : même décalage maintenant ET dans six mois
// (Paris et Bruxelles : rien à signaler ; Paris et Alger : à signaler, ils ne
// coïncident qu'en hiver). Appareil sans fuseau lisible, ou en UTC (certains
// navigateurs masquent ainsi le fuseau réel) : ignoré.
const APP_TZ_KEEP_KEY = 'tempo:tzKeep';
function appTzUsableDevice() {
  if (typeof stateDeviceTz === 'function') return stateDeviceTz();
  const tz = appDeviceTz();
  return tz && !/^(Etc\/)?(UTC|GMT)$/i.test(tz) && (typeof fpTzValid !== 'function' || fpTzValid(tz)) ? tz : '';
}
function appTzSameHour(a, b, nowMs) {
  if (a === b) return true;
  if (typeof fpTzOffsetMs !== 'function') return false;
  try {
    const later = nowMs + 183 * 86400000;
    return fpTzOffsetMs(a, nowMs) === fpTzOffsetMs(b, nowMs) && fpTzOffsetMs(a, later) === fpTzOffsetMs(b, later);
  } catch (e) { return false; }
}
function appTzKeepRead() {
  try { return localStorage.getItem(APP_TZ_KEEP_KEY) || ''; } catch (e) { return ''; }
}
function AppTzNotice({ state, style }) {
  const [kept, setKept] = React.useState(appTzKeepRead);
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState('');
  const alive = React.useRef(true);
  React.useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
  // Seulement pour un profil enregistré et lu : un compte sans ligne profiles
  // suit déjà l'heure de l'appareil (state.jsx).
  if (!state || !state.profileReady || !state.profile || !state.profile.timezone) return null;
  const profileTz = state.timezone;
  const deviceTz = appTzUsableDevice();
  if (!deviceTz || !profileTz || appTzSameHour(deviceTz, profileTz, Date.now())) return null;
  const pair = deviceTz + '|' + profileTz;
  if (kept === pair && !err) return null;
  const adopt = async () => {
    if (busy) return;
    setBusy(true); setErr('');
    let r = null;
    try { r = await state.saveProfile({ timezone: deviceTz }); } catch (e) { r = { error: e }; }
    const failed = !!(r && r.error);
    const text = failed ? 'Le fuseau horaire n’a pas été enregistré. ' + sbErrorMessage(r.error) : '';
    if (!alive.current) {
      if (failed && typeof state.notifyWriteError === 'function') state.notifyWriteError(text);
      return;
    }
    setBusy(false);
    if (failed) setErr(text);
  };
  const keep = () => {
    try { localStorage.setItem(APP_TZ_KEEP_KEY, pair); } catch (e) { /* stockage indisponible : mémorisé pour cette visite */ }
    setErr(''); setKept(pair);
  };
  const devHour = tzHourLabel(deviceTz), proHour = tzHourLabel(profileTz);
  return (
    <section aria-label="Fuseau horaire" data-tz-notice="" className="card" style={{ padding: '12px 14px', display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '10px 14px', ...style }}>
      <div style={{ flex: '1 1 260px', minWidth: 0, fontSize: 12.5, lineHeight: 1.5, color: 'var(--fg)' }}>
        Ton appareil est à l’{devHour}, Tempo est réglé sur l’{proHour}.
        <span style={{ color: 'var(--fg-3)' }}> Tes trades, ta journée et ton agenda sont datés à l’{proHour}.</span>
        {err && <div role="alert" style={{ color: 'var(--red-text)', marginTop: 4 }}>{err}</div>}
      </div>
      <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
        <button type="button" onClick={adopt} disabled={busy} className="btn btn-blue tap" style={{ fontSize: 12, color: '#fff' }}>{busy ? 'Enregistrement…' : 'Passer à l’' + devHour}</button>
        <button type="button" onClick={keep} disabled={busy} className="btn tap" style={{ fontSize: 12, color: 'var(--fg)' }}>Garder l’{proHour}</button>
      </div>
    </section>
  );
}

function App() {
  const isMobile = useIsMobile();
  const [route, setRoute] = React.useState('home');
  const { session, checked, recovery, clearRecovery } = useAuthSession();
  const loggedIn = !!session;
  const [journalingOpen, setJournalingOpen] = React.useState(true);
  const [statsInitialView, setStatsInitialView] = React.useState('trading'); // 'trading' | 'habits' — sous-onglet d'ouverture de Statistiques
  // Période d'ouverture de Statistiques : 'all' depuis une tuile d'indicateur
  // (la valeur y est calculée sur tous les trades), null depuis le menu (« Ce mois »).
  const [statsInitialPeriod, setStatsInitialPeriod] = React.useState(null);
  const [modalDay, setModalDay] = React.useState(null); // ISO date string
  const [importOpen, setImportOpen] = React.useState(false);
  const [importForDay, setImportForDay] = React.useState(null); // ISO date string seeded into the trade date field
  const [importTab, setImportTab] = React.useState('csv'); // onglet d'ouverture de « Ajouter des trades » : 'csv' | 'manual' | 'broker'
  const [settingsSection, setSettingsSection] = React.useState(null); // section de Réglages à montrer à l'arrivée ('brokers')
  const [tradeDetail, setTradeDetail] = React.useState(null); // trade row object
  // Changement de compte (connexion, déconnexion, session expirée, autre compte) :
  // la navigation repart de zéro. App reste monté derrière l'écran de connexion ;
  // sans cela, la reconnexion rouvrirait l'écran, la section de Réglages, le
  // journal du jour ou le trade laissés par la session précédente — au besoin
  // sur un autre compte. Fait pendant le rendu (et non dans un effet) pour que
  // rien de l'ancienne navigation ne soit affiché, même le temps d'une image.
  // L'identifiant ne change pas quand le jeton est renouvelé : rien ne bouge alors.
  const sessionUid = session && session.user ? session.user.id : null;
  const [navUid, setNavUid] = React.useState(sessionUid);
  if (navUid !== sessionUid) {
    setNavUid(sessionUid);
    setRoute('home'); setJournalingOpen(true);
    setStatsInitialView('trading'); setStatsInitialPeriod(null);
    setModalDay(null); setTradeDetail(null);
    setImportOpen(false); setImportForDay(null); setImportTab('csv');
    setSettingsSection(null);
  }
  // Suppression de compte dont la dernière étape a échoué : l'identifiant de
  // connexion existe encore (deleteMyAccount, account-api.jsx, pose la clé avant
  // de déconnecter). L'écran de connexion le dit, y compris après un
  // rechargement ; l'avertissement disparaît à la prochaine connexion.
  const [deleteNotice, setDeleteNotice] = React.useState(false);
  React.useEffect(() => {
    try {
      if (sessionUid) { sessionStorage.removeItem(APP_DELETE_NOTICE_KEY); setDeleteNotice(false); }
      else setDeleteNotice(sessionStorage.getItem(APP_DELETE_NOTICE_KEY) === '1');
    } catch (e) { /* stockage indisponible : pas d'avertissement à relire */ }
  }, [sessionUid]);
  const state = useAppState();
  // Titre de l'onglet du navigateur : « Statistiques — Tempo » (titre de page de
  // NAV_LABELS) une fois connecté, le titre générique sinon.
  React.useEffect(() => {
    try {
      const titre = loggedIn ? appNavText(route, 'title') : '';
      document.title = titre && titre !== route ? titre + ' — Tempo' : 'Tempo — Journal de trading';
    } catch (e) { /* document indisponible : rien à faire */ }
  }, [route, loggedIn]);
  // Changement d'écran lancé depuis le contenu (carte d'indicateur, « Tous les
  // trades »…) : l'élément activé disparaît avec l'ancien écran et le focus
  // retomberait sur <body>, en haut du menu. On le pose alors sur la zone de
  // contenu (#contenu, core.jsx). Fait dans un effet, une fois le nouvel écran
  // monté : on ne touche à rien si le focus existe encore (navigation par le
  // menu, champ en autoFocus du nouvel écran, fenêtre ouverte).
  const routeFocusRef = React.useRef(route);
  React.useEffect(() => {
    if (routeFocusRef.current === route) return;
    routeFocusRef.current = route;
    try {
      const a = document.activeElement;
      if (a && a !== document.body && a !== document.documentElement && a.isConnected) return;
      const contenu = document.getElementById('contenu');
      if (contenu && typeof contenu.focus === 'function') contenu.focus({ preventScroll: true });
    } catch (e) { /* document indisponible : rien à faire */ }
  }, [route]);
  // Stable d'un rendu à l'autre : les lignes de trade mémorisées (tableau
  // ordinateur, liste mobile) ne sont pas redessinées à chaque rendu d'App.
  const openTrade = React.useCallback((t) => setTradeDetail(t), []);
  const closeTrade = () => setTradeDetail(null);

  // Liens légaux entrants depuis la landing (landing.html) : ./index.html#cgu, #cgv
  // ou #confidentialite ouvrent le document correspondant, connecté ou non (Login et
  // Réglages gardent leurs propres boutons). Le hash est ensuite retiré de l'URL
  // (replaceState, query conservée pour ?mode=signup lu par Login) afin de ne pas
  // rejouer l'action plus tard ; les hash Supabase (#access_token=…) ne
  // correspondent jamais à ces valeurs exactes et ne sont pas touchés.
  const [legalKind, setLegalKind] = React.useState(null); // 'cgu' | 'cgv' | 'privacy' | null
  React.useEffect(() => {
    const readHash = () => {
      try {
        const hash = (window.location.hash || '').replace(/^#/, '').toLowerCase();
        const kind = hash === 'cgu' ? 'cgu' : hash === 'cgv' ? 'cgv' : hash === 'confidentialite' ? 'privacy' : null;
        if (!kind) return;
        setLegalKind(kind);
        if (window.history && typeof window.history.replaceState === 'function') {
          window.history.replaceState(null, '', window.location.pathname + (window.location.search || ''));
        }
      } catch (e) { /* URL illisible : on ignore */ }
    };
    readHash();
    window.addEventListener('hashchange', readHash);
    return () => window.removeEventListener('hashchange', readHash);
  }, []);
  // Utilisateur déjà connecté arrivant avec ?mode=signup : la query n'a plus de
  // sens (Login n'est pas monté) — on la retire pour qu'elle ne soit pas rejouée
  // à la prochaine déconnexion.
  React.useEffect(() => {
    if (!loggedIn) return;
    try {
      const params = new URLSearchParams(window.location.search || '');
      if (params.get('mode') === 'signup' && window.history && typeof window.history.replaceState === 'function') {
        window.history.replaceState(null, '', window.location.pathname + (window.location.hash || ''));
      }
    } catch (e) {}
  }, [loggedIn]);
  const legalModal = legalKind && typeof window.LegalModal === 'function'
    ? <window.LegalModal kind={legalKind} onClose={() => setLegalKind(null)}/>
    : null;

  const handleLogout = async () => {
    purgeLocalTempoData();
    // Hors ligne (ou API auth injoignable), supabase-js renvoie l'erreur AVANT
    // de retirer la session locale et n'émet pas SIGNED_OUT : on retire alors
    // nous-mêmes la clé de session et on recharge pour que la déconnexion soit
    // effective quoi qu'il arrive (le refresh token expirera côté serveur).
    let failed = false;
    try {
      const { error } = await window.sb.auth.signOut();
      failed = !!error;
    } catch (e) { failed = true; }
    if (failed) {
      try { localStorage.removeItem(window.sb.auth.storageKey || 'sb-twaldzmjryjyrzwjckpe-auth-token'); } catch (e) {}
      window.location.reload();
    }
    // session listener will flip loggedIn → false
  };

  // Ouvre le journal d'un jour. Accepte une date civile 'YYYY-MM-DD', un
  // horodatage (ramené au jour du fuseau du profil, jamais au jour UTC), un
  // trade ({ executed_at }) ou un objet { day: 'YYYY-MM-DD' } ; sinon aujourd'hui.
  // Un jour à venir n'a pas de journal (on ne tient pas le journal d'un jour
  // qui n'a pas eu lieu) : rien ne s'ouvre, sauf s'il contient déjà un trade
  // (import mal daté) ou un journal (notes, humeur ou captures enregistrées à
  // l'avance, ou fuseau du profil changé depuis) : le Journal quotidien affiche
  // ces jours, ils doivent rester atteignables. Garde qui fait autorité : tous
  // les écrans passent par ici.
  // Fonction stable (même raison qu'openTrade) : elle lit toujours le dernier
  // état par une référence.
  const journalStateRef = React.useRef(state);
  journalStateRef.current = state;
  const openJournal = React.useCallback((d) => {
    const st = journalStateRef.current;
    const src = d && typeof d === 'object' && !(d instanceof Date) ? (d.day || d.executed_at) : d;
    const day = appDayOf(src) || st.today;
    const known = (st.tradesByDay && st.tradesByDay[day]) || (st.journalDays && st.journalDays[day]);
    if (day > st.today && !known) return;
    setModalDay(day);
  }, []);

  const nav = (id) => {
    if (id === 'stats') setStatsInitialPeriod(null);
    setRoute(id);
    const journalingRoutes = ['dashboard', 'trades', 'daily', 'stats', 'weekly', 'strategy', 'data'];
    if (journalingRoutes.includes(id)) setJournalingOpen(true);
  };

  // Navigate to the Statistiques section, pre-opening a sub-view ('trading'|'habits').
  // Used by clickable KPI cards on Home/Dashboard.
  // period : 'week' | 'month' | 'year' | 'all' ; par défaut « Tout » pour le trading
  // (mêmes chiffres que la tuile cliquée), la période par défaut pour les habitudes.
  const navStats = (view, period) => {
    setStatsInitialView(view === 'habits' ? 'habits' : 'trading');
    nav('stats');
    setStatsInitialPeriod(period || (view === 'habits' ? null : 'all'));
  };

  // Fenêtre « Ajouter des trades ». openImport(jour, onglet) :
  //   jour   : 'YYYY-MM-DD' ou horodatage (pré-remplit la date de la saisie
  //            manuelle), null sinon ; tout autre type (événement de clic) est ignoré ;
  //   onglet : 'manual' (« Ajouter un trade »), 'csv' (« Importer un fichier »)
  //            ou 'broker'. Sans onglet : saisie manuelle si un jour est donné,
  //            sinon l'import de fichier. openImport(jour) reste compatible.
  const openImport = (forDay, tab) => {
    const iso = typeof forDay === 'string' ? appDayOf(forDay) : null;
    setImportForDay(iso);
    setImportTab(tab === 'manual' || tab === 'broker' || tab === 'csv' ? tab : (iso ? 'manual' : 'csv'));
    setImportOpen(true);
  };
  // « Connecter un courtier » : la connexion réelle (MetaTrader 5) vit dans
  // Réglages › Courtiers et synchronisation ; on y va directement.
  const openBrokers = () => {
    setSettingsSection('brokers');
    nav('settings');
  };
  // didImport : un import de fichier vient d'aboutir, tout l'historique est
  // relu. Un trade saisi à la main est déjà dans la liste (la fenêtre applique
  // la ligne écrite, state.applyTradeChange) : rien à relire.
  const closeImport = (didImport) => {
    setImportOpen(false);
    setImportForDay(null);
    if (didImport) state.refreshTrades();
  };

  const renderRoute = () => {
    // Wrap each route so a single broken screen keeps the app shell (sidebar/nav) alive.
    // key={route} resets the boundary when the user navigates away.
    return <ErrorBoundary key={route}>{renderRouteInner()}</ErrorBoundary>;
  };

  const renderRouteInner = () => {
    switch (route) {
      case 'home':        return <Home nav={nav} navStats={navStats} openJournal={openJournal} openImport={openImport} openTrade={openTrade} state={state}/>;
      case 'routines':    return <RoutinesPage state={state} nav={nav} navStats={navStats} openJournal={openJournal}/>;
      case 'dashboard':   return <Gate feature="journal"><Dashboard nav={nav} navStats={navStats} openImport={openImport} openJournal={openJournal} openTrade={openTrade} state={state}/></Gate>;
      case 'trades':      return <Gate feature="journal"><TradesPage nav={nav} openJournal={openJournal} openImport={openImport} openTrade={openTrade} state={state}/></Gate>;
      case 'daily':       return <Gate feature="journal"><Daily nav={nav} openJournal={openJournal} openImport={openImport} state={state}/></Gate>;
      case 'weekly':      return <Gate feature="journal"><WeeklyPage state={state} openJournal={openJournal} openImport={openImport}/></Gate>;
      case 'strategy':    return <Gate feature="journal"><StrategyPage state={state}/></Gate>;
      case 'backtest':    return <Gate feature="backtest"><BacktestPage state={state}/></Gate>;
      case 'fondamental': return <Gate feature="fondamental"><FondamentalPage state={state}/></Gate>;
      case 'stats':       return <Gate feature="journal"><StatsPage state={state} initialView={statsInitialView} period={statsInitialPeriod} openImport={openImport} nav={nav} openJournal={openJournal}/></Gate>;
      case 'data':        return <Gate feature="journal"><DataHubPage state={state} openImport={openImport} openBrokers={openBrokers}/></Gate>;
      case 'econ':        return <EconomicCalendar nav={nav}/>;
      case 'settings':    return <SettingsPage state={state} onLogout={handleLogout} openImport={openImport} section={settingsSection} onSectionShown={() => setSettingsSection(null)}/>;
      default:            return <Home nav={nav} navStats={navStats} openJournal={openJournal} openImport={openImport} openTrade={openTrade} state={state}/>;
    }
  };

  // While we resolve the session, show nothing (boot loader takes care of UI)
  if (!checked) return null;

  // Lien de récupération de mot de passe : forcer la définition du nouveau
  // mot de passe AVANT d'entrer dans l'app (la session de récupération connecte
  // déjà l'utilisateur, sans cet écran il ne pourrait jamais le changer).
  if (recovery && typeof window.UpdatePassword === 'function') {
    return <window.UpdatePassword onDone={clearRecovery}/>;
  }

  if (!loggedIn) {
    return (
      <React.Fragment>
        <Login onLogin={() => { /* session listener will flip state */ }} notice={deleteNotice ? appDeleteNoticeText() : ''}/>
        {legalModal}
      </React.Fragment>
    );
  }

  if (isMobile) {
    return (
      <React.Fragment>
        <MobileScreen active={route} onChange={nav} showNav={true}>
          <AppVersionNotice mobile/>
          <AppWriteNotices state={state} mobile/>
          <MobileContent route={route} nav={nav} navStats={navStats} statsInitialView={statsInitialView} statsInitialPeriod={statsInitialPeriod} openJournal={openJournal} openImport={openImport} openBrokers={openBrokers} openTrade={openTrade} state={state} onLogout={handleLogout} settingsSection={settingsSection} onSettingsSectionShown={() => setSettingsSection(null)}/>
        </MobileScreen>
        <JournalModal open={modalDay != null} dayISO={modalDay} onClose={() => setModalDay(null)} state={state} openImport={openImport} openTrade={openTrade}/>
        <ImportModal open={importOpen} onClose={closeImport} state={state} defaultDate={importForDay} initialTab={importTab}/>
        <TradeDetailModal open={tradeDetail != null} trade={tradeDetail} onClose={closeTrade} state={state}/>
        {legalModal}
      </React.Fragment>
    );
  }

  return (
    <React.Fragment>
      <DesktopScreen active={route} onChange={nav} journalingOpen={journalingOpen} setJournalingOpen={setJournalingOpen} user={state.user} displayName={state.profileReady ? state.displayName : ''}>
        <AppRouteFrame state={state}>{renderRoute()}</AppRouteFrame>
      </DesktopScreen>
      <JournalModal open={modalDay != null} dayISO={modalDay} onClose={() => setModalDay(null)} state={state} openImport={openImport} openTrade={openTrade}/>
      <ImportModal open={importOpen} onClose={closeImport} state={state} defaultDate={importForDay} initialTab={importTab}/>
      <TradeDetailModal open={tradeDetail != null} trade={tradeDetail} onClose={closeTrade} state={state}/>
      {legalModal}
    </React.Fragment>
  );
}

// Jour civil 'YYYY-MM-DD' d'une entrée : une date civile est gardée telle quelle
// (jamais projetée), un horodatage ou une Date est ramené au jour vécu dans le
// fuseau du profil (localDayInTz). Entrée absente ou illisible → null.
function appDayOf(x) {
  if (x == null || x === '') return null;
  if (typeof x === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(x)) return x;
  if (typeof x !== 'string' && !(x instanceof Date)) return null;
  const d = x instanceof Date ? x : new Date(x);
  return isNaN(d.getTime()) ? null : localDayInTz(d);
}

// Libellés de navigation : lus dans la table unique NAV_LABELS de core.jsx
// (menu, titre de page, description). Tant qu'elle n'est pas chargée, repli sur
// les mêmes libellés, tirés de la charte (docs/charte-ui.md §5).
const APP_NAV_FALLBACK = {
  home:        { menu: 'Accueil', title: 'Accueil' },
  econ:        { menu: 'Calendrier éco.', title: 'Calendrier économique', desc: 'Deux semaines d’événements, impact et valeur publiée' },
  routines:    { menu: 'Routines', title: 'Routines', desc: 'Habitudes, vérifications avant trade et agenda' },
  backtest:    { menu: 'Backtest', title: 'Backtest' },
  fondamental: { menu: 'Fondamental', title: 'Analyse fondamentale', desc: 'Contexte macro\u00A0: biais, catalyseurs et actualités' },
  dashboard:   { menu: 'Tableau de bord', title: 'Tableau de bord', desc: 'Indicateurs clés, jauges, P&L cumulé, calendrier' },
  trades:      { menu: 'Trades', title: 'Trades' },
  daily:       { menu: 'Journal quotidien', title: 'Journal quotidien', desc: 'Tes journaux quotidiens' },
  stats:       { menu: 'Statistiques', title: 'Statistiques' },
  weekly:      { menu: 'Bilan hebdo', title: 'Bilan hebdomadaire', desc: 'Bilan de tes semaines de trading' },
  strategy:    { menu: 'Stratégies', title: 'Stratégies', desc: 'Tes stratégies et leurs performances' },
  data:        { menu: 'Données', title: 'Gestion des données', desc: 'Import, synchronisation, export et nettoyage' },
  settings:    { menu: 'Réglages', title: 'Réglages', court: 'Plus' },
};
// Clés lues dans NAV_LABELS (core.jsx : menu, titre, court, desc).
const APP_NAV_KEYS = {
  menu:  ['menu'],
  title: ['titre', 'title'],
  court: ['court', 'menu'],
  desc:  ['desc'],
};
// appNavText('econ', 'menu' | 'title' | 'court' | 'desc') → texte affiché.
function appNavText(id, kind) {
  const table = typeof NAV_LABELS !== 'undefined' ? NAV_LABELS : (typeof window !== 'undefined' ? window.NAV_LABELS : null);
  const entry = table && table[id];
  if (entry && typeof entry === 'object') {
    for (const k of (APP_NAV_KEYS[kind] || [kind])) {
      if (typeof entry[k] === 'string' && entry[k]) return entry[k];
    }
  }
  const f = APP_NAV_FALLBACK[id] || {};
  return f[kind] || (kind === 'desc' ? '' : kind === 'title' ? (f.title || f.menu || id) : (f.menu || id));
}

// Source d'un trade, telle qu'affichée (charte §5) : jamais le code brut.
function appSourceLabel(src) {
  const s = String(src || '').trim().toLowerCase();
  if (!s) return '';
  if (s === 'manual') return 'Saisie manuelle';
  if (s === 'generic' || s === 'csv' || s === 'file' || s === 'html') return 'Import de fichier';
  if (s === 'mt5' || s === 'metatrader5' || s === 'metaapi') return 'MetaTrader 5';
  if (s === 'mt4' || s === 'metatrader4') return 'MetaTrader 4';
  if (s === 'backtest') return 'Backtest';
  if (s === 'tradovate') return 'Tradovate';
  return s.charAt(0).toUpperCase() + s.slice(1);
}

function MobileContent(props) {
  // Wrap each mobile screen so a broken route keeps the bottom nav alive.
  return <ErrorBoundary key={props.route}>{MobileContentInner(props)}</ErrorBoundary>;
}

function MobileContentInner({ route, nav, navStats, statsInitialView, statsInitialPeriod, openJournal, openImport, openBrokers, openTrade, state, onLogout, settingsSection, onSettingsSectionShown }) {
  switch (route) {
    case 'home':      return <HomeMobile nav={nav} navStats={navStats} openJournal={openJournal} openImport={openImport} openTrade={openTrade} state={state}/>;
    case 'dashboard': return <Gate feature="journal"><DashboardMobile nav={nav} navStats={navStats} openImport={openImport} openJournal={openJournal} openTrade={openTrade} state={state}/></Gate>;
    case 'trades':    return <Gate feature="journal"><TradesMobile openJournal={openJournal} openImport={openImport} openTrade={openTrade} state={state}/></Gate>;
    case 'daily':     return <Gate feature="journal"><DailyMobile openJournal={openJournal} openImport={openImport} state={state}/></Gate>;
    case 'weekly':    return <Gate feature="journal"><WeeklyMobile state={state} openJournal={openJournal} openImport={openImport}/></Gate>;
    case 'strategy':  return <Gate feature="journal"><StrategyMobile state={state}/></Gate>;
    case 'backtest':  return <Gate feature="backtest"><BacktestMobile state={state}/></Gate>;
    case 'fondamental': return <Gate feature="fondamental"><FondamentalMobile state={state}/></Gate>;
    case 'stats':     return <Gate feature="journal"><StatsMobile state={state} initialView={statsInitialView} period={statsInitialPeriod} openImport={openImport} nav={nav} openJournal={openJournal}/></Gate>;
    // Variantes mobiles des lots parallèles : branchées via garde typeof pour
    // basculer automatiquement dès qu'elles existent, sans casser en attendant.
    case 'routines':  return typeof RoutinesMobile === 'function' ? <RoutinesMobile state={state} nav={nav} navStats={navStats} openJournal={openJournal}/> : <RoutinesPage state={state} nav={nav} navStats={navStats} openJournal={openJournal}/>;
    case 'econ':      return typeof EconomicCalendarMobile === 'function' ? <EconomicCalendarMobile nav={nav}/> : <EconomicCalendar nav={nav}/>;
    // Même hub « Gestion des données » que sur ordinateur, sans le PageHeader desktop.
    case 'data':      return <Gate feature="journal"><DataHubPage mobile state={state} openImport={openImport} openBrokers={openBrokers}/></Gate>;
    case 'settings':  return <SettingsMobile state={state} onLogout={onLogout} openImport={openImport} nav={nav} section={settingsSection} onSectionShown={onSettingsSectionShown}/>;
    default:          return <HomeMobile nav={nav} navStats={navStats} openJournal={openJournal} openImport={openImport} openTrade={openTrade} state={state}/>;
  }
}

// Hub « Gestion des données » (ordinateur et mobile) : uniquement des actions
// qui existent vraiment — importer un fichier, ajouter un trade, connecter un
// courtier, réinitialiser le journal. Seul l'export, qui n'existe nulle part,
// porte « Bientôt disponible » (ligne inerte, pas un faux bouton).
function DataHubPage({ mobile = false, openImport, openBrokers, state }) {
  const title = appNavText('data', 'title');
  const desc = appNavText('data', 'desc');
  const descSentence = desc ? desc.replace(/\.\s*$/, '') + '.' : '';
  const body = (
    <React.Fragment>
      <div style={{ fontSize: 12, fontWeight: 700, color: 'var(--fg-2)', marginBottom: 10 }}>Ajouter des trades</div>
      <div style={{ marginBottom: 22 }}>
        <PlusNavRow onClick={() => openImport && openImport(null, 'csv')} label="Importer un fichier" desc="Export CSV ou HTML de ta plateforme, format détecté automatiquement" ico={Ico.upload}/>
        <PlusNavRow onClick={() => openImport && openImport(null, 'manual')} label="Ajouter un trade" desc="Saisie manuelle, trade par trade" ico={Ico.plus}/>
        <PlusNavRow onClick={openBrokers} label="Connecter un courtier" desc="Synchronisation automatique de ton compte MetaTrader 5" ico={Ico.sync}/>
      </div>
      <div style={{ fontSize: 12, fontWeight: 700, color: 'var(--fg-2)', marginBottom: 10 }}>Exporter</div>
      <div style={{ marginBottom: 22 }}>
        <PlusNavRow disabled badge="Bientôt disponible" label="Exporter mes trades" desc="Tout ton historique dans un fichier CSV" ico={Ico.data}/>
      </div>
      <div style={{ fontSize: 12, fontWeight: 700, color: 'var(--fg-2)', marginBottom: 10 }}>Nettoyage</div>
      <ResetJournalCard state={state}/>
    </React.Fragment>
  );
  if (mobile) {
    return (
      <div style={{ padding: '14px var(--m-gutter, 16px) 28px' }}>
        <h1 style={{ fontSize: 24, fontWeight: 700, letterSpacing: '-.025em', margin: '0 0 6px' }}>{title}</h1>
        <p style={{ fontSize: 13, color: 'var(--fg-3)', lineHeight: 1.5, margin: '0 0 18px' }}>{descSentence}</p>
        {body}
      </div>
    );
  }
  return (
    <div className="scroll" style={{ width: '100%', height: '100%', overflow: 'auto' }}>
      <PageHeader title={title}/>
      <div style={{ padding: '24px 24px 40px', maxWidth: 720 }}>
        <p style={{ fontSize: 13, color: 'var(--fg-2)', marginTop: 0, marginBottom: 20 }}>{descSentence}</p>
        {body}
      </div>
    </div>
  );
}

// Échec du chargement des trades (≠ compte vide, charte §7) : bandeau et
// « Réessayer » ; les trades déjà chargés restent affichés. Lit
// state.tradesError (vrai quand le dernier chargement a échoué, state.jsx).
function TradesLoadBanner({ state }) {
  if (!state || !state.tradesError) return null;
  return (
    <div role="alert" className="card" style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', padding: '12px 14px', marginBottom: 12, borderColor: 'var(--red)' }}>
      <span style={{ flex: 1, minWidth: 180, fontSize: 12.5, color: 'var(--fg)', lineHeight: 1.45 }}>Impossible de charger tes trades. Vérifie ta connexion.</span>
      <button type="button" onClick={() => state.refreshTrades && state.refreshTrades()} className="btn tap" style={{ fontSize: 12, color: 'var(--fg)', flexShrink: 0 }}>{Ico.refresh} Réessayer</button>
    </div>
  );
}

// Montant sur une ligne, jamais tronqué (charte §9). La taille de police descend
// de `max` à `min` pour tenir dans la largeur réellement disponible ; si le
// montant exact ne tient toujours pas à `min`, il passe au format compact
// (« +870,9 k CHF », montant exact en info-bulle). `value` null → « — ».
// La largeur du texte est lue sur une sonde invisible, hors flux et sans enfant
// React (même classe, même style, taille `max`) : on n'écrit jamais dans un
// nœud que React gère. Remesuré quand la largeur change et quand la police
// arrive (elle peut se charger après le premier rendu).
function AppFitAmount({ value, max = 36, min = 22, className, style }) {
  const boxRef = React.useRef(null);
  const probeRef = React.useRef(null);
  const [fit, setFit] = React.useState({ size: max, compact: false });
  const full = value == null ? '—' : fmtMoney(value, true);
  const short = value == null ? '—' : fmtMoneyCompact(value, true);
  React.useLayoutEffect(() => {
    const box = boxRef.current, probe = probeRef.current;
    if (!box || !probe) return undefined;
    let live = true;
    const widthOf = (text) => { probe.textContent = text; return probe.getBoundingClientRect().width; };
    const measure = () => {
      if (!live) return;
      const avail = box.clientWidth;
      if (!avail) return; // écran pas encore mis en page : la prochaine mesure s'en charge
      let compact = false;
      let w = widthOf(full);
      if (w * (min / max) > avail && short !== full) { compact = true; w = widthOf(short); }
      // Arrondi au dixième inférieur : jamais un pixel de trop.
      const size = w > avail ? Math.max(min, Math.floor(max * avail / w * 10) / 10) : max;
      setFit(f => (f.size === size && f.compact === compact ? f : { size, compact }));
    };
    measure();
    const fonts = typeof document !== 'undefined' ? document.fonts : null;
    try {
      if (fonts && fonts.ready && typeof fonts.ready.then === 'function') fonts.ready.then(measure);
      if (fonts && typeof fonts.addEventListener === 'function') fonts.addEventListener('loadingdone', measure);
    } catch (e) { /* API des polices indisponible : la mesure initiale suffit */ }
    let ro = null;
    if (typeof ResizeObserver === 'function') { ro = new ResizeObserver(measure); ro.observe(box); }
    return () => {
      live = false;
      if (ro) ro.disconnect();
      try { if (fonts && typeof fonts.removeEventListener === 'function') fonts.removeEventListener('loadingdone', measure); } catch (e) {}
    };
  }, [full, short, max, min]);
  return (
    <div ref={boxRef} style={{ minWidth: 0, position: 'relative' }}>
      <span aria-hidden="true" style={{ position: 'absolute', left: 0, top: 0, width: 0, height: 0, overflow: 'hidden', visibility: 'hidden', pointerEvents: 'none' }}>
        <span ref={probeRef} className={className} style={{ ...style, fontSize: max, whiteSpace: 'nowrap' }}></span>
      </span>
      <div className={className} title={fit.compact ? full : undefined}
        style={{ ...style, fontSize: fit.size, whiteSpace: 'nowrap' }}>{fit.compact ? short : full}</div>
    </div>
  );
}

function HomeMobile({ nav, navStats, openJournal, openImport, openTrade, state }) {
  const t = useLiveTime();
  // Largeurs mesurées (usePgWidth, pages.jsx) : grilles adaptées à la tablette.
  const [clockRef, clockW] = usePgWidth();
  const [kpiRef, kpiW] = usePgWidth();
  const goStatsTrading = navStats ? () => navStats('trading') : (nav ? () => nav('stats') : undefined);
  const { agenda, toggleAgenda, stats, tradesLoading } = state;
  // Marchés : une seule source de vérité (format-prefs), chaque place dans son fuseau.
  const tz = window.__appTz || 'Europe/Paris';
  const market = sessionStatusInTz(tz, t);
  // Date du jour dans le fuseau du profil : « Samedi 26 septembre · Semaine 39 »
  // (même construction que l'Accueil ordinateur, pages-2.jsx).
  const todayDay = state.today || todayISO();
  const dayLong = fmtDayLong(todayDay);
  const dateLabel = typeof buildDateLabel === 'function'
    ? buildDateLabel()
    : dayLong.charAt(0).toUpperCase() + dayLong.slice(1) + ' · ' + fmtWeekLabel(todayDay);
  // Prénom du profil, seulement une fois le profil chargé : jamais « Bonjour, demo »
  // (tiré de l'e-mail) remplacé une seconde plus tard par le vrai prénom.
  const greeting = state.profileReady ? (state.displayName || '') : '';
  const trades = Array.isArray(state.trades) ? state.trades : [];
  const net = Number(stats.netPnl) || 0;
  const recent = trades.slice(0, 4);
  // Un rafraîchissement (après un import) garde les chiffres affichés.
  const hasTrades = stats.count > 0;
  // Premier chargement ou échec sans données : ni « Aucun trade », ni état vide.
  const firstLoad = !!tradesLoading && !hasTrades;
  const noData = firstLoad || (!!state.tradesError && !hasTrades);
  const nuls = Number.isFinite(stats.breakeven) ? stats.breakeven : Math.max(0, (stats.count || 0) - (stats.wins || 0) - (stats.losses || 0));
  // Nombre de trades notés en R (même règle que computeStats : R fini et non nul).
  const rCount = Number.isFinite(stats.rCount) ? stats.rCount : appCountR(trades);

  // Pastille et horloges : mêmes règles que l'Accueil ordinateur (pages-2.jsx) —
  // l'heure du profil, puis Londres, New York et Tokyo, chacune dans son fuseau ;
  // la place du profil n'est pas répétée, son horloge porte l'état de sa séance.
  const heroPill = typeof homeMarketPill === 'function'
    ? homeMarketPill(market)
    : { text: market.cmeOpen ? market.cmeLabel : 'Marchés fermés', open: !!market.cmeOpen };
  const sessions = Array.isArray(market.sessions) ? market.sessions : [];
  const mine = sessions.find(s => s.tz === tz);
  const clocks = [{ k: 'me', label: 'Ton heure · ' + tzLabel(tz), time: tzTimeIn(t, tz), open: mine ? mine.open : false, status: mine ? mine.label : null }].concat(
    ['london', 'newyork', 'tokyo']
      .map(k => sessions.find(s => s.key === k))
      .filter(s => s && s.tz !== tz)
      .map(s => ({ k: s.key, label: s.name, time: tzTimeIn(t, s.tz), open: s.open, status: s.label }))
  );

  return (
    <div style={{ padding: '8px 0 20px' }}>
      <div style={{ padding: '14px var(--m-gutter, 16px) 16px' }}>
        <div style={{ fontSize: 12.5, color: 'var(--fg-3)', fontWeight: 500, marginBottom: 8 }}>{dateLabel}</div>
        <h1 style={{ fontSize: 28, fontWeight: 700, letterSpacing: '-.025em', margin: 0, lineHeight: 1.1 }}>Bonjour{greeting ? ', ' + greeting : ''}</h1>
      </div>
      {state.tradesError && <div style={{ padding: '0 var(--m-gutter, 16px)' }}><TradesLoadBanner state={state}/></div>}
      {/* Appareil et profil à des heures différentes : le même bandeau que sur ordinateur. */}
      <div style={{ padding: '0 var(--m-gutter, 16px)' }}><AppTzNotice state={state} style={{ marginBottom: 14 }}/></div>

      <div style={{ padding: '0 var(--m-gutter, 16px) 14px' }}>
        <div className="on-dark" style={{ background: 'linear-gradient(135deg, #0f172a 0%, #0c4a6e 100%)', borderRadius: 16, padding: '20px 22px', color: '#fff', position: 'relative', overflow: 'hidden' }}>
          {heroPill.open ? (
            <span className="pill" style={{ background: 'rgba(34,197,94,0.20)', 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.72)', 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>
          )}
          <div style={{ marginTop: 14 }}>
            <div style={{ fontSize: 11.5, color: 'rgba(255,255,255,0.72)', letterSpacing: '.06em', textTransform: 'uppercase', fontWeight: 500, marginBottom: 6 }}>P&amp;L net</div>
            {/* Montant entier à toutes les largeurs : la taille s'adapte (AppFitAmount). */}
            <AppFitAmount value={hasTrades ? net : null} max={36} min={22} className="num"
              style={{ fontWeight: 700, letterSpacing: '-.025em', color: !hasTrades || net === 0 ? '#fff' : net > 0 ? '#86efac' : '#fda4af', lineHeight: 1 }}/>
          </div>
          {/* Horloges : toutes sur une ligne dès que la carte est assez large
              (tablette), sinon deux par ligne — jamais une horloge seule. */}
          <div ref={clockRef} style={{ display: 'grid', gridTemplateColumns: `repeat(${clockW >= 520 ? clocks.length : 2}, minmax(0, 1fr))`, columnGap: 12, rowGap: 12, marginTop: 16, paddingTop: 14, borderTop: '1px solid rgba(255,255,255,0.10)' }}>
            {/* Nombre impair sur deux colonnes (profil à Londres, New York ou Tokyo) :
                « Ton heure » prend toute la première ligne (même règle que l'ordinateur). */}
            {clocks.map((s, i) => (
              <div key={s.k} style={{ minWidth: 0, gridColumn: clockW < 520 && clocks.length % 2 === 1 && i === 0 ? '1 / -1' : undefined }}>
                <div style={{ fontSize: 9.5, fontWeight: 600, letterSpacing: '.06em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.72)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.label}</div>
                <div className="mono" style={{ fontSize: 13, color: '#fff', marginTop: 3 }}>{s.time}</div>
                {s.status && (
                  <div style={{ display: 'flex', alignItems: 'center', gap: 5, marginTop: 3, fontSize: 10.5, color: s.open ? '#86efac' : 'rgba(255,255,255,0.72)', whiteSpace: 'nowrap' }}>
                    <span style={{ width: 5, height: 5, borderRadius: '50%', flexShrink: 0, background: s.open ? '#22c55e' : 'rgba(248,113,113,0.7)' }}></span>
                    {s.status}
                  </div>
                )}
              </div>
            ))}
          </div>
        </div>
      </div>

      {/* Trois indicateurs sur une ligne quand la largeur le permet (tablette) ;
          sinon 2 + Profit factor en pleine largeur (375 px). */}
      {/* Bloc cliquable = bouton (comme les cartes de l'Accueil ordinateur) :
          atteignable au clavier, Entrée ou Espace. Pas d'aria-label : il
          remplacerait les valeurs lues ; la destination est donnée en description. */}
      <div ref={kpiRef} onClick={goStatsTrading} className={goStatsTrading ? 'tap' : ''}
        role={goStatsTrading ? 'button' : undefined} tabIndex={goStatsTrading ? 0 : undefined}
        aria-describedby={goStatsTrading ? 'm-home-kpi-hint' : undefined}
        onKeyDown={goStatsTrading ? (e) => {
          if (e.target !== e.currentTarget || (e.key !== 'Enter' && e.key !== ' ')) return;
          e.preventDefault();
          goStatsTrading();
        } : undefined}
        style={{ padding: '0 var(--m-gutter, 16px) 14px', display: 'grid', gridTemplateColumns: `repeat(${kpiW >= 520 ? 3 : 2}, minmax(0, 1fr))`, gap: 10, cursor: goStatsTrading ? 'pointer' : 'default' }}>
        {goStatsTrading && <span id="m-home-kpi-hint" hidden>Ouvrir les statistiques de trading</span>}
        <QuickStat label="Taux de réussite" loading={firstLoad} display={hasTrades ? fmtPct(stats.winRate) : '—'} sub={hasTrades ? appWinLossLabel(stats.wins, stats.losses, nuls) : (noData ? null : 'aucun trade')}/>
        <QuickStat label="R moyen" loading={firstLoad} pos={hasTrades && rCount > 0 && stats.avgR > 0} neg={hasTrades && rCount > 0 && stats.avgR < 0} display={hasTrades && rCount > 0 ? fmtR(stats.avgR) : '—'} sub={!hasTrades ? (noData ? null : 'aucun trade') : rCount > 0 ? appCount(rCount, 'trade') + ' en R' : 'R non renseigné'}/>
        {/* Profit factor — 4e indicateur de l'Accueil desktop ; pleine largeur en 2 colonnes. */}
        <div style={{ gridColumn: kpiW >= 520 ? 'auto' : '1 / -1' }}>
          <QuickStat label="Profit factor" loading={firstLoad} display={hasTrades ? appPFText(stats) : '—'} sub={hasTrades ? 'sur tous les trades' : (noData ? null : 'aucun trade')}/>
        </div>
      </div>

      {/* Raccourcis vers les écrans hors barre du bas — parité avec les NavTiles desktop. */}
      <HomeShortcutsRow nav={nav}/>

      {/* Vérifications avant trade — la carte de l'Accueil ordinateur (pages-2.jsx). */}
      <div style={{ padding: '0 var(--m-gutter, 16px) 14px' }}>
        <HomePretradeCard state={state} nav={nav} openImport={openImport} compact/>
      </div>

      <div style={{ padding: '0 var(--m-gutter, 16px) 14px' }}>
        <div className="card" style={{ padding: '16px 18px' }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 12 }}>
            <CardTitle style={{ fontSize: 14, letterSpacing: 'normal' }}>Agenda du jour</CardTitle>
            <button type="button" onClick={() => nav('routines')} className="tap tap-area" style={{ background: 'transparent', border: 'none', padding: 0, color: 'var(--blue-600)', fontSize: 11.5, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>Modifier ›</button>
          </div>
          {/* Lecture en échec : message et « Réessayer », jamais « Aucun événement »
              (charte §7) ; les événements déjà chargés restent affichés dessous. */}
          <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 ? (
            // Agenda pas encore lu : un squelette, jamais « Aucun événement »
            // (même règle que l'Accueil ordinateur, pages-2.jsx).
            <HomeHabitSkeleton label="Chargement de ton agenda"/>
          ) : (
            <div style={{ fontSize: 12, color: 'var(--fg-3)', padding: '8px 0' }}>Aucun événement aujourd’hui — ajoute-en depuis Routines.</div>
          )}
        </div>
      </div>

      {/* Habitudes — la carte de l'Accueil ordinateur (pages-2.jsx). */}
      <div style={{ padding: '0 var(--m-gutter, 16px) 14px' }}>
        <HomeHabitsCard state={state} nav={nav} navStats={navStats} compact/>
      </div>

      <div style={{ padding: '0 var(--m-gutter, 16px) 14px' }}>
        <div className="card" style={{ padding: '16px 18px' }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 12 }}>
            <CardTitle style={{ fontSize: 14, letterSpacing: 'normal' }}>Trades récents</CardTitle>
            {/* Même raccourci que l'Accueil ordinateur ; l'ajout est déjà proposé par la carte Vérifications. */}
            {nav && <button type="button" onClick={() => nav('trades')} className="tap tap-area" style={{ background: 'transparent', border: 'none', padding: 0, color: 'var(--blue-600)', fontSize: 11.5, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>Tous les trades ›</button>}
          </div>
          {firstLoad ? (
            <div role="status" aria-busy="true" aria-label="Chargement de tes derniers trades" style={{ display: 'flex', flexDirection: 'column', gap: 16, padding: '8px 0' }}>
              {[0, 1, 2].map(i => (
                <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
                  <div style={{ width: '30%', height: 10, borderRadius: 6, background: 'var(--bg-elev)' }}></div>
                  <div style={{ width: '18%', height: 12, borderRadius: 6, background: 'var(--bg-elev)' }}></div>
                  <div style={{ flex: 1 }}></div>
                  <div style={{ width: '22%', height: 14, borderRadius: 6, background: 'var(--bg-elev)' }}></div>
                </div>
              ))}
            </div>
          ) : recent.length === 0 ? (
            state.tradesError
              ? <div style={{ fontSize: 12, color: 'var(--fg-3)', padding: '8px 0' }}>Tes trades n’ont pas pu être chargés.</div>
              : <EmptyState icon={Ico.trades} title={EMPTY_TRADES_TITLE} text={EMPTY_TRADES_TEXT} compact/>
          ) : (
            // Mêmes lignes que l'écran Trades mobile, en version courte : sens,
            // symbole, date et heure dessous, P&L. Un tableau à quatre colonnes ne
            // tient pas en 320 px (date tronquée, en-têtes collés).
            <div>
              {recent.map((tr, i) => <MobileTradeRow key={tr.id || i} trade={tr} first={i === 0} brief openTrade={openTrade} openJournal={openJournal}/>)}
            </div>
          )}
        </div>
      </div>

      <div style={{ padding: '0 var(--m-gutter, 16px) 14px' }}>
        {/* Actualités : même carte que sur ordinateur (news.jsx), en variante compacte. */}
        <div className="card" style={{ padding: '16px 18px' }}>
          <NewsBriefing compact/>
        </div>
      </div>
    </div>
  );
}

// Rangée de raccourcis horizontaux scrollables de l'Accueil mobile — expose les
// écrans absents de la barre du bas (parité avec les NavTiles du Home desktop).
function HomeShortcutsRow({ nav }) {
  if (typeof nav !== 'function') return null;
  // Libellés courts de NAV_LABELS (core.jsx), les mêmes que partout ailleurs.
  const shortcuts = [
    { id: 'dashboard',   ico: Ico.dashboard },
    { id: 'daily',       ico: Ico.daily },
    { id: 'weekly',      ico: Ico.weekly },
    { id: 'strategy',    ico: Ico.strategy },
    { id: 'econ',        ico: Ico.econ },
    { id: 'routines',    ico: Ico.book },
    { id: 'fondamental', ico: Ico.fondamental },
  ].map(s => ({ ...s, label: appNavText(s.id, 'court') }));
  return (
    // Zone tactile de 44 px de haut autour de chaque pastille (.tap-area) : la
    // rangée garde 6 px au-dessus (compensés par la marge) pour ne pas la rogner.
    <div className="scroll" style={{ display: 'flex', gap: 8, overflowX: 'auto', padding: '6px var(--m-gutter, 16px) 14px', marginTop: -6, WebkitOverflowScrolling: 'touch' }}>
      {shortcuts.map(s => (
        <button key={s.id} type="button" onClick={() => nav(s.id)} className="tap tap-area" style={{
          flexShrink: 0, display: 'inline-flex', alignItems: 'center', gap: 7,
          padding: '8px 13px', borderRadius: 999,
          border: '1px solid var(--line)', background: 'var(--bg-card)',
          color: 'var(--fg-2)', fontSize: 12.5, fontWeight: 600, cursor: 'pointer',
          fontFamily: 'inherit', letterSpacing: '-.005em', whiteSpace: 'nowrap',
        }}>
          <span style={{ display: 'flex', color: 'var(--blue-600)' }}>{React.cloneElement(s.ico, { width: 14, height: 14 })}</span>
          {s.label}
        </button>
      ))}
    </div>
  );
}

// Jauges du Dashboard empilées en colonne : réutilise DashGaugesRow desktop
// (dashboard-widgets.jsx) via sa prop `stack` — mêmes calculs, zéro duplication.
function DashGaugesColumnMobile({ stats }) {
  if (typeof DashGaugesRow !== 'function') return null;
  return <DashGaugesRow stats={stats} stack={true}/>;
}

// Calendrier P&L complet sur mobile : CalendarBlock desktop (source de vérité)
// rendu via sa prop `compact` (pages.jsx) — cases resserrées 7 colonnes, totaux
// hebdo en liste sous la grille. Tient sur 390px sans scroll horizontal.
function MobileCalendarScroller({ nav, openJournal, openImport, tradesByDay, today, showAdd = true }) {
  if (typeof CalendarBlock !== 'function') return null;
  return <CalendarBlock nav={nav} onDayClick={openJournal} openImport={openImport} tradesByDay={tradesByDay} compact={true} today={today} showAdd={showAdd}/>;
}

function DashboardMobile({ nav, navStats, openImport, openJournal, openTrade, state }) {
  const goStats = navStats ? () => navStats('trading') : (nav ? () => nav('stats') : undefined);
  const { stats, tradesLoading, tradesByDay, tradesError } = state || { stats: {}, tradesLoading: true, tradesByDay: {} };
  const trades = state && Array.isArray(state.trades) ? state.trades : [];
  const hasTrades = trades.length > 0;
  // Un échec de chargement n'est pas un compte vide : bandeau, pas d'accueil « premier trade ».
  const empty = !tradesLoading && !tradesError && !hasTrades;
  // P&L cumulé et drawdown max — même calcul que le Tableau de bord ordinateur (pages.jsx).
  const cumulative = React.useMemo(() => dashCumulative(trades), [trades]);
  const addTrade = () => openImport && openImport(null, 'manual');
  const importFile = () => openImport && openImport(null, 'csv');

  return (
    <div style={{ padding: '14px var(--m-gutter, 16px) 24px' }}>
      <h1 style={{ fontSize: 24, fontWeight: 700, letterSpacing: '-.025em', margin: '0 0 12px' }}>{appNavText('dashboard', 'title')}</h1>
      <TradesLoadBanner state={state}/>
      {hasTrades && (
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 8, marginBottom: 14 }}>
          <button type="button" onClick={importFile} className="btn tap" style={{ fontSize: 12, justifyContent: 'center', color: 'var(--fg)' }}>{Ico.upload} Importer un fichier</button>
          <button type="button" onClick={addTrade} className="btn btn-blue tap" style={{ fontSize: 12, justifyContent: 'center', color: '#fff' }}>{Ico.plus} Ajouter un trade</button>
        </div>
      )}

      {/* Squelette seulement au premier chargement : un rafraîchissement garde le contenu. */}
      {tradesLoading && !hasTrades ? (
        <div className="card" aria-busy="true" style={{ padding: '40px 20px', textAlign: 'center', color: 'var(--fg-3)', fontSize: 13, marginBottom: 14 }}>Chargement des données…</div>
      ) : empty ? (
        <DashboardEmpty openImport={openImport} compact/>
      ) : hasTrades ? (
        <React.Fragment>
          {/* Indicateurs clés — les mêmes que sur ordinateur (P&L net, espérance,
              drawdown max, gain/perte moyens) ; taux de réussite et profit
              factor sont portés par les jauges juste dessous. */}
          <DashKpiRow stats={stats} maxDD={cumulative.maxDD} onClick={goStats} compact/>

          {/* Jauges compteur en colonne */}
          <DashGaugesColumnMobile stats={stats}/>

          {/* P&L cumulé pleine largeur (la courbe mesure sa carte) */}
          {cumulative.points.length > 1 && typeof CumulativeChartReal === 'function' && typeof ChartCard === 'function' && (
            <div style={{ marginBottom: 14 }}>
              <ChartCard title="P&L cumulé" subtitle={dashCumulativeSubtitle(stats.count, cumulative.dates)}>
                <CumulativeChartReal points={cumulative.points} dates={cumulative.dates} H={190}/>
              </ChartCard>
            </div>
          )}
        </React.Fragment>
      ) : null}

      {/* Calendrier P&L complet — toujours affiché, données réelles. */}
      <MobileCalendarScroller nav={nav} openJournal={openJournal} openImport={openImport} tradesByDay={tradesByDay} today={state && state.today} showAdd={!empty}/>
    </div>
  );
}

// « 1 246 trades » : nombre au format français et accord du mot :
// formateur partagé fmtCount (format-prefs.jsx).
function appCount(n, mot) {
  return fmtCount(n, mot);
}

// Ligne d'un trade sur mobile (écran Trades et « Trades récents » de l'Accueil).
// Première ligne : pastille de sens, symbole et stratégie, P&L. Seconde ligne,
// sur toute la largeur restante : date, prix, quantité et source, qui passent
// à la ligne au besoin, et le R à droite. Le P&L et le R gardent toute leur
// largeur (charte §9) ; les détails ne sont pas enfermés dans la colonne que
// laisse le P&L : en 320 px, un prix à six chiffres y débordait sur le montant.
// `brief` : version courte de l'Accueil (sens, symbole, date et heure, P&L).
// La ligne est un vrai bouton pour le clavier et les lecteurs d'écran (Entrée
// ou Espace). Son nom remplace son contenu pour un lecteur d'écran : il dit
// donc ce que la ligne montre (pg2TradeRowLabel, pages-2.jsx, comme les lignes
// du tableau ordinateur) : « Ouvrir le trade GBPUSD du 9 oct. · 18:17 : Vente,
// stratégie Breakout VWAP, −0,58 R, P&L −46,40 $ » ; une valeur absente est omise.
// Mémorisée (React.memo) : une ligne n'est redessinée que si son trade change.
// MobileTradeRow, que les écrans appellent, lui ajoute `fmtKey`, la signature
// des préférences d'affichage (devise, fuseau, jour courant), comme TradeTable.
const APP_ROW_COLS_BRIEF = ['side', 'strategy', 'r', 'pnl'];
const APP_ROW_COLS_FULL = ['side', 'entry', 'exit', 'lots', 'strategy', 'r', 'pnl'];
function appTradeRowLabel(t, brief) {
  if (typeof pg2TradeRowLabel === 'function') return pg2TradeRowLabel(t, brief ? APP_ROW_COLS_BRIEF : APP_ROW_COLS_FULL, !brief);
  return `Ouvrir le trade ${t.symbol || ''} du ${fmtShortDate(t.executed_at)}`;
}
function MobileTradeRow(props) {
  const fmtKey = [window.__appCurrency, window.__appTz, todayISO()].join('|');
  return <MobileTradeRowMemo {...props} fmtKey={fmtKey}/>;
}
const MobileTradeRowMemo = React.memo(function MobileTradeRowMemo({ trade: t, first = false, brief = false, openTrade, openJournal }) {
  const pnl = Number(t.pnl) || 0;
  const hasR = t.r_multiple !== null && t.r_multiple !== undefined && t.r_multiple !== '' && Number.isFinite(Number(t.r_multiple));
  const r = hasR ? Number(t.r_multiple) : null;
  const isSell = t.direction === 'short';
  const lots = Number(t.lots);
  // Même libellé de source que sur ordinateur (« Saisie manuelle » compris).
  const source = typeof tradeSourceLabel === 'function' ? tradeSourceLabel(t.imported_from) : appSourceLabel(t.imported_from);
  const priceDigits = appPriceDigits(t.entry, t.exit_price);
  const hasPrices = !brief && (t.entry != null || t.exit_price != null);
  const open = () => {
    if (openTrade) return openTrade(t);
    if (openJournal) return openJournal(t.executed_at);
  };
  return (
    <div role="button" tabIndex={0} className="tap"
      aria-label={appTradeRowLabel(t, brief)}
      onClick={open}
      onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } }}
      style={{
        display: 'grid', gridTemplateColumns: '28px minmax(0, 1fr) auto',
        padding: '12px 0', borderTop: first ? 'none' : '1px solid var(--line)', alignItems: 'center', columnGap: 10, rowGap: 2, cursor: 'pointer',
      }}>
      {/* Pastille de sens A (Achat) / V (Vente) — même code couleur que le desktop.
          Centrée sur la hauteur des deux lignes. */}
      <span className="pill" title={isSell ? 'Vente' : 'Achat'} aria-label={isSell ? 'Vente' : 'Achat'} style={{
        gridRow: '1 / span 2', alignSelf: 'center',
        background: isSell ? 'var(--red-soft)' : 'var(--green-soft)',
        color: isSell ? 'var(--red-text)' : 'var(--green-text)',
        border: 'none', padding: 0, width: 26, height: 26, fontSize: 11, fontWeight: 700,
        borderRadius: 7, justifyContent: 'center',
      }}>{isSell ? 'V' : 'A'}</span>
      <div style={{ minWidth: 0, fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
        {t.symbol || '—'}{t.setup && !brief ? <> · <span style={{ color: 'var(--fg-3)', fontWeight: 500 }}>{t.setup}</span></> : null}
      </div>
      <div className={'num ' + (pnl > 0 ? 'pos' : pnl < 0 ? 'neg' : '')} style={{ fontSize: 14, fontWeight: 600, whiteSpace: 'nowrap', textAlign: 'right' }}>{fmtMoney(pnl, true)}</div>
      <div style={{ gridColumn: '2 / 4', minWidth: 0, display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', columnGap: 8 }}>
        <div style={{ flex: '1 1 auto', minWidth: 0, fontSize: 10.5, color: 'var(--fg-3)', display: 'flex', alignItems: 'center', columnGap: 6, rowGap: 2, flexWrap: 'wrap' }}>
          <span style={{ whiteSpace: 'nowrap' }}>{fmtShortDate(t.executed_at)}</span>
          {/* « entrée → sortie » reste d'un bloc tant qu'il tient sur une ligne ;
              au-delà (très grands prix), la sortie passe sous l'entrée, sans
              jamais couper un nombre ni déborder. */}
          {hasPrices && (
            <span className="mono" style={{ minWidth: 0 }}>
              <span style={{ whiteSpace: 'nowrap' }}>{fmtPrice(t.entry, priceDigits)}</span>{' '}
              <span style={{ whiteSpace: 'nowrap' }}>→ {fmtPrice(t.exit_price, priceDigits)}</span>
            </span>
          )}
          {!brief && t.lots != null && t.lots !== '' && Number.isFinite(lots) && <span className="mono" style={{ whiteSpace: 'nowrap' }}>{fmtLots(lots)} lot{Math.abs(lots) >= 2 ? 's' : ''}</span>}
          {!brief && source && (
            <span style={{
              fontSize: 10.5, fontWeight: 600,
              padding: '1px 5px', borderRadius: 4, whiteSpace: 'nowrap',
              background: 'var(--bg-elev)', color: 'var(--fg-2)',
            }}>{source}</span>
          )}
        </div>
        {!brief && hasR && <div className={'mono ' + (r > 0 ? 'pos' : r < 0 ? 'neg' : '')} style={{ flexShrink: 0, fontSize: 10.5, fontWeight: 500, whiteSpace: 'nowrap', textAlign: 'right' }}>{fmtR(r)}</div>}
      </div>
    </div>
  );
});

function TradesMobile({ openJournal, openImport, openTrade, state }) {
  const { trades, tradesLoading, refreshTrades, tradesError } = state || { trades: [], tradesLoading: false };
  const [symbolFilter, setSymbolFilter] = React.useState('');
  // « Actualiser » : désactivé pendant le rechargement, comme sur ordinateur.
  const [refreshing, setRefreshing] = React.useState(false);
  const refresh = async () => {
    if (!refreshTrades || refreshing) return;
    setRefreshing(true);
    try { await refreshTrades(); } finally { setRefreshing(false); }
  };

  // Mêmes filtres que le TradesPage desktop : symbole + actualiser + compteur.
  // Le filtre vient de la liste des symboles : égalité stricte, comme sur
  // ordinateur (une sous-chaîne ferait entrer MES sous ES, US300 sous US30).
  const filteredTrades = React.useMemo(() => {
    let arr = trades;
    if (symbolFilter) arr = arr.filter(t => (t.symbol || '') === symbolFilter);
    return arr;
  }, [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é) : la
  // liste déroulante n'a plus cette option et afficherait « Tous les symboles »
  // au-dessus d'une liste vide. On revient à tous les symboles — sauf pendant
  // un chargement ou après un échec, où la liste est seulement incomplète.
  React.useEffect(() => {
    if (symbolFilter && !tradesLoading && !tradesError && !allSymbols.includes(symbolFilter)) setSymbolFilter('');
  }, [allSymbols, symbolFilter, tradesLoading, tradesError]);

  const addTrade = () => openImport && openImport(null, 'manual');
  const importFile = () => openImport && openImport(null, 'csv');
  const hasTrades = trades.length > 0;
  // Liste par pages de 200 lignes, comme sur ordinateur (useTradesPaging,
  // pages-2.jsx) : avec des milliers de trades, l'écran s'ouvre sans bloquer.
  // Le compteur reste calculé sur tous les trades filtrés.
  const paging = useTradesPaging(filteredTrades, symbolFilter);

  return (
    <div style={{ padding: '14px var(--m-gutter, 16px) 24px' }}>
      <h1 style={{ fontSize: 24, fontWeight: 700, letterSpacing: '-.025em', margin: '0 0 12px' }}>{appNavText('trades', 'title')}</h1>
      {/* Actions seulement s'il y a des trades : sinon l'état vide les porte (charte §7).
          Même rangée pleine largeur que le Tableau de bord mobile, mêmes libellés. */}
      {hasTrades && (
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 8, marginBottom: 14 }}>
          <button type="button" onClick={importFile} className="btn tap" style={{ fontSize: 12, justifyContent: 'center', color: 'var(--fg)' }}>{Ico.upload} Importer un fichier</button>
          <button type="button" onClick={addTrade} className="btn btn-blue tap" style={{ fontSize: 12, justifyContent: 'center', color: '#fff' }}>{Ico.plus} Ajouter un trade</button>
        </div>
      )}

      <TradesLoadBanner state={state}/>

      {/* Barre filtre : select symbole + actualiser + compteur (parité desktop).
          En 320 px le compteur passe à la ligne plutôt que d'écraser la liste. */}
      {hasTrades && <div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 8, marginBottom: 12 }}>
        <select value={symbolFilter} onChange={e => setSymbolFilter(e.target.value)} className="input" aria-label="Symbole" style={{ flex: '1 1 130px', minWidth: 0, 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={{ fontSize: 12, flexShrink: 0, color: 'var(--fg)', opacity: refreshing ? .55 : 1, cursor: refreshing ? 'not-allowed' : 'pointer' }}>{Ico.refresh} Actualiser</button>
        {/* « 2 trades sur 46 » quand un filtre est actif, comme sur ordinateur. */}
        <span style={{ fontSize: 11.5, color: 'var(--fg-3)', fontWeight: 600, flexShrink: 0, whiteSpace: 'nowrap' }}>{appCount(filteredTrades.length, 'trade')}{symbolFilter ? ' sur ' + fmtNum(trades.length, 0) : ''}</span>
      </div>}

      <div ref={paging.listRef} className="card" style={{ padding: '4px 16px' }}>
        {tradesLoading && !hasTrades ? (
          <div role="status" aria-busy="true" style={{ fontSize: 12, color: 'var(--fg-3)', padding: '12px 0' }}>Chargement…</div>
        ) : filteredTrades.length === 0 && tradesError ? (
          <div style={{ fontSize: 12, color: 'var(--fg-3)', padding: '12px 0' }}>Tes trades n’ont pas pu être chargés.</div>
        ) : filteredTrades.length === 0 ? (
          trades.length === 0
            ? <EmptyTrades onAdd={addTrade} onImport={importFile} compact/>
            : <EmptyState title="Aucun trade ne correspond à ce filtre" text="Choisis un autre symbole ou affiche-les tous."
                secondary={{ label: 'Tous les symboles', onClick: () => setSymbolFilter('') }} compact/>
        ) : paging.visible.map((t, i) => <MobileTradeRow key={t.id || i} trade={t} first={i === 0} openTrade={openTrade} openJournal={openJournal}/>)}
      </div>
      <TradesMoreButton more={paging.more} onMore={paging.showMore} style={{ marginTop: 14 }}/>
    </div>
  );
}

function DailyMobile({ openJournal, openImport, state }) {
  const [tab, setTab] = React.useState('all');
  const { tradesLoading, today } = state || { tradesLoading: false, today: todayISO() };

  // Même liste que le Journal quotidien ordinateur (useDailyDays, pages-2.jsx) :
  // jours tradés, jours avec un journal ou des captures, aujourd'hui en tête,
  // par pages de 30 jours.
  const { visible, more, showMore, empty } = useDailyDays(state, tab);

  const tradesError = state && state.tradesError;
  const addTrade = () => openImport && openImport(null, 'manual');
  const noTrades = empty;
  // Aucune carte à l'écran : compte sans rien, ou filtre « Avec trades » sans
  // jour tradé. Même règle que l'ordinateur (Daily, pages-2.jsx) : tant que les
  // trades se chargent, ou si leur lecture a échoué, on ne peut pas dire
  // « aucun jour avec trades ».
  const nothingShown = noTrades || visible.length === 0;
  // Premier chargement : message d'attente ; un rafraîchissement garde la liste.
  const firstLoad = !!tradesLoading && nothingShown;
  // Échec sans données : ce n'est pas un compte vide (charte §7), le bandeau propose Réessayer.
  const unavailable = !firstLoad && !!tradesError && nothingShown;
  const emptyState = !firstLoad && !tradesError && noTrades;

  return (
    <div style={{ padding: '14px var(--m-gutter, 16px) 24px' }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, gap: 10, flexWrap: 'wrap' }}>
        <h1 style={{ fontSize: 24, fontWeight: 700, letterSpacing: '-.025em', margin: 0 }}>{appNavText('daily', 'title')}</h1>
        {/* Action d'en-tête et filtres seulement quand il y a des jours à
            filtrer : sinon l'état vide porte seul l'action principale (charte §7). */}
        {!noTrades && <button onClick={addTrade} className="btn btn-blue tap" style={{ fontSize: 12, color: '#fff' }}>{Ico.plus} Ajouter un trade</button>}
      </div>

      <TradesLoadBanner state={state}/>

      {/* Filtres Tous / Avec trades — parité avec le FilterBar desktop. */}
      {!noTrades && <div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
        {[{ id: 'all', label: 'Tous' }, { id: 'verified', label: 'Avec trades' }].map(f => (
          <button key={f.id} onClick={() => setTab(f.id)} className="tap" style={{
            padding: '6px 13px', borderRadius: 999, fontSize: 12, fontWeight: 600, cursor: 'pointer',
            border: '1px solid ' + (tab === f.id ? 'var(--blue)' : 'var(--line)'),
            background: tab === f.id ? 'var(--blue-soft)' : 'var(--bg-card)',
            color: tab === f.id ? 'var(--blue-600)' : 'var(--fg-2)',
            fontFamily: 'inherit',
          }}>{f.label}</button>
        ))}
      </div>}

      {firstLoad ? (
        <div role="status" aria-busy="true" style={{ fontSize: 12, color: 'var(--fg-3)', padding: '12px 0', textAlign: 'center' }}>Chargement…</div>
      ) : unavailable ? (
        <div className="card">
          <EmptyState 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.'} compact/>
        </div>
      ) : emptyState ? (
        <div className="card">
          <EmptyTrades openImport={openImport} compact/>
        </div>
      ) : visible.length === 0 ? (
        // Filtre « Avec trades » sans aucun jour tradé (le compte n'a que des
        // journaux) : même état vide que l'ordinateur (DailyFilterEmpty,
        // pages-2.jsx). L'action principale « Ajouter un trade » est déjà dans
        // l'en-tête de l'écran : une seule action principale (charte §7).
        <div className="card">
          <DailyFilterEmpty onAll={() => setTab('all')}/>
        </div>
      ) : <>{visible.map(d => {
        const highlight = d.day === today;
        const hasTrades = d.t > 0;
        const journalBits = dailyJournalBits(d.journal);
        const openDay = () => openJournal && openJournal(d.day);
        return (
          // Toute la carte ouvre le journal au toucher (confort), mais elle n'est
          // pas déclarée comme bouton : elle contient déjà de vrais boutons
          // (en-tête, « Ajouter un trade »), et un contrôle imbriqué dans un
          // role="button" n'est pas annoncé par les lecteurs d'écran.
          <div key={d.day} onClick={openDay} className="card tap" style={{
            padding: '15px 17px', marginBottom: 10, cursor: 'pointer',
            border: highlight ? '1.5px solid var(--blue)' : '1px solid var(--line)',
            boxShadow: highlight ? '0 0 0 3px rgba(59,130,246,0.08)' : 'none',
          }}>
            {/* En-tête : le vrai bouton d'ouverture (clavier, lecteurs d'écran),
                équivalent du bouton « Journal » de l'ordinateur. Son libellé
                reprend le P&L du jour, que l'aria-label masquerait sinon. */}
            <button type="button"
              aria-label={'Ouvrir le journal du ' + fmtDayLong(d.day) + (hasTrades ? ', P&L ' + fmtMoney(d.pnl, true) : '')}
              onClick={e => { e.stopPropagation(); openDay(); }}
              style={{
                display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8,
                width: '100%', margin: '0 0 8px', padding: 0,
                background: 'transparent', border: 'none', borderRadius: 6,
                color: 'var(--fg)', font: 'inherit', textAlign: 'left', cursor: 'pointer',
              }}>
              {/* Trop étroit (320 px, montant long) : la pastille passe sous la
                  date ; le montant garde sa largeur et son alignement à droite. */}
              <span style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', columnGap: 8, rowGap: 4, minWidth: 0, flex: '1 1 auto' }}>
                <span style={{ fontSize: 14, fontWeight: 600, whiteSpace: 'nowrap' }}>{fmtLongDate(d.day)}</span>
                {highlight && <span className="pill pill-blue" style={{ padding: '2px 8px', fontSize: 10 }}>Aujourd’hui</span>}
              </span>
              <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
                {hasTrades && (
                  <span className={'num ' + (d.pnl > 0 ? 'pos' : d.pnl < 0 ? 'neg' : '')} style={{ fontSize: 17, fontWeight: 600, whiteSpace: 'nowrap' }}>
                    {fmtMoney(d.pnl, true)}
                  </span>
                )}
                <span aria-hidden="true" style={{ display: 'inline-flex', color: 'var(--fg-3)', alignSelf: 'center' }}>{Ico.chev}</span>
              </span>
            </button>
            {hasTrades ? (
              <div style={{ display: 'flex', alignItems: 'center', columnGap: 6, rowGap: 2, fontSize: 11.5, color: 'var(--fg-3)', flexWrap: 'wrap' }}>
                <span style={{ whiteSpace: 'nowrap' }}>{appCount(d.t, 'trade')}</span>
                <span aria-hidden="true">·</span>
                <span style={{ whiteSpace: 'nowrap' }}>
                  <b style={{ color: d.w > 0 ? 'var(--green-text)' : 'var(--fg-3)', fontWeight: 600 }}>{d.w} G</b>
                  {' · '}
                  <b style={{ color: d.l > 0 ? 'var(--red-text)' : 'var(--fg-3)', fontWeight: 600 }}>{d.l} P</b>
                  {d.t - d.w - d.l > 0 ? ` · ${d.t - d.w - d.l} nul${d.t - d.w - d.l > 1 ? 's' : ''}` : ''}
                </span>
                <span aria-hidden="true">·</span>
                <span style={{ whiteSpace: 'nowrap' }}>Taux de réussite {fmtPct(d.wr, 1)}</span>
                <span aria-hidden="true">·</span>
                <span style={{ whiteSpace: 'nowrap' }}>Profit factor {fmtPF(d.pf, d.t)}</span>
              </div>
            ) : (
              // Même contenu que la carte ordinateur (pages-2.jsx) : pas de montant à zéro.
              <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 10 }}>
                <span style={{ fontSize: 12.5, color: 'var(--fg-2)' }}>{EMPTY_DAY_TITLE}</span>
                {openImport && d.day <= todayISO() && (
                  <button type="button" onClick={e => { e.stopPropagation(); openImport(d.day, 'manual'); }} className="btn tap" style={{ fontSize: 12, color: 'var(--fg)' }}>{Ico.plus} Ajouter un trade</button>
                )}
              </div>
            )}
            {journalBits.length > 0 && (
              <div style={{ marginTop: 8, fontSize: 11.5, color: 'var(--fg-3)', display: '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>
        );
      })}
      <DailyMoreButton more={more} onMore={showMore} style={{ marginTop: 4 }}/></>}
    </div>
  );
}

// ─── Réglages : éléments partagés (profil, confirmations, données) ─────
// Fuseaux proposés dans Réglages (tous nommés dans TZ_LABELS, format-prefs.jsx).
// Les libellés « Paris (UTC+2) » sont calculés à chaque ouverture : le décalage
// change avec l'heure d'été. Un fuseau de profil absent de la liste y est ajouté.
const TZ_OPTIONS = [
  'Europe/Paris', 'Europe/Brussels', 'Europe/Luxembourg', 'Europe/Monaco', 'Europe/Zurich',
  'Europe/London', 'Europe/Dublin', 'Europe/Lisbon', 'Europe/Berlin', 'Europe/Madrid',
  'Europe/Rome', 'Europe/Amsterdam', 'Europe/Athens',
  'Africa/Casablanca', 'Africa/Algiers', 'Africa/Tunis', 'Africa/Dakar', 'Africa/Abidjan',
  'America/New_York', 'America/Toronto', 'America/Chicago', 'America/Los_Angeles',
  'America/Guadeloupe', 'America/Martinique', 'America/Cayenne',
  'Indian/Reunion', 'Indian/Mayotte', 'Indian/Mauritius', 'Asia/Dubai',
  'Asia/Singapore', 'Asia/Hong_Kong', 'Asia/Tokyo', 'Australia/Sydney',
  'Pacific/Noumea', 'Pacific/Tahiti', 'UTC',
];
// Devises proposées. Le symbole vient d'Intl (currencySymbol), jamais d'un littéral.
const CURRENCY_OPTIONS = [
  { v: 'EUR', name: 'Euro' },
  { v: 'USD', name: 'Dollar américain' },
  { v: 'GBP', name: 'Livre sterling' },
  { v: 'CHF', name: 'Franc suisse' },
  { v: 'JPY', name: 'Yen' },
];
// Fond plein des boutons de suppression : rouge fixe dans les deux thèmes, pour
// que le texte blanc garde un contraste ≥ 4,5:1 (var(--red) s'éclaircit en sombre).
const APP_DANGER_BG = '#dc2626';

// Décalage d'un fuseau en minutes à l'instant ms (sert uniquement au tri).
function appTzOffsetMin(tz, ms) {
  const o = {};
  new Intl.DateTimeFormat('en-US', {
    timeZone: tz, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
  }).formatToParts(new Date(ms)).forEach(p => { o[p.type] = p.value; });
  const asUTC = Date.UTC(+o.year, +o.month - 1, +o.day, (+o.hour) % 24, +o.minute);
  return Math.round((asUTC - Math.floor(ms / 60000) * 60000) / 60000);
}

// Options du sélecteur de fuseau, triées par décalage puis par nom. Le fuseau
// courant est toujours présent, pour que le sélecteur n'affiche jamais un autre fuseau.
function appTzChoices(current) {
  const list = TZ_OPTIONS.slice();
  if (current && !list.includes(current)) list.push(current);
  const now = Date.now();
  const out = [];
  for (const v of list) {
    let off;
    try { off = appTzOffsetMin(v, now); }
    catch (e) {
      // Fuseau inconnu du navigateur : ignoré, sauf celui du profil (nommé tel quel).
      if (v === current) out.push({ v, l: tzLabel(v), off: 0 });
      continue;
    }
    out.push({ v, l: tzLabelWithOffset(v), off });
  }
  return out.sort((a, b) => a.off - b.off || a.l.localeCompare(b.l, 'fr'));
}

// Options du sélecteur de devise ; une devise de profil hors liste y est ajoutée
// avec son nom français (Intl.DisplayNames) et son symbole.
function appCurrencyChoices(current) {
  const out = CURRENCY_OPTIONS.map(o => ({ v: o.v, l: o.name + ' (' + currencySymbol(o.v) + ')' }));
  const code = String(current || '').trim().toUpperCase();
  if (code && !out.some(o => o.v === code)) {
    let name = code;
    try {
      const dn = new Intl.DisplayNames(['fr-FR'], { type: 'currency' }).of(code);
      if (dn && dn !== code) name = dn.charAt(0).toUpperCase() + dn.slice(1);
    } catch (e) { /* Intl.DisplayNames indisponible : le code suffit */ }
    const sym = currencySymbol(code);
    out.push({ v: code, l: sym && sym !== code ? name + ' (' + sym + ')' : name + ' (' + code + ')' });
  }
  return out;
}

// Fuseau de l'appareil (navigateur), '' s'il est illisible.
function appDeviceTz() {
  try { return Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (e) { return ''; }
}

// « Il est 12:52 à New York. » — aperçu du fuseau choisi, rafraîchi toutes les 15 s.
function TzPreview({ tz }) {
  const [, setTick] = React.useState(0);
  React.useEffect(() => {
    const id = setInterval(() => setTick(x => x + 1), 15000);
    return () => clearInterval(id);
  }, []);
  const label = tzLabel(tz);
  // « à Paris », « au Caire », « à la Réunion » (tzAtLabel, format-prefs.jsx).
  return <span>Il est {tzClock(tz)} {label === 'UTC' ? '(heure UTC)' : tzAtLabel(tz)}.</span>;
}

// Editable profile form. Pre-fills from state.profile, saves via state.saveProfile.
// Vrai <form> : Entrée enregistre ; garde anti double envoi par ref.
function ProfileForm({ state }) {
  const p = state?.profile || null;
  const uid = React.useId();
  const [first, setFirst]   = React.useState('');
  const [last, setLast]     = React.useState('');
  const [disp, setDisp]     = React.useState('');
  // Fuseau effectivement appliqué (state.timezone : celui du profil, sinon
  // celui de l'appareil pour un compte sans profil), jamais Paris d'office.
  const appliedTz = (state && state.timezone) || 'Europe/Paris';
  const [tz, setTz]         = React.useState(appliedTz);
  const [cur, setCur]       = React.useState('USD');
  const [saving, setSaving] = React.useState(false);
  const [saved, setSaved]   = React.useState(false);
  const [err, setErr]       = React.useState('');
  const savingRef = React.useRef(false);
  // Écran encore monté ? Un enregistrement lent peut se terminer après le
  // départ de Réglages : son échec part alors vers l'avis commun.
  const mountedRef = React.useRef(true);
  React.useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []);

  // Remplit les champs depuis une ligne de profil. Mêmes valeurs par défaut que
  // state.jsx (devise et fuseau effectivement appliqués).
  const hydrate = (row) => {
    setFirst(row?.first_name || '');
    setLast(row?.last_name || '');
    setDisp(row?.display_name || '');
    setTz(row?.timezone || appliedTz);
    setCur(row?.currency || 'USD');
  };
  // (Ré)hydrate quand les VALEURS enregistrées changent (premier chargement,
  // enregistrement, modification depuis un autre appareil) — et non à chaque
  // nouvel objet profil : state.jsx relit le profil au retour sur l'onglet et
  // republie alors un objet neuf aux valeurs identiques, qui effaçait une
  // saisie pas encore enregistrée.
  React.useEffect(() => {
    hydrate(p);
  }, [p?.first_name, p?.last_name, p?.display_name, p?.timezone, p?.currency, appliedTz]);

  // Efface la confirmation « Enregistré » après 2 s.
  React.useEffect(() => {
    if (!saved) return;
    const id = setTimeout(() => setSaved(false), 2000);
    return () => clearTimeout(id);
  }, [saved]);

  const tzChoices = React.useMemo(() => appTzChoices(tz), [tz]);
  const curChoices = React.useMemo(() => appCurrencyChoices(cur), [cur]);
  const deviceTz = appDeviceTz();
  const showDeviceTz = !!deviceTz && deviceTz !== tz;

  const onSave = async (e) => {
    if (e && e.preventDefault) e.preventDefault();
    if (savingRef.current) return;
    savingRef.current = true;
    setSaving(true); setErr(''); setSaved(false);
    const patch = {
      first_name: first.trim() || null,
      last_name: last.trim() || null,
      display_name: disp.trim() || null,
      timezone: tz,
      currency: cur,
    };
    let r = null;
    try { r = await state.saveProfile(patch); }
    catch (ex) { r = { error: ex }; }
    savingRef.current = false;
    // Réglages quitté pendant l'envoi : plus rien à mettre à jour ici ; un
    // échec est annoncé sur l'écran où se trouve maintenant l'utilisateur.
    if (!mountedRef.current) {
      if (r && r.error) {
        const text = 'Ton profil n’a pas été enregistré. ' + sbErrorMessage(r.error);
        if (state && typeof state.notifyWriteError === 'function') state.notifyWriteError(text);
        else console.warn('[Tempo] ' + text);
      }
      return;
    }
    setSaving(false);
    if (r && r.error) {
      console.warn('[Tempo] enregistrement du profil :', r.error);
      setErr(sbErrorMessage(r.error));
      return;
    }
    // Les champs reprennent ce qui est réellement enregistré (espaces retirés),
    // même si aucune valeur du profil n'a changé.
    if (r && r.data) hydrate(r.data);
    setSaved(true);
  };

  // Même gabarit de libellé que le formulaire de mot de passe (pages-2.jsx).
  const labelStyle = { fontSize: 12, fontWeight: 500, color: 'var(--fg-2)', display: 'block', marginBottom: 6 };
  const helpStyle = { fontSize: 11.5, color: 'var(--fg-3)', marginTop: 6, lineHeight: 1.5 };
  return (
    <form onSubmit={onSave} noValidate>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 12, marginBottom: 12 }}>
        <div>
          <label htmlFor={uid + '-first'} style={labelStyle}>Prénom</label>
          <input id={uid + '-first'} className="input" value={first} onChange={e => setFirst(e.target.value)} placeholder="Alex" autoComplete="given-name"/>
        </div>
        <div>
          <label htmlFor={uid + '-last'} style={labelStyle}>Nom</label>
          <input id={uid + '-last'} className="input" value={last} onChange={e => setLast(e.target.value)} placeholder="Martin" autoComplete="family-name"/>
        </div>
      </div>
      <div style={{ marginBottom: 12 }}>
        <label htmlFor={uid + '-disp'} style={labelStyle}>Pseudo affiché</label>
        <input id={uid + '-disp'} className="input" value={disp} onChange={e => setDisp(e.target.value)} placeholder="Utilisé pour te saluer sur l’accueil" autoComplete="nickname"/>
      </div>
      <div style={{ marginBottom: 12 }}>
        <label htmlFor={uid + '-tz'} style={labelStyle}>Fuseau horaire</label>
        <select id={uid + '-tz'} className="input" value={tz} onChange={e => setTz(e.target.value)}>
          {tzChoices.map(o => <option key={o.v} value={o.v}>{o.l}</option>)}
        </select>
        <div style={helpStyle}>
          Sert à dater tes trades, ta journée, ton agenda et le calendrier économique. <TzPreview tz={tz}/>
          {showDeviceTz && (
            <React.Fragment>
              {' '}
              <button type="button" onClick={() => setTz(deviceTz)} className="tap" style={{ background: 'transparent', border: 'none', padding: 0, color: 'var(--blue-600)', fontSize: 11.5, fontFamily: 'inherit', cursor: 'pointer', textDecoration: 'underline' }}>
                Utiliser le fuseau de cet appareil ({tzLabel(deviceTz)})
              </button>
            </React.Fragment>
          )}
        </div>
      </div>
      <div style={{ marginBottom: 16 }}>
        <label htmlFor={uid + '-cur'} style={labelStyle}>Devise</label>
        <select id={uid + '-cur'} className="input" value={cur} onChange={e => setCur(e.target.value)}>
          {curChoices.map(o => <option key={o.v} value={o.v}>{o.l}</option>)}
        </select>
        <div style={helpStyle}>{'Devise d’affichage de tes montants. Aucune conversion\u00A0: tes P&L restent tels que saisis ou importés.'}</div>
      </div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
        <button type="submit" disabled={saving} className="btn btn-blue tap" style={{ color: '#fff' }}>
          {saving ? 'Enregistrement…' : 'Enregistrer'}
        </button>
        {saved && (
          <span role="status" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 12.5, color: 'var(--green-text)', fontWeight: 600 }}>
            {React.cloneElement(Ico.check, { width: 14, height: 14 })} Enregistré
          </span>
        )}
        {err && <span role="alert" style={{ fontSize: 12.5, color: 'var(--red)' }}>{err}</span>}
      </div>
    </form>
  );
}

// Confirmation en ligne d'une action destructive, sur le modèle du backtest :
// une question qui nomme l'objet et la conséquence, le mot à taper, puis
// [action] en rouge et [Annuler]. onConfirm() (async) renvoie { error } ou
// { ok } ; en cas de succès, il recharge la page (le bouton reste occupé).
function TypeToConfirm({ word, openLabel, confirmLabel, busyLabel, question, color, onConfirm }) {
  const uid = React.useId();
  const [open, setOpen] = React.useState(false);
  const [val, setVal]   = React.useState('');
  const [busy, setBusy] = React.useState(false);
  const [err, setErr]   = React.useState('');
  const busyRef = React.useRef(false);
  const c = color || 'var(--red)';
  const armed = val.trim().toUpperCase() === word;

  const reset = () => { setOpen(false); setVal(''); setErr(''); };

  const run = async (e) => {
    if (e && e.preventDefault) e.preventDefault();
    if (!armed || busyRef.current) return;
    busyRef.current = true;
    setBusy(true); setErr('');
    let r = null;
    try { r = await onConfirm(); }
    catch (ex) { r = { error: ex }; }
    if (r && r.error) {
      console.warn('[Tempo] action sur les données :', r.error);
      busyRef.current = false;
      setBusy(false);
      setErr(sbErrorMessage(r.error));
    }
    // Succès : onConfirm recharge la page ; le bouton reste occupé jusque-là.
  };

  if (!open) {
    return (
      <button type="button" onClick={() => setOpen(true)} className="btn tap" style={{ color: c, borderColor: c }}>{openLabel || confirmLabel}</button>
    );
  }
  return (
    <form onSubmit={run} noValidate>
      {question && <div style={{ fontSize: 13, fontWeight: 600, color: 'var(--fg)', marginBottom: 6, lineHeight: 1.45 }}>{question}</div>}
      <label htmlFor={uid + '-word'} style={{ display: 'block', fontSize: 12, color: 'var(--fg-2)', marginBottom: 8 }}>
        Tape <b style={{ color: c }}>{word}</b> pour confirmer.
      </label>
      <input
        id={uid + '-word'}
        className="input" value={val} onChange={e => setVal(e.target.value)}
        placeholder={word} autoFocus disabled={busy}
        autoComplete="off" autoCapitalize="characters" spellCheck={false}
        style={{ marginBottom: 10 }}
      />
      <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
        <button type="submit" disabled={!armed || busy} className="btn tap"
          style={{ background: armed ? APP_DANGER_BG : 'var(--bg-elev)', color: armed ? '#fff' : 'var(--fg-3)', borderColor: armed ? APP_DANGER_BG : 'var(--line)', cursor: armed && !busy ? 'pointer' : 'not-allowed' }}>
          {busy ? (busyLabel || 'Suppression…') : confirmLabel}
        </button>
        <button type="button" onClick={reset} disabled={busy} className="btn tap" style={{ color: 'var(--fg)' }}>Annuler</button>
      </div>
      {err && <div role="alert" style={{ fontSize: 12, color: 'var(--red)', marginTop: 8 }}>{err}</div>}
    </form>
  );
}

// Action indisponible (script non chargé) : message français, sans jargon.
const APP_ACTION_UNAVAILABLE = { error: { message: 'Cette action est momentanément indisponible. Recharge la page.' } };

// Périmètre de la réinitialisation : UNE seule phrase, reprise par la description
// et par la question de confirmation (charte §8 : rien de caché). Doit rester
// alignée sur resetMyData (account-api.jsx : tables effacées + notes « Mon plan »
// du Calendrier éco., stockées sur l'appareil).
// Ce que la réinitialisation garde : le compte et les réglages du profil, dont
// l'objectif de régularité des habitudes (profiles.habit_goal), comme la devise.
const APP_RESET_KEPT = 'Ton compte et tes réglages (devise, fuseau, objectif de régularité) sont conservés';
const APP_RESET_SCOPE = 'tes trades, habitudes, agenda, notes et captures, stratégies, sauvegardes de backtest, historique d’analyses fondamentales et notes «\u00A0Mon plan\u00A0» du Calendrier économique';

// Effacement arrêté en route (resetMyData renvoie alors partial) : une partie
// des données n'existe plus en base. L'écran relit le compte sans recharger la
// page, pour ne plus rien afficher d'effacé ; le message reste dans le formulaire.
function appResyncAfterPartialErase(r, state) {
  if (r && r.partial && state && typeof state.refresh === 'function') state.refresh();
}

// Réinitialisation du journal — utilisée dans Réglages et dans « Gestion des données ».
function ResetJournalCard({ state }) {
  const resetJournal = async () => {
    if (typeof window.resetMyData !== 'function') return APP_ACTION_UNAVAILABLE;
    const r = await window.resetMyData();
    if (r && r.error) { appResyncAfterPartialErase(r, state); return r; }
    window.location.reload(); // re-fetch empty state
    return { ok: true };
  };
  return (
    <div className="card" style={{ padding: '20px 22px', marginBottom: 14, borderColor: 'color-mix(in srgb, var(--orange) 40%, var(--line))' }}>
      <CardTitle style={{ fontSize: 14, letterSpacing: 'normal', marginBottom: 4, color: 'var(--amber-text)' }}>Réinitialiser mon journal</CardTitle>
      <div style={{ fontSize: 12, color: 'var(--fg-3)', marginBottom: 14, lineHeight: 1.5 }}>
        {'Efface ' + APP_RESET_SCOPE + '. ' + APP_RESET_KEPT + '.'}
      </div>
      <TypeToConfirm
        word="EFFACER"
        openLabel="Réinitialiser mon journal"
        confirmLabel="Réinitialiser"
        busyLabel="Réinitialisation…"
        question={'Tout effacer\u202F? ' + APP_RESET_KEPT + ', le reste ne pourra pas être récupéré.'}
        color="var(--amber-text)"
        onConfirm={resetJournal}/>
    </div>
  );
}

function DeleteAccountCard({ state }) {
  const deleteAccount = async () => {
    if (typeof window.deleteMyAccount !== 'function') return APP_ACTION_UNAVAILABLE;
    const r = await window.deleteMyAccount();
    if (r && r.error) { appResyncAfterPartialErase(r, state); return r; }
    // Identifiant non supprimé mais session bien terminée : l'écran de connexion
    // est déjà affiché, avec l'avertissement (App) — un rechargement le ferait
    // seulement clignoter. Dans tous les autres cas on recharge : compte
    // supprimé, ou déconnexion impossible hors ligne (la clé de session a été
    // retirée, le rechargement mène à l'écran de connexion et à l'avertissement).
    if (!(r && r.authLeft && r.signedOut)) window.location.reload();
    return { ok: true };
  };
  return (
    <div className="card" style={{ padding: '20px 22px', borderColor: 'color-mix(in srgb, var(--red) 70%, var(--line))' }}>
      <CardTitle style={{ fontSize: 14, letterSpacing: 'normal', marginBottom: 4, color: 'var(--red)' }}>Supprimer le compte</CardTitle>
      <div style={{ fontSize: 12, color: 'var(--fg-3)', marginBottom: 14, lineHeight: 1.5 }}>
        Supprime définitivement toutes tes données personnelles et te déconnecte. Cette action est irréversible.
      </div>
      <TypeToConfirm
        word="SUPPRIMER"
        openLabel="Supprimer mon compte"
        confirmLabel="Supprimer définitivement"
        busyLabel="Suppression…"
        question={'Supprimer définitivement ton compte et toutes tes données\u202F? Tu seras déconnecté.'}
        color="var(--red)"
        onConfirm={deleteAccount}/>
      <div style={{ fontSize: 11, color: 'var(--fg-3)', marginTop: 12, lineHeight: 1.5 }}>
        Ton identifiant de connexion (email) est supprimé avec le compte. Si cette dernière étape échoue, tu en es averti et il peut être purgé sur demande à {window.LEGAL_CONTACT || 'contact@tempo-trading.com'}.
      </div>
    </div>
  );
}

// The two GDPR data actions (reset journal + delete account), shared desktop/mobile.
function DataActions({ state }) {
  return (
    <React.Fragment>
      <ResetJournalCard state={state}/>
      <DeleteAccountCard state={state}/>
    </React.Fragment>
  );
}

// Déconnexion avec confirmation en ligne (charte §8).
function LogoutControl({ onLogout }) {
  const [confirm, setConfirm] = React.useState(false);
  const [busy, setBusy] = React.useState(false);
  const busyRef = React.useRef(false);
  // Le focus suit la confirmation (charte §8) : à l'ouverture il va sur
  // « Annuler » (choix sans conséquence), à la fermeture il revient au bouton
  // d'ouverture. backRef évite de prendre le focus au premier affichage de Réglages.
  const openRef = React.useRef(null);
  const cancelRef = React.useRef(null);
  const backRef = React.useRef(false);
  const close = () => { backRef.current = true; setConfirm(false); };
  React.useEffect(() => {
    if (confirm) { if (cancelRef.current) cancelRef.current.focus(); return; }
    if (!backRef.current) return;
    backRef.current = false;
    if (openRef.current) openRef.current.focus();
  }, [confirm]);
  const run = async () => {
    if (busyRef.current) return;
    busyRef.current = true;
    setBusy(true);
    try { await onLogout(); }
    catch (e) { console.warn('[Tempo] déconnexion :', e); }
    // Succès : l'écran de connexion remplace l'application. Sinon on rend la main.
    busyRef.current = false;
    setBusy(false);
    close();
  };
  if (!confirm) {
    return <button ref={openRef} type="button" onClick={() => setConfirm(true)} className="btn tap" style={{ color: 'var(--fg)' }}>Se déconnecter</button>;
  }
  const onKeyDown = (e) => {
    if (e.key !== 'Escape' || busyRef.current) return;
    e.preventDefault();
    e.stopPropagation();
    close();
  };
  return (
    <div role="alertdialog" aria-label="Confirmer la déconnexion" onKeyDown={onKeyDown} style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
      <span style={{ fontSize: 12.5, color: 'var(--fg-2)' }}>{'Te déconnecter de Tempo sur cet appareil\u202F?'}</span>
      <button type="button" onClick={run} disabled={busy} className="btn btn-blue tap" style={{ color: '#fff' }}>{busy ? 'Déconnexion…' : 'Se déconnecter'}</button>
      <button ref={cancelRef} type="button" onClick={close} disabled={busy} className="btn tap" style={{ color: 'var(--fg)' }}>Annuler</button>
    </div>
  );
}

// Arrivée sur Réglages depuis « Connecter un courtier » : on fait défiler
// jusqu'à la section demandée, après la remise en haut du conteneur de
// défilement (effet du parent, exécuté après celui-ci).
function useSettingsSectionScroll(section, onSectionShown) {
  const ref = React.useRef(null);
  React.useEffect(() => {
    if (section !== 'brokers') return;
    const id = setTimeout(() => {
      const el = ref.current;
      if (el && typeof el.scrollIntoView === 'function') el.scrollIntoView({ behavior: 'smooth', block: 'start' });
      if (onSectionShown) onSectionShown();
    }, 80);
    return () => clearTimeout(id);
  }, [section]);
  return ref;
}

// Ligne-lien du menu Plus et du hub « Gestion des données ». `onClick` remplace
// la navigation ; `disabled` + `badge` donnent une ligne inerte (pas un faux bouton).
function PlusNavRow({ nav, to, label, desc, ico, onClick, badge, disabled = false }) {
  const rowStyle = {
    width: '100%', textAlign: 'left',
    padding: '12px 14px', marginBottom: 8,
    display: 'flex', alignItems: 'center', gap: 12,
    fontFamily: 'inherit', color: 'var(--fg)',
  };
  const content = (
    <React.Fragment>
      <span style={{
        width: 34, height: 34, borderRadius: 9, flexShrink: 0,
        background: disabled ? 'var(--bg-elev)' : 'var(--blue-soft)', color: disabled ? 'var(--fg-3)' : 'var(--blue-600)',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
      }}>{React.cloneElement(ico, { width: 16, height: 16 })}</span>
      <span style={{ flex: 1, minWidth: 0 }}>
        <span style={{ display: 'block', fontSize: 14, fontWeight: 600, color: disabled ? 'var(--fg-2)' : 'var(--fg)', letterSpacing: '-.01em' }}>{label}</span>
        {desc && <span style={{ display: 'block', fontSize: 11.5, color: 'var(--fg-3)', marginTop: 1 }}>{desc}</span>}
      </span>
      {badge
        ? <span className="pill pill-blue" style={{ flexShrink: 0, fontSize: 10.5, padding: '2px 8px', whiteSpace: 'nowrap' }}>{badge}</span>
        : <svg className="ico" width="16" height="16" viewBox="0 0 20 20" style={{ color: 'var(--fg-4)', flexShrink: 0 }} aria-hidden="true"><path d="M7.5 5l5 5-5 5"/></svg>}
    </React.Fragment>
  );
  if (disabled) {
    return <div className="card" aria-disabled="true" style={{ ...rowStyle, cursor: 'default' }}>{content}</div>;
  }
  return (
    <button type="button" className="card tap" onClick={onClick || (() => nav && nav(to))} style={{ ...rowStyle, cursor: 'pointer' }}>
      {content}
    </button>
  );
}

// Apparence : bascule clair/sombre (inaccessible ailleurs sur mobile — le
// PageHeader desktop qui la porte n'est jamais rendu ici). L'état des marchés
// est affiché par l'Accueil (sessionStatusInTz), pas ici.
function AppearanceCard() {
  const { theme, toggle } = useTheme();
  const toggleLabel = theme === 'dark' ? 'Passer en clair' : 'Passer en sombre';
  return (
    <div className="card" style={{ padding: '14px 16px', marginBottom: 22, display: 'flex', alignItems: 'center', gap: 12 }}>
      <span style={{ flex: 1, minWidth: 0 }}>
        <span style={{ display: 'block', fontSize: 14, fontWeight: 600 }}>Thème</span>
        <span style={{ display: 'block', fontSize: 11.5, color: 'var(--fg-3)', marginTop: 1 }}>{theme === 'dark' ? 'Sombre' : 'Clair'}</span>
      </span>
      <button type="button" onClick={toggle} title={toggleLabel} aria-label={toggleLabel} className="tap tap-44" style={{ background: 'transparent', border: '1px solid var(--line)', borderRadius: 8, width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: 'var(--fg-2)', flexShrink: 0 }}>
        {theme === 'dark' ? Ico.moon : Ico.sun}
      </button>
    </div>
  );
}

// Libellé de champ en lecture seule (Email), même gabarit que les champs du profil.
const APP_FIELD_LABEL_STYLE = { fontSize: 12, fontWeight: 500, color: 'var(--fg-2)', marginBottom: 6 };
const APP_BROKERS_TITLE = 'Courtiers et synchronisation';
const APP_BROKERS_SUB = 'Connecte ton courtier pour importer tes trades automatiquement, ou importe un fichier.';
const APP_BROKERS_UNAVAILABLE = 'Module courtier indisponible, recharge la page.';

// Réglages › Abonnement : le chemin annoncé par les CGV (souscription, reçus,
// « Résilier mon abonnement »). Tant que le paiement n'est pas ouvert (plans.jsx :
// hasAccess() renvoie toujours true), la section indique le plan actuel et ce qui
// arrivera ici — aucun bouton factice.
const APP_SUBSCRIPTION_SUB = 'Ton plan Tempo et sa facturation.';
function SubscriptionCard({ mobile = false }) {
  const pad = mobile ? '16px 18px' : '20px 22px';
  return (
    <div className="card" style={{ padding: pad }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 6 }}>
        <CardTitle style={{ fontSize: 14, letterSpacing: 'normal' }}>Plan actuel{'\u00A0'}: Starter</CardTitle>
        <span className="pill" style={{ background: 'var(--bg-soft)', color: 'var(--fg-2)', border: '1px solid var(--line)' }}>Gratuit</span>
      </div>
      <div style={{ fontSize: 12.5, color: 'var(--fg-3)', lineHeight: 1.5 }}>
        Pendant le lancement, toutes les fonctionnalités sont accessibles. Le plan Pro (9{'\u00A0'}€ TTC par mois) ouvrira bientôt{'\u00A0'}: tu pourras y souscrire, retrouver tes reçus et le résilier ici, en quelques clics.
      </div>
    </div>
  );
}

function SettingsMobile({ state, onLogout, openImport, nav, section, onSectionShown }) {
  const email = state?.user?.email || '';
  const brokersRef = useSettingsSectionScroll(section, onSectionShown);
  const sectionTitle = { fontSize: 12, fontWeight: 700, color: 'var(--fg-2)', marginBottom: 10 };
  const navRow = (id, ico, desc) => (
    <PlusNavRow nav={nav} to={id} label={appNavText(id, 'menu')} desc={desc != null ? desc : appNavText(id, 'desc')} ico={ico}/>
  );
  return (
    <div style={{ padding: '14px var(--m-gutter, 16px) 28px' }}>
      <h1 style={{ fontSize: 24, fontWeight: 700, letterSpacing: '-.025em', margin: '0 0 16px' }}>{appNavText('settings', 'title')}</h1>

      {/* Apparence — bascule du thème. */}
      <div style={sectionTitle}>Apparence</div>
      <AppearanceCard/>

      {/* Hub de navigation : toutes les routes absentes de la barre du bas. */}
      {typeof nav === 'function' && (
        <React.Fragment>
          <div style={sectionTitle}>Journal</div>
          <div style={{ marginBottom: 14 }}>
            {navRow('dashboard', Ico.dashboard)}
            {navRow('daily', Ico.daily)}
            {navRow('weekly', Ico.weekly)}
            {navRow('strategy', Ico.strategy)}
            {navRow('data', Ico.data)}
          </div>

          <div style={sectionTitle}>Marchés</div>
          <div style={{ marginBottom: 14 }}>
            {navRow('fondamental', Ico.fondamental)}
            {navRow('econ', Ico.econ)}
          </div>

          <div style={sectionTitle}>Organisation</div>
          <div style={{ marginBottom: 22 }}>
            {navRow('routines', Ico.book)}
          </div>
        </React.Fragment>
      )}

      <div style={sectionTitle}>Profil</div>
      <div className="card" style={{ padding: '16px 18px', marginBottom: 22 }}>
        <ProfileForm state={state}/>
      </div>

      <div style={sectionTitle}>Compte</div>
      <div className="card" style={{ padding: '16px 18px', marginBottom: 12 }}>
        <div style={APP_FIELD_LABEL_STYLE}>Email</div>
        <div style={{ fontSize: 14, color: 'var(--fg)', overflowWrap: 'anywhere' }}>{email || '—'}</div>
      </div>
      <div className="card" style={{ padding: '16px 18px', marginBottom: 12 }}>
        <CardTitle style={{ fontSize: 14, letterSpacing: 'normal', marginBottom: 4 }}>Mot de passe</CardTitle>
        <div style={{ fontSize: 12, color: 'var(--fg-3)', marginBottom: 14 }}>Change le mot de passe de connexion de ton compte.</div>
        {typeof window.PasswordForm === 'function'
          ? <window.PasswordForm requireCurrent/>
          : <div style={{ fontSize: 12, color: 'var(--fg-3)' }}>Module indisponible, recharge la page.</div>}
      </div>
      <div className="card" style={{ padding: '16px 18px', marginBottom: 22 }}>
        <CardTitle style={{ fontSize: 14, letterSpacing: 'normal', marginBottom: 4 }}>Déconnexion</CardTitle>
        <div style={{ fontSize: 12, color: 'var(--fg-3)', marginBottom: 14 }}>Tu pourras te reconnecter avec ton email à tout moment.</div>
        <LogoutControl onLogout={onLogout}/>
      </div>

      <div style={sectionTitle}>Abonnement</div>
      <div style={{ marginBottom: 22 }}><SubscriptionCard mobile/></div>

      <div ref={brokersRef} style={{ ...sectionTitle, scrollMarginTop: 12 }}>{APP_BROKERS_TITLE}</div>
      <div style={{ fontSize: 12, color: 'var(--fg-3)', marginBottom: 10, lineHeight: 1.5 }}>{APP_BROKERS_SUB}</div>
      <div style={{ marginBottom: 22 }}>
        {typeof window.BrokerPanelCompact === 'function'
          ? <BrokerPanelCompact onSynced={state.refreshTrades} openImport={openImport}/>
          : <div className="card" style={{ padding: '16px 18px', fontSize: 12, color: 'var(--fg-3)' }}>{APP_BROKERS_UNAVAILABLE}</div>}
      </div>

      <div style={sectionTitle}>Données</div>
      <DataActions state={state}/>

      <div style={{ marginTop: 22, display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'center' }}>
        {typeof window.TradingDisclaimer === 'function' && <window.TradingDisclaimer compact/>}
        {typeof window.LegalLinks === 'function' && <window.LegalLinks compact/>}
      </div>
    </div>
  );
}

function SettingsPage({ state, onLogout, openImport, section, onSectionShown }) {
  const email = state?.user?.email || '';
  const brokersRef = useSettingsSectionScroll(section, onSectionShown);
  const h2 = { fontSize: 19, fontWeight: 700, letterSpacing: '-.02em', margin: 0, marginBottom: 4 };
  const lead = { fontSize: 13, color: 'var(--fg-2)', marginTop: 4, marginBottom: 18 };
  return (
    <div className="scroll" style={{ width: '100%', height: '100%', overflow: 'auto' }}>
      <PageHeader title={appNavText('settings', 'title')}/>
      <div style={{ padding: '24px 24px 40px', maxWidth: 720 }}>
        <h2 style={h2}>Profil</h2>
        <p style={lead}>Tes informations personnelles, ton fuseau horaire et ta devise d’affichage.</p>

        <div className="card" style={{ padding: '20px 22px', marginBottom: 28 }}>
          <ProfileForm state={state}/>
        </div>

        <h2 style={h2}>Compte</h2>
        <p style={lead}>Gère ton compte Tempo.</p>

        <div className="card" style={{ padding: '20px 22px', marginBottom: 14 }}>
          <div style={APP_FIELD_LABEL_STYLE}>Email</div>
          <div style={{ fontSize: 14, color: 'var(--fg)', marginBottom: 4, overflowWrap: 'anywhere' }}>{email || '—'}</div>
          <div style={{ fontSize: 11.5, color: 'var(--fg-3)' }}>Ton identifiant de connexion.</div>
        </div>

        <div className="card" style={{ padding: '20px 22px', marginBottom: 14 }}>
          <CardTitle style={{ fontSize: 14, letterSpacing: 'normal', marginBottom: 4 }}>Mot de passe</CardTitle>
          <div style={{ fontSize: 12, color: 'var(--fg-3)', marginBottom: 14 }}>Change le mot de passe de connexion de ton compte.</div>
          <div style={{ maxWidth: 380 }}>
            {typeof window.PasswordForm === 'function'
              ? <window.PasswordForm requireCurrent/>
              : <div style={{ fontSize: 12, color: 'var(--fg-3)' }}>Module indisponible, recharge la page.</div>}
          </div>
        </div>

        <div className="card" style={{ padding: '20px 22px', marginBottom: 28 }}>
          <CardTitle style={{ fontSize: 14, letterSpacing: 'normal', marginBottom: 4 }}>Déconnexion</CardTitle>
          <div style={{ fontSize: 12, color: 'var(--fg-3)', marginBottom: 14 }}>Tu pourras te reconnecter avec ton email à tout moment.</div>
          <LogoutControl onLogout={onLogout}/>
        </div>

        <h2 style={h2}>Abonnement</h2>
        <p style={lead}>{APP_SUBSCRIPTION_SUB}</p>
        <div style={{ marginBottom: 28 }}><SubscriptionCard/></div>

        <h2 ref={brokersRef} style={{ ...h2, scrollMarginTop: 72 }}>{APP_BROKERS_TITLE}</h2>
        <p style={lead}>{APP_BROKERS_SUB}</p>

        <div style={{ marginBottom: 28 }}>
          {typeof window.BrokerPanel === 'function'
            ? <BrokerPanel onSynced={state.refreshTrades} openImport={openImport}/>
            : <div className="card" style={{ padding: '20px 22px', fontSize: 13, color: 'var(--fg-3)' }}>{APP_BROKERS_UNAVAILABLE}</div>}
        </div>

        <h2 style={h2}>Données</h2>
        <p style={lead}>Réinitialise ton journal ou supprime ton compte.</p>

        <DataActions state={state}/>

        <div style={{ marginTop: 26, display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'center' }}>
          {typeof window.TradingDisclaimer === 'function' && <window.TradingDisclaimer compact/>}
          {typeof window.LegalLinks === 'function' && <window.LegalLinks compact/>}
        </div>
      </div>
    </div>
  );
}

// ─── Number/date formatting helpers ───────────────────────────────
// Point d'injection UNIQUE de la devise : délègue à fmtMoneyCur (format-prefs.jsx,
// toujours chargé avant app.jsx) avec la devise du profil, propagée dans
// window.__appCurrency par state.jsx. Tous les écrans héritent ainsi de la devise
// choisie en Réglages, sans rechargement.
function fmtMoney(v, sign = false) {
  return fmtMoneyCur(v, window.__appCurrency, sign);
}

// Date et heure d'un trade dans le fuseau du profil : « 26 sept. · 14:30 ».
function fmtShortDate(iso) {
  return fmtDateTime(iso);
}

// Jour d'un journal : « Ven. 25 sept. ». Une date civile 'YYYY-MM-DD' n'est
// jamais projetée ; un horodatage est ramené au jour du fuseau du profil.
function fmtLongDate(iso) {
  const wd = fmtWeekdayShort(iso);
  if (wd === '—') return '—';
  return wd.charAt(0).toUpperCase() + wd.slice(1) + ' ' + fmtDayShort(iso);
}

// « 24 G · 22 P · 1 nul » — gagnants, perdants et nuls (P&L = 0). Nombres au
// format français (charte §1 : « 2 664 G · 2 336 P »), comme pgWinLossText.
function appWinLossLabel(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' : ''}` : '');
}

// Profit factor affiché : « — » sans trade ou avec uniquement des trades nuls
// (sans objet), « ∞ » sans perte (Infinity conservé), sinon 2 décimales.
function appPFText(stats) {
  if (!stats || !stats.count) return '—';
  if (!stats.wins && !stats.losses) return '—';
  return fmtPF(stats.profitFactor, stats.count);
}

// Décimales communes aux prix d'entrée et de sortie d'un trade (2 à 5), pour que
// les deux prix d'une même ligne s'alignent (« 1,11480 → 1,11302 »).
// Délègue au formateur commun (format-prefs.jsx).
function appPriceDigits(...vals) {
  return priceDigitsOf(...vals);
}

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

Object.assign(window, {
  fmtMoney, fmtShortDate, fmtLongDate, appSourceLabel, appNavText, AppTzNotice, AppWriteNotices,
  AppVersionNotice, appVersionCheck, appVersionState, APP_VERSION_IMPORT_TEXT,
});

ReactDOM.createRoot(document.getElementById('root')).render(<ErrorBoundary><App/></ErrorBoundary>);
