// Tempo — global app state hook backed by Supabase
// Returns habits (grouped by section), today's completions, today's agenda, day journals,
// trades + stats, and CRUD callbacks for everything.

// « Avant trade » : section des vérifications avant trade (charte §5, ← Pré-trade).
const SECTION_LABELS = { matin: 'Matin', journee: 'Journée', soir: 'Soir', pretrade: 'Avant trade' };
const SECTION_ORDER  = ['matin', 'journee', 'soir', 'pretrade'];

// Map UI group label → DB section code.
function sectionCodeFromLabel(label) {
  const s = String(label || '').toLowerCase();
  if (s.startsWith('matin'))   return 'matin';
  if (s.startsWith('journ'))   return 'journee';
  if (s.startsWith('soir'))    return 'soir';
  if (s.startsWith('avant') || s.startsWith('vérif') || s.startsWith('pré-trade') || s.startsWith('pre-trade') || s.startsWith('pretrade')) return 'pretrade';
  return 'matin';
}

// ════════════════════════════════════════════════════════════════════
// Habitudes — moteur de calcul (docs/spec-habitudes.md §5 et §7.1)
// ════════════════════════════════════════════════════════════════════
// SOURCE UNIQUE des règles : l'Accueil, Routines et Statistiques lisent ces
// fonctions, jamais une copie. Fonctions pures, sans React, exposées sur window.
//
// Conventions
//   - Un jour est un jour civil 'YYYY-MM-DD' (fuseau du profil). Toute
//     l'arithmétique de dates se fait à midi UTC (jamais new Date(y, m, d) ni
//     getDate() : ce serait le fuseau du navigateur).
//   - Jour de semaine : 1 = lundi … 7 = dimanche. La semaine commence le lundi.
//   - Un taux (« rate ») est un POURCENTAGE non arrondi, de 0 à 100, ou null
//     quand rien n'est compté (l'écran affiche alors « — », jamais « 0 % »).
//   - h   = ligne normalisée par habitNormRow :
//           { id, name, section, position, days, archived, archivedDay, startDay, pauses }
//   - ctx = { today, tz, history, tradedDays, tradesByDay }
//           history     : Map(habit_id → Set(jours cochés))
//           tradedDays  : Set(jours avec au moins un trade), ou null si les trades
//                         n'ont pas pu être chargés (aucune vérification comptée)
//           tradesByDay : state.tradesByDay (groupTradesByDay, fuseau du profil)
//
// Constantes : HABIT_ALL_DAYS, HABIT_MAX_ACTIVE (30), HABIT_NAME_MAX (80),
//              HABIT_GOAL_DEFAULT (80)
// Dates      : habitShiftDay(iso, n) → iso · habitWeekdayNum(iso) → 1…7 ·
//              habitWeekStart(iso) → lundi de la semaine
// Jours      : habitNormDays(v) → [1…7] trié (7 jours si absent ou invalide) ·
//              habitDaysLabel(days) → « tous les jours », « lun.–ven. », « lun., mer., ven. »
// Ligne      : habitNormRow(row, tz, history) → h
// Par jour   : habitIsTracked(h, iso, ctx) (suivie, §5.2) · habitIsDue (prévue, §5.3) ·
//              habitIsCounted (comptée, §5.4) · habitIsDone (cochée) → booléens
//              habitDayStatus(h, iso, ctx) → 'future' | 'none' | 'today' | 'today-done' |
//                                            'done' | 'missed' | 'extra' | 'off'
//              habitDayTally(rows, iso, ctx) → { state: 'future' | 'today' | 'none' | 'counted',
//                                               done, planned, level }
// Niveaux    : habitLevel(faites, prevues) → 0…4 | null ·
//              habitLevelStyle(level, theme) → { background, color, border }
// Périodes   : habitRangeStats(rows, fromISO, toISO, ctx) → { planned, done, rate, perfectDays, countedDays }
//              habitRate(h, fromISO, toISO, ctx) → { done, planned, rate }
//              habitWeekdayStats(rows, fromISO, toISO, ctx) → 7 × { done, planned, rate, days }
//              habitFirstDay(rows) → premier jour de suivi | null
//              habitPrevRange(period, fromISO, today) → { fromISO, toISO, label, curFromISO, curToISO } | null
//              habitEvolution(rows, period, fromISO, ctx) → { delta, cur, prev, range } | null
//              (fromISO null = depuis le premier jour de suivi ; toISO au-delà
//               d'hier est ramené à hier : la journée en cours n'entre dans aucun taux)
// Séries     : habitStreaks(h, ctx) → { current, best } (current = null si archivée)
// Discipline : habitDiscipline(rows, fromISO, toISO, mode, ctx, goal) → { a, b, skipped }
//              mode 'pretrade' | 'habits' ; a et b =
//              { days, trades, pnl, pnlPerDay, winRate, avgR, dayList: [{ day, done, planned, pnl, trades }] }
//
// LIMITES ASSUMÉES (§5.18)
//   - Modifier les jours prévus ou le moment d'une habitude s'applique à TOUT
//     son historique : il n'existe pas d'historique daté des réglages.
//   - Un import tardif de trades peut rendre « comptés » des jours passés pour
//     les vérifications avant trade (elles ne comptent que les jours tradés).
//   - Une habitude archivée sans date d'archivage (ancienne ligne, ou migration
//     supabase-migration-habitudes.sql pas encore exécutée) sort de toutes les
//     statistiques tant qu'elle est archivée.

const HABIT_ALL_DAYS = [1, 2, 3, 4, 5, 6, 7];
const HABIT_MAX_ACTIVE = 30;
const HABIT_NAME_MAX = 80;
const HABIT_GOAL_DEFAULT = 80;
const HABIT_DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
// Délai minimal entre deux rechargements complets du compte déclenchés par le
// retour sur l'onglet (voir onAuthStateChange dans useAppState).
const ACCOUNT_REFRESH_MIN_MS = 5 * 60 * 1000;
// Captures du journal : images uniquement, 10 Mo max — exactement ce que
// l'écran annonce (zone de dépôt et sélecteur de fichiers de l'onglet Captures,
// pages-2.jsx). Vérifié AVANT l'envoi pour un message clair (uploadDayAttachment).
// Le bucket day-attachments (supabase-migration-securite.sql, section 3)
// accepte encore le PDF côté serveur : les PDF déposés par le passé restent
// listés et supprimables, mais on n'en ajoute plus.
const DAY_ATTACHMENT_MAX_BYTES = 10485760; // 10 Mo
const DAY_ATTACHMENT_MIME = ['image/png', 'image/jpeg', 'image/webp', 'image/gif',
  'image/heic', 'image/heif'];

// Jour civil → Date à midi UTC (aucun fuseau ne peut le faire changer de jour).
function habitNoonUTC(iso) {
  const a = String(iso).slice(0, 10).split('-').map(Number);
  return new Date(Date.UTC(a[0], a[1] - 1, a[2], 12));
}
function habitShiftDay(iso, n) {
  const d = habitNoonUTC(iso);
  d.setUTCDate(d.getUTCDate() + n);
  return d.toISOString().slice(0, 10);
}
// 1 = lundi … 7 = dimanche.
function habitWeekdayNum(iso) { return ((habitNoonUTC(iso).getUTCDay() + 6) % 7) + 1; }
// Lundi de la semaine du jour.
function habitWeekStart(iso) { return habitShiftDay(iso, 1 - habitWeekdayNum(iso)); }

// Jours prévus : entiers 1…7, sans doublon, triés. Valeur absente, illisible
// ou vide → les 7 jours (comportement d'avant la colonne `days`).
function habitNormDays(v) {
  if (!Array.isArray(v)) return HABIT_ALL_DAYS.slice();
  const out = [];
  for (const x of v) {
    const n = Number(x);
    if (Number.isInteger(n) && n >= 1 && n <= 7 && !out.includes(n)) out.push(n);
  }
  if (!out.length) return HABIT_ALL_DAYS.slice();
  return out.sort((a, b) => a - b);
}

// Libellé des jours prévus : « tous les jours », « lun.–ven. », « sam.–dim. »,
// « lun., mer., ven. ». Une suite d'au moins 3 jours s'écrit en plage, ainsi
// que deux jours voisins quand ce sont les seuls ; sinon, liste.
function habitDaysLabel(days) {
  const d = habitNormDays(days);
  if (d.length === 7) return 'tous les jours';
  const name = (n) => FR_WEEKDAYS_SHORT[n - 1];
  const runs = [];
  for (const n of d) {
    const last = runs[runs.length - 1];
    if (last && last[1] === n - 1) last[1] = n; else runs.push([n, n]);
  }
  if (runs.length === 1 && runs[0][0] !== runs[0][1]) return name(runs[0][0]) + '–' + name(runs[0][1]);
  const parts = [];
  for (const [a, b] of runs) {
    if (b - a >= 2) parts.push(name(a) + '–' + name(b));
    else for (let n = a; n <= b; n++) parts.push(name(n));
  }
  return parts.join(', ');
}

// Jour civil d'un instant (created_at, archived_at) dans le fuseau donné.
function habitInstantDay(ts, tz) {
  if (ts === null || ts === undefined || ts === '') return null;
  if (typeof ts === 'string' && HABIT_DAY_RE.test(ts)) return ts;
  const d = ts instanceof Date ? ts : new Date(ts);
  if (isNaN(d.getTime())) return null;
  try {
    const day = localDayInTz(d, tz);
    return HABIT_DAY_RE.test(day) ? day : null;
  } catch (e) { return null; }
}

function habitNormPauses(v) {
  if (!Array.isArray(v)) return [];
  const out = [];
  for (const p of v) {
    if (!p || typeof p !== 'object') continue;
    const from = String(p.from || '').slice(0, 10);
    const to = String(p.to || '').slice(0, 10);
    if (HABIT_DAY_RE.test(from) && HABIT_DAY_RE.test(to) && from <= to) out.push({ from, to });
  }
  return out;
}

// Ligne `habits` brute → ligne normalisée. startDay = le plus ancien du jour de
// création (fuseau du profil) et de la première coche (§5.2).
function habitNormRow(row, tz, history) {
  const r = row || {};
  const set = history && typeof history.get === 'function' ? history.get(r.id) : null;
  let first = null;
  if (set) set.forEach((d) => { if (first === null || d < first) first = d; });
  const created = habitInstantDay(r.created_at, tz);
  const startDay = created && first ? (created < first ? created : first) : (created || first || null);
  const archived = r.archived === true;
  const position = Number(r.position);
  return {
    id: r.id,
    name: String(r.name == null ? '' : r.name),
    section: SECTION_ORDER.includes(r.section) ? r.section : 'matin',
    position: Number.isFinite(position) ? position : 0,
    days: habitNormDays(r.days),
    archived,
    archivedDay: archived ? habitInstantDay(r.archived_at, tz) : null,
    startDay,
    pauses: habitNormPauses(r.pauses),
  };
}

// §5.2 — suivie ce jour-là : entre le début et la fin du suivi, hors pause.
// Archivée avec date : suivie jusqu'à la veille de l'archivage. Archivée sans
// date : jamais suivie (exclue de toutes les statistiques).
function habitIsTracked(h, iso, ctx) {
  if (!h || !iso) return false;
  const start = h.startDay || (ctx && ctx.today) || null;
  if (!start || iso < start) return false;
  if (h.archived && (!h.archivedDay || iso >= h.archivedDay)) return false;
  const pauses = h.pauses || [];
  for (let i = 0; i < pauses.length; i++) {
    if (iso >= pauses[i].from && iso <= pauses[i].to) return false;
  }
  return true;
}

function habitIsDone(h, iso, ctx) {
  const set = h && ctx && ctx.history && typeof ctx.history.get === 'function' ? ctx.history.get(h.id) : null;
  return !!(set && set.has(iso));
}

// §5.3 — prévue : suivie et jour de semaine prévu. Les vérifications avant
// trade ignorent `days` : prévues tous les jours suivis.
function habitIsDue(h, iso, ctx) {
  if (!habitIsTracked(h, iso, ctx)) return false;
  if (h.section === 'pretrade') return true;
  return (h.days || HABIT_ALL_DAYS).includes(habitWeekdayNum(iso));
}

// §5.4 — comptée : prévue, journée terminée et, pour une vérification avant
// trade, au moins un trade ce jour-là (rien de compté si les trades manquent).
function habitIsCounted(h, iso, ctx) {
  if (!ctx || !ctx.today || !(iso < ctx.today)) return false;
  if (!habitIsDue(h, iso, ctx)) return false;
  if (h.section === 'pretrade') return !!(ctx.tradedDays && ctx.tradedDays.has(iso));
  return true;
}

// §5.5 — statut d'une case habitude × jour.
function habitDayStatus(h, iso, ctx) {
  const today = ctx && ctx.today;
  if (today && iso > today) return 'future';
  if (!habitIsTracked(h, iso, ctx)) return 'none';
  const done = habitIsDone(h, iso, ctx);
  if (iso === today && habitIsDue(h, iso, ctx)) return done ? 'today-done' : 'today';
  if (habitIsCounted(h, iso, ctx)) return done ? 'done' : 'missed';
  return done ? 'extra' : 'off';
}

// §5.7 — niveau d'un jour, sur le rapport exact (jamais sur le pourcentage arrondi).
function habitLevel(done, planned) {
  const p = Number(planned), d = Number(done);
  if (!(p > 0)) return null;
  if (!(d > 0)) return 0;
  if (d >= p) return 4;
  if (d * 5 >= p * 4) return 3;
  if (d * 2 >= p) return 2;
  return 1;
}

// Fond, encre et bord d'une case de niveau. Tout le texte de la case prend
// cette encre. `theme` : 'dark' | 'light' (useTheme().theme).
function habitLevelStyle(level, theme) {
  const dark = theme === 'dark';
  const mix = (p) => 'color-mix(in srgb, var(--green) ' + p + '%, var(--bg-card))';
  const border = '1px solid var(--line-2)';
  switch (level) {
    case 0: return { background: 'var(--red-soft)', color: 'var(--fg)', border };
    case 1: return { background: mix(20), color: 'var(--fg)', border };
    case 2: return { background: mix(40), color: 'var(--fg)', border };
    case 3: return { background: mix(65), color: dark ? '#0a0f1f' : 'var(--fg)', border };
    case 4: return { background: 'var(--green)', color: dark ? '#0a0f1f' : '#fff', border };
    // Rien de prévu : même bord que les cases comptées (var(--line) seul est
    // presque invisible sur le fond de la carte, surtout en sombre).
    default: return { background: 'var(--bg-card)', color: 'var(--fg-3)', border: '1px solid var(--line-2)' };
  }
}

// §5.6 — bilan d'un jour pour un ensemble d'habitudes.
function habitDayTally(rows, iso, ctx) {
  const list = Array.isArray(rows) ? rows : [];
  const today = ctx && ctx.today;
  if (today && iso > today) return { state: 'future', done: 0, planned: 0, level: null };
  let done = 0, planned = 0;
  if (iso === today) {
    for (const h of list) {
      if (!habitIsDue(h, iso, ctx)) continue;
      planned++;
      if (habitIsDone(h, iso, ctx)) done++;
    }
    return { state: 'today', done, planned, level: null };
  }
  for (const h of list) {
    if (!habitIsCounted(h, iso, ctx)) continue;
    planned++;
    if (habitIsDone(h, iso, ctx)) done++;
  }
  if (!planned) return { state: 'none', done: 0, planned: 0, level: null };
  return { state: 'counted', done, planned, level: habitLevel(done, planned) };
}

// §5.14 — premier jour de suivi de la plus ancienne habitude (archivées datées
// comprises). null sans habitude suivie.
function habitFirstDay(rows) {
  let first = null;
  for (const h of Array.isArray(rows) ? rows : []) {
    if (!h || !h.startDay) continue;
    if (h.archived && !h.archivedDay) continue;
    if (first === null || h.startDay < first) first = h.startDay;
  }
  return first;
}

// Bornes utiles d'une plage : jamais avant le premier jour de suivi, jamais
// après hier. null si la plage ne contient aucun jour terminé.
function habitClampRange(rows, fromISO, toISO, ctx) {
  const today = ctx && ctx.today;
  if (!today) return null;
  const first = habitFirstDay(rows);
  if (!first) return null;
  const yesterday = habitShiftDay(today, -1);
  const from = fromISO && fromISO > first ? fromISO : first;
  const to = toISO && toISO < yesterday ? toISO : yesterday;
  return from <= to ? { from, to } : null;
}

// §5.8 et §5.10 — régularité et journées parfaites d'une période.
function habitRangeStats(rows, fromISO, toISO, ctx) {
  const out = { planned: 0, done: 0, rate: null, perfectDays: 0, countedDays: 0 };
  const list = Array.isArray(rows) ? rows : [];
  const rg = habitClampRange(list, fromISO, toISO, ctx);
  if (!rg) return out;
  for (let d = rg.from; d <= rg.to; d = habitShiftDay(d, 1)) {
    const t = habitDayTally(list, d, ctx);
    if (t.state !== 'counted') continue;
    out.planned += t.planned;
    out.done += t.done;
    out.countedDays++;
    if (t.done === t.planned) out.perfectDays++;
  }
  out.rate = out.planned > 0 ? (out.done / out.planned) * 100 : null;
  return out;
}

// §5.9 — régularité d'une habitude : jours comptés et cochés ÷ jours comptés.
function habitRate(h, fromISO, toISO, ctx) {
  const out = { done: 0, planned: 0, rate: null };
  const rg = h ? habitClampRange([h], fromISO, toISO, ctx) : null;
  if (!rg) return out;
  for (let d = rg.from; d <= rg.to; d = habitShiftDay(d, 1)) {
    if (!habitIsCounted(h, d, ctx)) continue;
    out.planned++;
    if (habitIsDone(h, d, ctx)) out.done++;
  }
  out.rate = out.planned > 0 ? (out.done / out.planned) * 100 : null;
  return out;
}

// §5.12 — série en cours et record, sur tout l'historique, sans plafond.
// Un jour non compté (non prévu, pause, pas de trade) ne casse rien et n'ajoute
// rien. La journée en cours ne casse jamais une série : elle la prolonge
// seulement si l'habitude est prévue et cochée (et, pour une vérification
// avant trade, s'il y a un trade aujourd'hui). Archivée : current = null.
function habitStreaks(h, ctx) {
  const today = ctx && ctx.today;
  if (!h || !today) return { current: h && h.archived ? null : 0, best: 0 };
  let run = 0, best = 0;
  const yesterday = habitShiftDay(today, -1);
  const end = h.archived ? (h.archivedDay ? habitShiftDay(h.archivedDay, -1) : null) : yesterday;
  const last = end && end < yesterday ? end : (end ? yesterday : null);
  if (h.startDay && last) {
    for (let d = h.startDay; d <= last; d = habitShiftDay(d, 1)) {
      if (!habitIsCounted(h, d, ctx)) continue;
      if (habitIsDone(h, d, ctx)) { run++; if (run > best) best = run; }
      else run = 0;
    }
  }
  if (h.archived) return { current: null, best };
  if (habitIsDue(h, today, ctx) && habitIsDone(h, today, ctx)
      && (h.section !== 'pretrade' || !!(ctx.tradedDays && ctx.tradedDays.has(today)))) {
    run++;
    if (run > best) best = run;
  }
  return { current: run, best };
}

// §5.11 — même portion de la période précédente. `period` : 'week' | 'month' |
// 'year' | 'all'. Renvoie la portion courante (curFromISO → curToISO, d'hier
// au plus tard), la portion précédente (fromISO → toISO) et `label`, à écrire
// après « par rapport » : « au début du mois dernier (1–13 sept.) », « à la
// même période l'an dernier (1 janv. – 6 oct. 2025) ». Chaque libellé porte sa
// plage. « Tout » compare les 30 derniers jours terminés aux 30 d'avant : ce
// n'est plus la période affichée, l'écran doit donc écrire les deux taux.
// null si la portion courante est vide (lundi, 1er du mois, 1er janvier).
function habitPrevRange(period, fromISO, today) {
  if (!today) return null;
  const yesterday = habitShiftDay(today, -1);
  if (period === 'all') {
    return {
      curFromISO: habitShiftDay(today, -30), curToISO: yesterday,
      fromISO: habitShiftDay(today, -60), toISO: habitShiftDay(today, -31),
      label: 'aux 30 jours d’avant',
    };
  }
  if (period === 'week') {
    const cur = fromISO || habitWeekStart(today);
    if (cur > yesterday) return null;
    const from = habitShiftDay(cur, -7), to = habitShiftDay(yesterday, -7);
    const a = FR_WEEKDAYS_SHORT[habitWeekdayNum(from) - 1], b = FR_WEEKDAYS_SHORT[habitWeekdayNum(to) - 1];
    return { curFromISO: cur, curToISO: yesterday, fromISO: from, toISO: to,
      label: 'au début de la semaine dernière (' + (from === to ? a : a + '–' + b) + ')' };
  }
  if (period === 'month') {
    const cur = fromISO || (today.slice(0, 8) + '01');
    if (cur > yesterday) return null;
    const prevLast = habitShiftDay(cur.slice(0, 8) + '01', -1);
    const from = prevLast.slice(0, 8) + '01';
    const same = prevLast.slice(0, 8) + yesterday.slice(8, 10);
    const to = same < prevLast ? same : prevLast;
    const plage = typeof fmtDayRange === 'function' ? fmtDayRange(from, to) : '';
    return { curFromISO: cur, curToISO: yesterday, fromISO: from, toISO: to,
      label: 'au début du mois dernier' + (plage ? ' (' + plage + ')' : '') };
  }
  if (period === 'year') {
    const cur = fromISO || (today.slice(0, 4) + '-01-01');
    if (cur > yesterday) return null;
    const py = String(Number(cur.slice(0, 4)) - 1).padStart(4, '0');
    let to = py + yesterday.slice(4);
    if (habitShiftDay(to, 0) !== to) to = py + '-02-28'; // 29 février → 28 février
    const plage = typeof fmtDayRange === 'function' ? fmtDayRange(py + '-01-01', to) : '';
    return { curFromISO: cur, curToISO: yesterday, fromISO: py + '-01-01', toISO: to,
      label: 'à la même période l’an dernier' + (plage ? ' (' + plage + ')' : '') };
  }
  return null;
}

// Évolution en points entiers : régularité de la portion courante − celle de
// la même portion de la période précédente, chacune arrondie à l'entier AVANT
// la soustraction (« 76 % » et « 78 % » affichés donnent toujours « −2 pts »).
// delta = null (« — ») si rien n'était compté d'un côté ou de l'autre. null si
// la portion courante est vide.
function habitEvolution(rows, period, fromISO, ctx) {
  const range = habitPrevRange(period, fromISO, ctx && ctx.today);
  if (!range) return null;
  const cur = habitRangeStats(rows, range.curFromISO, range.curToISO, ctx);
  const prev = habitRangeStats(rows, range.fromISO, range.toISO, ctx);
  const delta = cur.rate === null || prev.rate === null ? null : Math.round(cur.rate) - Math.round(prev.rate);
  return { delta: delta === 0 ? 0 : delta, cur, prev, range };
}

// §5.13 — par jour de la semaine (indice 0 = lundi … 6 = dimanche).
// days = nombre de jours terminés de ce jour de semaine où quelque chose comptait.
function habitWeekdayStats(rows, fromISO, toISO, ctx) {
  const out = [];
  for (let i = 0; i < 7; i++) out.push({ done: 0, planned: 0, rate: null, days: 0 });
  const list = Array.isArray(rows) ? rows : [];
  const rg = habitClampRange(list, fromISO, toISO, ctx);
  if (!rg) return out;
  for (let d = rg.from; d <= rg.to; d = habitShiftDay(d, 1)) {
    const t = habitDayTally(list, d, ctx);
    if (t.state !== 'counted') continue;
    const o = out[habitWeekdayNum(d) - 1];
    o.done += t.done; o.planned += t.planned; o.days++;
  }
  for (const o of out) o.rate = o.planned > 0 ? (o.done / o.planned) * 100 : null;
  return out;
}

// §5.16 — Discipline et résultats. Population : jours terminés et tradés de la
// période. mode 'pretrade' : A = toutes les vérifications comptées sont cochées,
// B = incomplet. mode 'habits' : A = bilan Matin + Journée + Soir ≥ objectif,
// B = sous l'objectif. Un jour tradé sans rien de compté n'est pas comparé
// (skipped), y compris un jour tradé de la période antérieur au début du
// suivi : la plage part de fromISO, pas du premier jour de suivi (sans
// fromISO — période « Tout » — elle part du premier jour de suivi, §5.14).
// Les trades de chaque groupe passent par computeStats. Trades non chargés
// (tradedDays null) : groupes vides.
function habitDiscipline(rows, fromISO, toISO, mode, ctx, goal) {
  const blank = () => ({ days: 0, trades: 0, pnl: 0, pnlPerDay: null, winRate: null, avgR: null, dayList: [] });
  const out = { a: blank(), b: blank(), skipped: 0 };
  const all = Array.isArray(rows) ? rows : [];
  if (!ctx || !ctx.tradedDays || !ctx.today) return out;
  const yesterday = habitShiftDay(ctx.today, -1);
  const from = fromISO || habitFirstDay(all);
  const to = toISO && toISO < yesterday ? toISO : yesterday;
  if (!from || from > to) return out;
  const rg = { from, to };
  const habitsMode = mode === 'habits';
  const list = all.filter((h) => (habitsMode ? h.section !== 'pretrade' : h.section === 'pretrade'));
  const target = Number.isFinite(Number(goal)) && goal !== null && goal !== undefined ? Number(goal) : HABIT_GOAL_DEFAULT;
  const byDay = ctx.tradesByDay || {};
  const pools = { a: [], b: [] };
  for (let d = rg.from; d <= rg.to; d = habitShiftDay(d, 1)) {
    if (!ctx.tradedDays.has(d)) continue;
    const t = habitDayTally(list, d, ctx);
    if (t.state !== 'counted') { out.skipped++; continue; }
    const isA = habitsMode ? (t.done / t.planned) * 100 >= target : t.done === t.planned;
    const key = isA ? 'a' : 'b';
    const dayTrades = (byDay[d] && Array.isArray(byDay[d].trades)) ? byDay[d].trades : [];
    let pnl = 0;
    for (const tr of dayTrades) { pnl += Number(tr.pnl) || 0; pools[key].push(tr); }
    out[key].dayList.push({ day: d, done: t.done, planned: t.planned, pnl, trades: dayTrades.length });
  }
  for (const key of ['a', 'b']) {
    const g = out[key];
    const s = computeStats(pools[key]);
    g.days = g.dayList.length;
    g.trades = s.count;
    g.pnl = s.netPnl;
    g.pnlPerDay = g.days > 0 ? s.netPnl / g.days : null;
    g.winRate = s.count > 0 ? s.winRate : null;
    g.avgR = s.rCount > 0 ? s.avgR : null;
    g.dayList.reverse(); // du plus récent au plus ancien
  }
  return out;
}

// ── Migration supabase-migration-habitudes.sql pas encore exécutée (§6.3) ──
// Colonnes optionnelles : habits.days, habits.archived_at, habits.pauses,
// profiles.habit_goal. Une colonne absente se reconnaît au code 42703
// (Postgres), PGRST204 (PostgREST) ou au message.
const HABIT_OPTIONAL_COLS = ['days', 'archived_at', 'pauses'];
function habitIsMissingColumn(error) {
  if (!error) return false;
  const code = String(error.code || '');
  if (code === '42703' || code === 'PGRST204') return true;
  return /column .* does not exist|Could not find the '.*' column/i.test(String(error.message || ''));
}
function habitMissingColumnName(error) {
  const m = String((error && error.message) || '');
  const a = m.match(/Could not find the '([A-Za-z0-9_]+)' column/i);
  if (a) return a[1];
  const b = m.match(/column "?(?:[A-Za-z0-9_]+"?\."?)?([A-Za-z0-9_]+)"? (?:of relation "?[A-Za-z0-9_]+"? )?does not exist/i);
  return b ? b[1] : null;
}
// Un seul avertissement par session, quel que soit le nombre d'écritures repliées.
let habitMissingWarned = false;
function habitWarnMissing(cols) {
  if (habitMissingWarned) return;
  habitMissingWarned = true;
  console.warn('[Tempo] habitudes : colonnes absentes (' + cols.join(', ') + ') — exécuter supabase-migration-habitudes.sql');
}

// Nom d'habitude nettoyé (espaces réduits), 1 à HABIT_NAME_MAX caractères.
function habitCleanName(name) {
  const clean = String(name == null ? '' : name).replace(/\s+/g, ' ').trim();
  if (!clean) return { error: 'Donne un nom à l’habitude.' };
  if (clean.length > HABIT_NAME_MAX) return { error: 'Le nom ne peut pas dépasser ' + HABIT_NAME_MAX + ' caractères.' };
  return { name: clean };
}

// Clé de comparaison de deux noms d'habitude (§8.1 : pas de doublon dans un
// même moment) : casse, espaces (insécables compris) et apostrophes
// typographiques indifférents. Même règle que les formulaires de Routines.
function habitNameKey(name) {
  return String(name == null ? '' : name)
    .replace(/[\u00A0\u202F]/g, ' ').replace(/[\u2019\u2018]/g, "'")
    .replace(/\s+/g, ' ').trim().toLocaleLowerCase('fr');
}

// Tri local des lignes : moment, position, date de création, identifiant.
function habitSortRows(rows) {
  const sec = (r) => { const i = SECTION_ORDER.indexOf(r.section); return i < 0 ? 0 : i; };
  const pos = (r) => { const n = Number(r.position); return Number.isFinite(n) ? n : 0; };
  return rows.slice().sort((a, b) => {
    if (sec(a) !== sec(b)) return sec(a) - sec(b);
    if (pos(a) !== pos(b)) return pos(a) - pos(b);
    const ca = String(a.created_at || ''), cb = String(b.created_at || '');
    if (ca !== cb) return ca < cb ? -1 : 1;
    const ia = String(a.id), ib = String(b.id);
    return ia < ib ? -1 : ia > ib ? 1 : 0;
  });
}

// Lecture complète d'une table par pages : le service plafonne chaque réponse
// à 1 000 lignes, sans erreur. `page(from, to)` lance la requête d'une page,
// avec un tri STABLE (sinon une ligne peut être lue deux fois ou sautée d'une
// page à l'autre). Renvoie { data: lignes, error: null, complete } ou
// { data: null, error } dès qu'une page échoue : jamais une liste partielle
// présentée comme complète. Ne lève pas d'exception.
const SB_PAGE_SIZE = 1000;
const SB_MAX_PAGES = 50; // garde : 50 000 lignes
async function sbReadAllPages(page) {
  const out = [];
  for (let i = 0; i < SB_MAX_PAGES; i++) {
    let res;
    try { res = await page(out.length, out.length + SB_PAGE_SIZE - 1); }
    catch (e) { res = { data: null, error: e || { message: 'échec du chargement' } }; }
    if (!res || res.error) return { data: null, error: (res && res.error) || { message: 'échec du chargement' } };
    const rows = Array.isArray(res.data) ? res.data : [];
    for (const r of rows) out.push(r);
    if (rows.length < SB_PAGE_SIZE) return { data: out, error: null, complete: true };
  }
  return { data: out, error: null, complete: false };
}

// Historique (Map habit_id → Set de jours) : applique une suite de bascules
// { id, day, on } sans modifier la Map d'origine.
function habitHistApply(map, edits) {
  if (!edits || !edits.length) return map;
  const next = new Map(map);
  for (const e of edits) {
    const cur = next.get(e.id);
    if (!!(cur && cur.has(e.day)) === !!e.on) continue;
    const set = new Set(cur || []);
    if (e.on) set.add(e.day); else set.delete(e.day);
    next.set(e.id, set);
  }
  return next;
}

// ════════════════════════════════════════════════════════════════════
// Habitudes — affichage partagé (docs/spec-habitudes.md §7.2)
// ════════════════════════════════════════════════════════════════════
// Primitives communes à l'Accueil, Routines et Statistiques, exposées sur
// window. Couleurs : tokens CSS uniquement (clair et sombre).
//   HabitCheck({ checked, onToggle, label, size = 20, disabled, busy })
//   HabitDayMark({ status, size = 22, onToggle, label })
//   HabitLegend({ mode: 'marks' | 'levels', planned, future = true, extra, pretrade })
//   HabitRateBar({ rate, goal })            rate et goal en % (0–100)
//   HabitLoadError({ onRetry, text })

// Zone de clic d'une case : au moins 32 px, 44 px sous 900 px de large (taille
// tactile, charte §8), même si la case dessinée est plus petite.
(function () {
  if (typeof document === 'undefined' || document.getElementById('habit-shared-css')) return;
  const st = document.createElement('style');
  st.id = 'habit-shared-css';
  st.textContent = '.habit-hit{min-width:32px;min-height:32px}'
    + '@media (max-width:899px){.habit-hit{min-width:44px;min-height:44px}}';
  document.head.appendChild(st);
})();

const HABIT_HIT_STYLE = {
  display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flex: 'none',
  padding: 0, margin: 0, border: 0, background: 'transparent', color: 'var(--fg)',
  borderRadius: 8, font: 'inherit', lineHeight: 1,
};

function HabitGlyph({ kind, size, color }) {
  const s = Math.max(8, Math.round(size * 0.6));
  const common = { width: s, height: s, viewBox: '0 0 12 12', fill: 'none', stroke: color,
    strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true, focusable: 'false',
    style: { display: 'block' } };
  if (kind === 'check') return <svg {...common}><path d="M2.2 6.4l2.6 2.6 5-6"/></svg>;
  if (kind === 'cross') return <svg {...common}><path d="M3 3l6 6M9 3l-6 6"/></svg>;
  return <svg {...common}><path d="M3.5 6h5"/></svg>;
}

// Dessin d'une case selon son statut (§5.5) : style de la boîte + signe.
function habitMarkLook(status) {
  const box = { background: 'transparent', border: '1.5px solid transparent' };
  switch (status) {
    case 'done': case 'today-done':
      return { box: { background: 'var(--buy-bg)', border: '1.5px solid var(--buy-bg)' }, glyph: 'check', ink: '#fff' };
    case 'missed':
      return { box: { background: 'var(--red-soft)', border: '1.5px solid transparent' }, glyph: 'cross', ink: 'var(--red-text)' };
    case 'extra':
      return { box: { background: 'transparent', border: '1.5px solid var(--green)' }, glyph: 'check', ink: 'var(--green)' };
    case 'off':
      return { box, glyph: 'dash', ink: 'var(--fg-3)' };
    case 'today':
      // Contour de la case vide : jeton de contour de champ du socle
      // (--field-border, index.html, 3:1 au moins en clair et en sombre ;
      // charte §8). --line-2 n'est qu'un repli si le jeton manquait. Même
      // écriture que la case de l'Agenda (routines.jsx) : les deux cases de la
      // page Routines restent identiques.
      return { box: { background: 'var(--bg-card)', border: '1.5px solid var(--field-border, var(--line-2))' }, glyph: null, ink: 'var(--fg)' };
    case 'future':
      return { box: { background: 'transparent', border: '1px dashed var(--line-2)' }, glyph: null, ink: 'var(--fg-3)' };
    default:
      return { box, glyph: null, ink: 'var(--fg-3)' };
  }
}

function HabitMarkBox({ status, size, radius }) {
  const look = habitMarkLook(status);
  return (
    <span aria-hidden="true" style={{
      width: size, height: size, borderRadius: radius, boxSizing: 'border-box', flex: 'none',
      display: 'inline-flex', alignItems: 'center', justifyContent: 'center', ...look.box,
    }}>
      {look.glyph && <HabitGlyph kind={look.glyph} size={size} color={look.ink}/>}
    </span>
  );
}

// Case à cocher d'une habitude pour la journée en cours. Bouton
// role="checkbox" : Espace et Entrée basculent (comportement natif du bouton).
// Inerte (aria-disabled) sans onToggle ou avec disabled ; pendant une écriture
// (busy) les clics sont ignorés. Le clic ne remonte pas à la ligne parente.
function HabitCheck({ checked, onToggle, label, size = 20, disabled, busy }) {
  const inert = !!disabled || typeof onToggle !== 'function';
  const onClick = (e) => {
    e.stopPropagation();
    if (inert || busy) return;
    onToggle();
  };
  return (
    <button type="button" role="checkbox" className="habit-hit tap"
      aria-checked={checked ? 'true' : 'false'} aria-label={label} title={label}
      aria-disabled={inert ? 'true' : undefined} aria-busy={busy ? 'true' : undefined}
      data-habit-check={checked ? 'on' : 'off'} onClick={onClick}
      style={{ ...HABIT_HIT_STYLE, cursor: inert ? 'default' : (busy ? 'progress' : 'pointer'), opacity: inert && disabled ? 0.55 : 1 }}>
      <HabitMarkBox status={checked ? 'today-done' : 'today'} size={size} radius={6}/>
    </button>
  );
}

// Case d'historique (§5.5). Cliquable (role="checkbox") seulement si onToggle
// est fourni et que le statut est done, missed, extra ou off ; sinon inerte.
function HabitDayMark({ status, size = 22, onToggle, label }) {
  const st = status || 'none';
  const radius = Math.min(6, Math.round(size / 3));
  const clickable = typeof onToggle === 'function' && (st === 'done' || st === 'missed' || st === 'extra' || st === 'off');
  if (clickable) {
    const on = st === 'done' || st === 'extra';
    return (
      <button type="button" role="checkbox" className="habit-hit tap" data-habit-status={st}
        aria-checked={on ? 'true' : 'false'} aria-label={label} title={label}
        onClick={(e) => { e.stopPropagation(); onToggle(); }}
        style={{ ...HABIT_HIT_STYLE, cursor: 'pointer' }}>
        <HabitMarkBox status={st} size={size} radius={radius}/>
      </button>
    );
  }
  return (
    <span data-habit-status={st} title={label || undefined}
      role={label ? 'img' : undefined} aria-label={label || undefined} aria-hidden={label ? undefined : 'true'}
      style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flex: 'none' }}>
      <HabitMarkBox status={st} size={size} radius={radius}/>
    </span>
  );
}

// Légende. mode="marks" : signes des cases habitude × jour. mode="levels" :
// niveaux du calendrier, en pourcentage, ou en nombre quand `planned` est
// fourni (niveaux impossibles omis), puis les états neutres. future={false} :
// sans l'entrée « À venir », pour une grille qui s'arrête à aujourd'hui.
// mode="marks" seulement — extra : ajoute « Fait, non compté » (coche
// entourée) et la phrase qui l'explique ; pretrade : la grille montre des
// vérifications avant trade, dont le tiret veut dire « Pas de trade ce jour ».
// Routines et Statistiques partagent ainsi les mêmes mots pour les mêmes signes.
function HabitLegend({ mode, planned, future = true, extra = false, pretrade = false }) {
  const { theme } = useTheme();
  const SW = 14;
  const swatch = (style) => (
    <span aria-hidden="true" style={{ width: SW, height: SW, borderRadius: 4, boxSizing: 'border-box', flex: 'none', ...style }}/>
  );
  const todaySwatch = swatch({ background: 'var(--bg-card)', border: '1.5px solid var(--blue)' });
  const futureSwatch = swatch({ background: 'transparent', border: '1px dashed var(--line-2)' });
  const items = [];
  if (mode === 'marks') {
    [['done', 'Fait'], ['missed', 'Manqué'], ['extra', 'Fait, non compté'],
      ['off', pretrade ? 'Pas de trade ce jour' : 'Pas prévu'], ['future', 'À venir']].forEach(([st, text]) => {
      if (st === 'future' && !future) return;
      if (st === 'extra' && !extra) return;
      items.push({ key: st, text, icon: <HabitMarkBox status={st} size={SW} radius={4}/> });
    });
    items.push({ key: 'today', text: 'Aujourd’hui', icon: todaySwatch });
  } else {
    const n = Number.isInteger(Number(planned)) && Number(planned) > 0 ? Number(planned) : null;
    const pct = (v) => fmtPct(v, 0);
    const pctLabels = [pct(0) + ' · rien de fait', '1–' + pct(49), '50–' + pct(79), '80–' + pct(99), pct(100) + ' · tout est fait'];
    for (let lv = 0; lv <= 4; lv++) {
      let text = pctLabels[lv];
      if (n) {
        let lo = null, hi = null;
        for (let k = 0; k <= n; k++) {
          if (habitLevel(k, n) !== lv) continue;
          if (lo === null) lo = k;
          hi = k;
        }
        if (lo === null) continue; // niveau impossible avec ce nombre d'habitudes
        text = (lo === hi ? String(lo) : lo + '–' + hi) + ' sur ' + n + (lv === 4 ? ' · tout est fait' : '');
      }
      const ls = habitLevelStyle(lv, theme);
      items.push({ key: 'l' + lv, level: lv, text, icon: swatch({ background: ls.background, border: ls.border }) });
    }
    const none = habitLevelStyle(null, theme);
    items.push({ key: 'none', text: 'Rien de prévu', icon: swatch({ background: none.background, border: none.border }) });
    if (future) items.push({ key: 'future', text: 'À venir', icon: futureSwatch });
    items.push({ key: 'today', text: 'Aujourd’hui', icon: todaySwatch });
  }
  const note = mode === 'marks' && extra
    ? (pretrade ? 'Une vérification ne compte que les jours où tu as tradé\u00A0: cochée un jour sans trade, elle n’entre pas dans tes chiffres.'
      : 'Cochée un jour non prévu, une habitude n’entre pas dans tes chiffres.')
    : '';
  return (
    <div data-habit-legend={mode === 'marks' ? 'marks' : 'levels'} style={{ fontSize: 11.5, lineHeight: 1.3, color: 'var(--fg-2)' }}>
      <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '6px 14px' }}>
        {items.map((it) => (
          <span key={it.key} data-level={it.level != null ? it.level : undefined} data-legend-item={it.key}
            style={{ display: 'inline-flex', alignItems: 'center', gap: 6, whiteSpace: 'nowrap' }}>
            {it.icon}<span>{it.text}</span>
          </span>
        ))}
      </div>
      {note && <div data-habit-legend-note style={{ marginTop: 6, lineHeight: 1.45, color: 'var(--fg-3)' }}>{note}</div>}
    </div>
  );
}

// Barre de régularité sur une échelle ABSOLUE 0–100 % (20 % remplit 20 % de
// la piste). Repère vertical à l'objectif si `goal` est fourni. rate null →
// piste vide. Décorative : le chiffre est écrit à côté par l'écran.
function HabitRateBar({ rate, goal }) {
  const r = rate === null || rate === undefined || !Number.isFinite(Number(rate)) ? null : Math.max(0, Math.min(100, Number(rate)));
  const g = goal === null || goal === undefined || !Number.isFinite(Number(goal)) ? null : Math.max(0, Math.min(100, Number(goal)));
  return (
    <div aria-hidden="true" style={{ position: 'relative', width: '100%', minWidth: 40, padding: '3px 0' }}>
      <div data-habit-track style={{ position: 'relative', height: 8, borderRadius: 4, background: 'var(--bg-elev)', overflow: 'hidden' }}>
        <div data-habit-fill style={{ width: (r === null ? 0 : r) + '%', height: '100%', borderRadius: 4, background: 'var(--green)' }}/>
      </div>
      {g !== null && (
        <span data-habit-goal style={{ position: 'absolute', top: 0, bottom: 0, left: g + '%', width: 2, marginLeft: -1, borderRadius: 1, background: 'var(--fg-2)' }}/>
      )}
    </div>
  );
}

// Échec de chargement des habitudes : jamais confondu avec un compte vide.
function HabitLoadError({ onRetry, text }) {
  return (
    <div role="alert" className="card" data-habit-error style={{
      display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12,
      padding: '12px 16px', background: 'var(--red-soft)', borderColor: 'var(--red)',
    }}>
      <span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--red-text)' }}>
        {text || 'Impossible de charger tes habitudes. Vérifie ta connexion.'}
      </span>
      {typeof onRetry === 'function' && (
        <button type="button" onClick={() => onRetry()} className="btn tap" style={{ color: 'var(--fg)' }}>Réessayer</button>
      )}
    </div>
  );
}

// ════════════════════════════════════════════════════════════════════
// useAppState — partie Habitudes : API exposée aux écrans (§7.3)
// ════════════════════════════════════════════════════════════════════
// Lecture
//   habitRows          lignes normalisées (actives ET archivées), triées par
//                      moment, position, création
//   habitCtx           { today, tz, history, tradedDays, tradesByDay } mémoïsé,
//                      à passer tel quel aux fonctions du moteur
//   habitsLoading      vrai jusqu'à la première réponse de la table habits
//   habitsError        message affichable ou null (les données déjà chargées
//                      restent en place : une erreur n'est jamais un compte vide)
//   reloadHabits()     relance habitudes + coches (bouton « Réessayer »)
//   habitHistoryReady  vrai quand tout l'historique des coches est chargé
//                      (avant : séries et statistiques incomplètes → squelette)
//   habitTradesState   'ready' | 'loading' | 'error' : jours tradés connus, en
//                      cours de chargement, ou jamais chargés. Hors 'ready', les
//                      vérifications avant trade ne sont pas comptées : attendre
//                      ('loading') ou le dire à l'écran ('error')
//   habitFeatures      { days, archive, goal } : faux tant que
//                      supabase-migration-habitudes.sql n'est pas exécutée
//   habitGoal          objectif de régularité en % (80 par défaut)
//   habitsArchived     [{ id, t, section, archivedDay, doneCount }]
//   habits, habitsAll  [{ g, sec, items }] — habitudes ACTIVES ; habits = Matin,
//                      Journée, Soir ; habitsAll = les quatre moments
//   pretradeItems      items du moment « Avant trade »
//                      item = { id, t, d, section, days, due, streak, best }
//                      (d = cochée aujourd'hui, due = prévue aujourd'hui)
//   recentDays         7 derniers jours, du plus ancien à aujourd'hui
//   habitDoneOn(id, jour) → booléen · habitStreak(id) → série en cours
// Écriture — chaque fonction renvoie { data } ou { error } (error.message en
// français ou erreur brute à passer à sbErrorMessage)
//   toggleHabit(id, jour = aujourd'hui)   coche ou décoche ; refuse un jour à
//                                          venir ou hors suivi
//   addHabit(section | libellé, nom, { days } = {})
//   addHabitsBulk([{ section, name, days }])   un seul insert
//   updateHabit(id, { name, section, days })   une seule requête
//   renameHabit(id, nom)                       alias de updateHabit
//   moveHabit(id, -1 | 1)                      monter / descendre dans son moment
//   archiveHabit(id) · restoreHabit(id)        l'historique est conservé ; les
//                                              jours passés en archive deviennent une pause
//   removeHabit(id)                            suppression définitive
//   setHabitGoal(pct)                          50 à 100

// Fuseau de l'appareil (navigateur), ou '' quand il ne peut pas servir :
// illisible, inconnu du navigateur, ou UTC (certains navigateurs masquent le
// fuseau réel en annonçant UTC : l'adopter daterait tout à l'heure de Londres
// en hiver pour un compte utilisé en France). Sert uniquement tant que le
// compte n'a pas de ligne profiles, donc pas de fuseau choisi.
const STATE_TZ_UTC_RE = /^(Etc\/)?(UTC|UCT|GMT([+-]?0)?|Universal|Zulu|Greenwich|Unknown)$/i;
function stateDeviceTz() {
  let tz = '';
  try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (e) { return ''; }
  if (typeof tz !== 'string' || !tz || STATE_TZ_UTC_RE.test(tz)) return '';
  if (typeof fpTzValid === 'function' && !fpTzValid(tz)) return '';
  return tz;
}

function useAppState() {
  const [user, setUser]         = React.useState(null);
  const [loading, setLoading]   = React.useState(true);
  // Lignes `habits` brutes, actives ET archivées, triées (habitSortRows).
  const [habitsRows, setHabitsRows] = React.useState([]);
  const [habitsLoading, setHabitsLoading] = React.useState(true);
  // Échecs de chargement (lignes / coches) : jamais confondus avec un compte vide.
  const [habitsRowsError, setHabitsRowsError] = React.useState(false);
  const [habitHistError, setHabitHistError] = React.useState(false);
  const HABITS_LOAD_ERROR = 'Impossible de charger tes habitudes. Vérifie ta connexion.';
  // Historique complet des coches : Map(habit_id → Set('YYYY-MM-DD')). Source
  // unique de « cochée ce jour-là », journée en cours comprise.
  const [habitHist, setHabitHist] = React.useState(() => new Map());
  const habitHistRef = React.useRef(habitHist);
  habitHistRef.current = habitHist;
  // Vrai quand tout l'historique est lu (séries et statistiques complètes).
  const [habitHistoryReady, setHabitHistoryReady] = React.useState(false);
  const habitHistReadyRef = React.useRef(false);
  const setHistoryReady = (v) => { habitHistReadyRef.current = v; setHabitHistoryReady(v); };
  // Numéros de requête : seule la plus récente publie (changement de compte,
  // « Réessayer » pendant un chargement).
  const habitsReqRef = React.useRef(0);
  const habitHistReqRef = React.useRef(0);
  const habitDayReqRef = React.useRef(0);
  // Bascules faites pendant qu'une lecture des coches est en cours : rejouées
  // sur son résultat, pour qu'une réponse partie avant le clic ne l'efface pas.
  const habitHistLoadsRef = React.useRef(0);
  const habitHistEditsRef = React.useRef([]);
  // Une écriture à la fois par case (clé `habit_id|jour`).
  const habitBusyRef = React.useRef(new Set());
  // Réordonnancements envoyés l'un après l'autre.
  const habitMoveChainRef = React.useRef(Promise.resolve());
  // Colonnes de supabase-migration-habitudes.sql présentes ? (§6.3)
  const [habitFeatures, setHabitFeatures] = React.useState({ days: true, archive: true, goal: true });
  const habitFeatRef = React.useRef(habitFeatures);
  const habitProbeRef = React.useRef(false);
  const [agendaRows, setAgendaRows] = React.useState([]);
  // Échec de la dernière lecture de l'agenda du jour (charte §7 : une erreur de
  // chargement n'est pas un agenda vide). Les événements déjà chargés restent
  // affichés ; les écrans montrent un message avec « Réessayer » (reloadAgenda).
  const [agendaError, setAgendaError] = React.useState(false);
  // Jour de la dernière lecture de l'agenda qui a répondu (réussie ou non),
  // null tant qu'aucune n'a répondu pour ce compte. agendaLoaded (plus bas) =
  // ce jour est le jour courant : avant la première réponse, et entre un
  // changement de jour et la réponse de la nouvelle lecture, l'agenda est « en
  // cours de chargement », pas « vide » (charte §7 : squelette, puis la phrase).
  const [agendaDay, setAgendaDay] = React.useState(null);
  // Jour civil d'une ligne agenda_events ('YYYY-MM-DD').
  const agendaRowDay = (r) => String((r && r.date) || '').slice(0, 10);
  // Numéro de la dernière lecture de l'agenda du jour : seule la plus récente
  // publie (démarrage et changement de jour qui se croisent, déconnexion).
  const agendaReqRef = React.useRef(0);
  // Compteur de version de l'agenda : incrémenté après chaque mutation réussie
  // (ajout / suppression / bascule « fait »). La vue semaine de Routines
  // (design/routines.jsx) recharge sa plage de dates quand il change — les
  // cartes « Agenda du jour » de l'Accueil continuent de lire `agenda`
  // (lignes du jour, mises à jour de façon optimiste) sans dépendre de lui.
  const [agendaTick, setAgendaTick] = React.useState(0);
  const bumpAgenda = () => setAgendaTick(t => t + 1);
  const [dayJournals, setDayJournals] = React.useState({}); // keyed by ISO date
  const [dayAttachments, setDayAttachments] = React.useState({}); // keyed by ISO date
  // Jours qui ont un journal (notes non vides ou humeur) ou des captures, même
  // sans trade : { 'YYYY-MM-DD': { notes: bool, mood: n|null, captures: n } }.
  // null tant que la liste n'a pas été lue (Journal quotidien, loadJournalDays).
  const [journalDays, setJournalDays] = React.useState(null);
  const journalDaysReqRef = React.useRef(0);
  // Numéro du compte affiché : incrémenté à chaque déconnexion ou changement de
  // compte (resetAccountData). Toute lecture ou écriture le relève avant
  // d'attendre le serveur et ne publie rien s'il a changé entre-temps : une
  // réponse du compte précédent n'arrive jamais à l'écran du suivant.
  const accountSeqRef = React.useRef(0);
  // Écritures du journal et des captures, par jour (clé `journal|jour` ou
  // `captures|jour`) : { n: version, jobs: écritures en cours }. Une lecture
  // partie avant une écriture ne doit pas être prise pour l'état enregistré
  // (voir readDayFresh).
  const dayWritesRef = React.useRef(new Map());
  // Heure du dernier rechargement complet du compte (effet de démarrage) :
  // espace les rechargements déclenchés par le retour sur l'onglet.
  const lastRefreshRef = React.useRef(0);
  // Un chargement est-il en échec à l'écran ? (lu par onAuthStateChange, dont
  // la fonction date du premier rendu.)
  const loadFailedRef = React.useRef(false);
  const [trades, setTrades]     = React.useState([]);
  const [tradesLoading, setTradesLoading] = React.useState(true);
  // Vrai dès qu'un chargement des trades a réussi pour ce compte : tant qu'il
  // est faux, les jours tradés sont inconnus (habitCtx.tradedDays = null).
  const [tradesKnown, setTradesKnown] = React.useState(false);
  // Échec du dernier chargement des trades : message affichable, sinon null.
  // Charte §7 : une erreur de chargement n'est pas un compte vide. Les trades
  // déjà chargés restent affichés, et les écrans montrent un bandeau avec
  // « Réessayer » (refreshTrades).
  const [tradesError, setTradesError] = React.useState(null);
  const TRADES_LOAD_ERROR = 'Impossible de charger tes trades. Vérifie ta connexion.';
  // Numéro du dernier chargement des trades : seule la requête la plus récente
  // publie son résultat (double chargement au démarrage via INITIAL_SESSION,
  // « Réessayer » pendant un chargement, déconnexion en cours de route).
  const tradesReqRef = React.useRef(0);
  // Lectures des trades en cours, et issue de la dernière lecture publiée
  // (vrai = liste en mémoire complète et à jour). Tenus hors rendu :
  // applyTradeChange les consulte après l'attente d'une écriture, depuis un
  // écran dont l'objet d'état peut dater d'un rendu précédent.
  const tradesInFlightRef = React.useRef(0);
  const tradesFreshRef    = React.useRef(false);
  // Avis d'écriture non aboutie, visibles sur tous les écrans (notifyWriteError).
  const [writeNotices, setWriteNotices] = React.useState([]);
  const writeNoticeSeqRef = React.useRef(0);
  const [refreshTick, setRefreshTick]     = React.useState(0);
  const [profile, setProfile]   = React.useState(null); // profiles row or null
  // Les trades ne sont publiés qu'une fois le profil connu : sinon les montants
  // s'afficheraient un instant en $ (devise par défaut) et groupés dans le
  // fuseau par défaut, puis basculeraient à l'arrivée du profil (charte §2 et §4).
  const profileKnownRef   = React.useRef(false); // profil lu (ou pas encore créé, ou table absente)
  // Même information en état, pour l'affichage : tant qu'il est faux, le prénom
  // n'est pas encore connu (displayName retombe sur l'email, « Demo » au lieu
  // de « Guillaume ») et un écran peut attendre avant de saluer.
  const [profileReady, setProfileReady] = React.useState(false);
  const setProfileKnown = (known) => { profileKnownRef.current = known; setProfileReady(known); };
  const profilePendingRef = React.useRef(null);  // chargement du profil en cours (promesse → profil connu ?)
  const profileReqRef     = React.useRef(0);     // numéro du dernier chargement du profil
  // Dernière ligne profiles publiée (null = aucune), à jour sans attendre le
  // rendu : saveProfile et adoptDeviceTz s'y fient entre deux rendus.
  const profileRowRef     = React.useRef(null);
  const publishProfile = (row) => { profileRowRef.current = row || null; setProfile(row || null); };
  // Compte dont le fuseau de l'appareil est en cours d'enregistrement (adoptDeviceTz).
  const tzAdoptRef        = React.useRef(null);
  // Compte dont les données sont chargées (détecte une connexion ou un changement de compte).
  const userIdRef = React.useRef(null);

  // Devise / fuseau dérivés du profil (fallbacks sains). Propagés AVANT le calcul
  // de `today` pour que todayISO() (qui lit window.__appTz) soit déjà dans le bon
  // fuseau au tout premier rendu où le profil est connu.
  // Fuseau : celui du profil (charte §4). Sans ligne profiles (compte neuf,
  // fuseau jamais choisi) ou avant sa lecture, celui de l'appareil ; Paris
  // seulement si l'appareil n'en annonce aucun d'utilisable.
  const currency = profile?.currency || 'USD';
  const timezone = profile?.timezone || stateDeviceTz() || 'Europe/Paris';
  if (typeof window !== 'undefined') { window.__appCurrency = currency; window.__appTz = timezone; }

  // Date du jour (ISO) en état : elle ne change qu'à minuit (ou si le fuseau du
  // profil change), et déclenche alors le rechargement des données du jour
  // (voir effets ci-dessous) au lieu de rester figée sur la veille.
  const [today, setToday] = React.useState(() => todayISO());
  // Dernière date pour laquelle complétions / agenda du jour ont été chargés
  // (évite un double chargement au boot).
  const loadedDayRef = React.useRef(null);

  // Bascule de date : vérification chaque minute, immédiate quand le fuseau
  // change, et au retour sur l'onglet ou l'application (un minuteur peut être
  // gelé pendant la veille). toggleHabit la refait au moment du clic.
  React.useEffect(() => {
    const check = () => { const t = todayISO(); if (t !== today) setToday(t); };
    check();
    const id = setInterval(check, 60000);
    const onShow = () => { if (typeof document === 'undefined' || document.visibilityState !== 'hidden') check(); };
    const canListen = typeof window !== 'undefined' && typeof window.addEventListener === 'function';
    if (canListen) {
      window.addEventListener('focus', onShow);
      window.addEventListener('pageshow', onShow);
      document.addEventListener('visibilitychange', onShow);
    }
    return () => {
      clearInterval(id);
      if (canListen) {
        window.removeEventListener('focus', onShow);
        window.removeEventListener('pageshow', onShow);
        document.removeEventListener('visibilitychange', onShow);
      }
    };
  }, [today, timezone]);

  // ── Boot: get user, fetch habits + completions + today's agenda
  React.useEffect(() => {
    let live = true;
    loadedDayRef.current = today;
    lastRefreshRef.current = Date.now();
    (async () => {
      if (!window.sb) { setLoading(false); setTradesLoading(false); setHabitsLoading(false); return; }
      const u = await resolveBootUser();
      if (!live) return;
      setUser(u || null);
      userIdRef.current = u ? u.id : null;
      if (!u) { setLoading(false); setTradesLoading(false); setHabitsLoading(false); return; }
      // Profil lancé en premier : loadTrades l'attend avant de publier les
      // trades, puis met lui-même tradesLoading à false.
      const profileLoad = startProfileLoad(u.id);
      // Historique complet des coches : lancé tout de suite, sans retarder le
      // premier affichage (les coches du jour arrivent par loadCompletions).
      loadHabitHistory();
      await Promise.all([
        loadHabits(),
        loadCompletions(today),
        loadAgenda(today),
        loadTrades(),
        profileLoad,
      ]);
      if (!live) return;
      setLoading(false);
    })();
    return () => { live = false; };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [refreshTick]);

  // Changement de jour (minuit / fuseau) après le boot : recharge les coches
  // du jour, l'historique et l'agenda du jour pour la nouvelle date.
  React.useEffect(() => {
    if (!user || loading || !window.sb) return;
    if (loadedDayRef.current === today) return;
    loadedDayRef.current = today;
    loadCompletions(today);
    loadHabitHistory();
    loadAgenda(today);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [today, user, loading]);

  // Colonnes de supabase-migration-habitudes.sql : deux lectures d'essai
  // légères, une fois par session, après le premier affichage.
  React.useEffect(() => {
    if (loading || !user || !window.sb || habitProbeRef.current) return;
    habitProbeRef.current = true;
    probeHabitColumns();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [loading, user]);

  // Compte connecté au démarrage. auth.getUser interroge le serveur : hors ligne,
  // il échoue alors que la session locale est valide. On se rabat alors sur la
  // session locale pour lancer quand même les chargements : ils échouent
  // proprement (bandeau « Réessayer ») au lieu de montrer un compte vide.
  async function resolveBootUser() {
    try {
      const { data, error } = await window.sb.auth.getUser();
      if (data && data.user) return data.user;
      const { data: s } = await window.sb.auth.getSession();
      const su = (s && s.session && s.session.user) || null;
      if (su && error) console.warn('[Tempo] getUser :', error.message || error);
      return su;
    } catch (e) {
      console.warn('[Tempo] getUser :', (e && e.message) || e);
      return null;
    }
  }

  // Déconnexion ou changement de compte : plus rien du compte précédent ne
  // reste affiché, et ses chargements encore en cours ne publient plus rien.
  function resetAccountData() {
    accountSeqRef.current++;
    dayWritesRef.current = new Map();
    agendaReqRef.current++;
    setAgendaError(false);
    setAgendaDay(null);
    tradesReqRef.current++;
    tradesFreshRef.current = false;
    setWriteNotices([]);
    profileReqRef.current++;
    profilePendingRef.current = null;
    setProfileKnown(false);
    habitsReqRef.current++; habitHistReqRef.current++; habitDayReqRef.current++;
    habitHistEditsRef.current = [];
    habitBusyRef.current = new Set();
    setHabitsRows([]); setHabitHist(new Map()); setHistoryReady(false);
    setHabitsRowsError(false); setHabitHistError(false); setHabitsLoading(true);
    setAgendaRows([]);
    journalDaysReqRef.current++;
    setDayJournals({}); setDayAttachments({}); setJournalDays(null); setTrades([]); setTradesKnown(false); setTradesError(null); publishProfile(null);
    tzAdoptRef.current = null;
  }

  // Subscribe to auth changes to reload state
  React.useEffect(() => {
    if (!window.sb) return;
    const { data: sub } = window.sb.auth.onAuthStateChange((evt, session) => {
      const uid = session?.user?.id || null;
      setUser(session?.user || null);
      if (!uid) { userIdRef.current = null; resetAccountData(); return; }
      if (uid !== userIdRef.current) {
        // Connexion ou autre compte : rechargement complet, tout de suite.
        if (userIdRef.current) resetAccountData();
        // Nouveau compte : squelette jusqu'à l'arrivée de ses trades, jamais
        // l'état « Aucun trade » d'un compte vide.
        setTradesLoading(true);
        userIdRef.current = uid;
      } else {
        // Même compte. Seul SIGNED_IN recharge : supabase-js l'émet à chaque
        // retour de l'onglet au premier plan, et c'est le moment de reprendre ce
        // qui a changé sur un autre appareil. INITIAL_SESSION ne fait que
        // confirmer la session lue au démarrage ; TOKEN_REFRESHED (toutes les
        // heures) et USER_UPDATED (mot de passe, email : `user` est déjà à jour
        // ci-dessus) ne changent aucune donnée du compte.
        if (evt !== 'SIGNED_IN') return;
        // Tout relire (historique complet des coches compris) à chaque retour
        // sur l'onglet coûte cher et efface une saisie en cours dans Réglages :
        // au plus un rechargement toutes les 5 minutes… sauf si un chargement est
        // en échec à l'écran, auquel cas le retour sur l'onglet (souvent le
        // retour du réseau) relance tout de suite.
        // (Un écart négatif = horloge de l'appareil reculée : on recharge.)
        const since = Date.now() - lastRefreshRef.current;
        if (!loadFailedRef.current && since >= 0 && since < ACCOUNT_REFRESH_MIN_MS) return;
      }
      setRefreshTick(t => t + 1);
    });
    return () => { try { sub.subscription.unsubscribe(); } catch (e) {} };
  }, []);

  // ── Habitudes : chargements ─────────────────────────────────────
  // Toutes les habitudes, actives et archivées. Toujours select('*') sans
  // filtre ni tri sur une colonne récente (§6.3) : le tri est local. En cas
  // d'échec, les lignes déjà chargées restent et habitsError est renseigné.
  async function loadHabits() {
    const seq = ++habitsReqRef.current;
    let res;
    try { res = await window.sb.from('habits').select('*'); }
    catch (e) { res = { data: null, error: e || { message: 'échec du chargement' } }; }
    if (seq !== habitsReqRef.current) return false; // une requête plus récente (ou une déconnexion) l'emporte
    if (res.error) {
      console.warn('[Tempo] habits :', res.error.message || res.error);
      setHabitsRowsError(true);
      setHabitsLoading(false);
      return false;
    }
    setHabitsRows(habitSortRows(res.data || []));
    setHabitsRowsError(false);
    setHabitsLoading(false);
    return true;
  }

  // Publie un nouvel historique des coches, en rejouant par-dessus les
  // bascules faites pendant la lecture.
  function publishHabitHist(build) {
    const edits = habitHistEditsRef.current.slice();
    setHabitHist(prev => habitHistApply(build(prev), edits));
  }
  function endHabitHistLoad() {
    habitHistLoadsRef.current = Math.max(0, habitHistLoadsRef.current - 1);
    if (habitHistLoadsRef.current === 0) habitHistEditsRef.current = [];
  }
  // Bascule locale d'une coche (optimiste, ou retour arrière après un échec).
  function editHabitHist(id, day, on) {
    const edit = { id, day, on };
    if (habitHistLoadsRef.current > 0) habitHistEditsRef.current.push(edit);
    setHabitHist(prev => habitHistApply(prev, [edit]));
  }

  // Coches d'un jour (premier affichage : la journée en cours sans attendre
  // tout l'historique). Remplace ce que l'on sait de ce jour-là.
  async function loadCompletions(date) {
    const seq = ++habitDayReqRef.current;
    habitHistLoadsRef.current++;
    try {
      let res;
      try { res = await window.sb.from('habit_completions').select('habit_id').eq('date', date); }
      catch (e) { res = { data: null, error: e || { message: 'échec du chargement' } }; }
      if (seq !== habitDayReqRef.current) return false;
      if (res.error) { console.warn('[Tempo] completions :', res.error.message || res.error); return false; }
      const ids = new Set((res.data || []).map(r => r.habit_id));
      publishHabitHist(prev => {
        const next = new Map();
        prev.forEach((set, id) => {
          if (set.has(date) && !ids.has(id)) { const s = new Set(set); s.delete(date); next.set(id, s); }
          else next.set(id, set);
        });
        ids.forEach(id => {
          const cur = next.get(id);
          if (cur && cur.has(date)) return;
          const s = new Set(cur || []); s.add(date); next.set(id, s);
        });
        return next;
      });
      return true;
    } finally { endHabitHistLoad(); }
  }

  // Tout l'historique des coches, par pages de HABIT_PAGE_SIZE : au-delà de
  // 1 000 lignes, rien ne manque, et aucun plafond n'arrête la lecture. La
  // première page (les coches les plus récentes) est lue avec le total exact,
  // comme listTradesResult, et publiée aussitôt au premier chargement ; les
  // pages suivantes partent ensuite par vagues de HABIT_PAGE_WAVE requêtes en
  // parallèle au lieu de s'attendre l'une l'autre. habitHistoryReady passe à
  // vrai à la fin. Un échec, même d'une seule page, garde l'historique déjà
  // chargé : rien de partiel n'est publié comme complet (charte §7).
  const HABIT_PAGE_SIZE = 1000;
  const HABIT_PAGE_WAVE = 6; // pages demandées en même temps
  async function loadHabitHistory() {
    const seq = ++habitHistReqRef.current;
    habitHistLoadsRef.current++;
    const map = new Map();
    const copy = () => { const m = new Map(); map.forEach((set, id) => m.set(id, new Set(set))); return m; };
    // Une page de `size` lignes à partir du rang `offset`. Ne lève jamais.
    const readPage = async (offset, size, withCount) => {
      try {
        return await window.sb.from('habit_completions')
          .select('habit_id, date', withCount ? { count: 'exact' } : undefined)
          .order('date', { ascending: false })
          .order('habit_id') // ordre stable d'une page à l'autre
          .range(offset, offset + size - 1);
      } catch (e) { return { data: null, error: e || { message: 'échec du chargement' } }; }
    };
    const take = (rows) => {
      for (const r of rows) {
        if (!r || r.habit_id == null || !r.date) continue;
        const day = String(r.date).slice(0, 10);
        let set = map.get(r.habit_id);
        if (!set) { set = new Set(); map.set(r.habit_id, set); }
        set.add(day);
      }
    };
    const fail = (error) => {
      console.warn('[Tempo] historique des habitudes :', (error && error.message) || error);
      setHabitHistError(true);
      return false;
    };
    try {
      const first = await readPage(0, HABIT_PAGE_SIZE, true);
      if (seq !== habitHistReqRef.current) return false;
      if (first.error) return fail(first.error);
      const rows0 = first.data || [];
      take(rows0);
      let offset = rows0.length;
      // Total annoncé par le service ; null s'il ne le donne pas (lecture à la suite).
      const total = typeof first.count === 'number' && Number.isFinite(first.count) && first.count >= 0 ? first.count : null;
      // Taille réelle d'une page : une première page plus courte que demandé
      // alors que le total annonce davantage = plafond du service réglé plus
      // bas. Les rangs des pages suivantes avancent alors de cette taille.
      const size = (rows0.length > 0 && rows0.length < HABIT_PAGE_SIZE && total != null && total > rows0.length)
        ? rows0.length : HABIT_PAGE_SIZE;
      // `more` : il reste peut-être des lignes après `offset`.
      let more = rows0.length >= size;
      if (more && !habitHistReadyRef.current) {
        // Premier chargement : la page la plus récente s'affiche sans attendre la suite.
        const recent = copy();
        publishHabitHist(prev => {
          const next = new Map(prev);
          recent.forEach((set, id) => { const s = new Set(next.get(id) || []); set.forEach(d => s.add(d)); next.set(id, s); });
          return next;
        });
      }
      // Total connu : les pages restantes partent par vagues, en parallèle.
      // Une page incomplète arrête les vagues ; la suite, s'il y en a une, est
      // lue page après page ci-dessous.
      while (more && total != null && offset < total) {
        const starts = [];
        for (let o = offset; o < total && starts.length < HABIT_PAGE_WAVE; o += size) starts.push(o);
        const wave = await Promise.all(starts.map(o => readPage(o, size, false)));
        if (seq !== habitHistReqRef.current) return false;
        const bad = wave.find(r => r.error);
        if (bad) return fail(bad.error);
        let short = false;
        for (let i = 0; i < wave.length; i++) {
          const rows = wave[i].data || [];
          take(rows);
          offset = starts[i] + rows.length;
          if (rows.length < size) {
            // Page incomplète avant le total annoncé (coches retirées pendant
            // la lecture, plafond du service abaissé entre-temps) : si elle
            // n'est pas vide, on finit à la suite depuis ce rang. Les pages
            // suivantes de la vague seront relues : rien n'est sauté.
            more = rows.length > 0 && offset < total;
            short = true;
            break;
          }
        }
        if (short) break;
      }
      // À la suite : total inconnu, page incomplète avant la fin, ou dernière
      // page pleine (des coches ajoutées pendant la lecture décalent les rangs
      // et repoussent les plus anciennes au-delà du total annoncé). Fin sur
      // une page vide, ou incomplète une fois le total atteint ou inconnu.
      while (more) {
        const res = await readPage(offset, size, false);
        if (seq !== habitHistReqRef.current) return false;
        if (res.error) return fail(res.error);
        const rows = res.data || [];
        take(rows);
        offset += rows.length;
        if (rows.length === 0 || (rows.length < size && (total == null || offset >= total))) more = false;
      }
      const full = copy();
      publishHabitHist(() => full);
      setHabitHistError(false);
      setHistoryReady(true);
      return true;
    } finally { endHabitHistLoad(); }
  }

  // « Réessayer » : habitudes, coches du jour et historique.
  async function reloadHabits() {
    const res = await Promise.all([loadHabits(), loadCompletions(todayISO()), loadHabitHistory()]);
    return res.every(Boolean);
  }

  // ── Habitudes : colonnes de supabase-migration-habitudes.sql (§6.3) ──
  // Marque des colonnes absentes : les drapeaux passent à false (archive =
  // archived_at ET pauses) et un seul avertissement part dans la console.
  function markHabitColsMissing(cols) {
    const cur = habitFeatRef.current;
    const next = { ...cur };
    if (cols.includes('days')) next.days = false;
    if (cols.includes('archived_at') || cols.includes('pauses')) next.archive = false;
    if (cols.includes('habit_goal')) next.goal = false;
    if (next.days !== cur.days || next.archive !== cur.archive || next.goal !== cur.goal) {
      habitFeatRef.current = next;
      setHabitFeatures(next);
    }
    habitWarnMissing(cols);
  }

  // Lectures d'essai, non bloquantes. Colonne absente → drapeau à false. Toute
  // autre erreur (réseau) laisse le drapeau à true : le repli d'écriture fait foi.
  async function probeHabitColumns() {
    const read = async (table, cols) => {
      try { const { error } = await window.sb.from(table).select(cols).limit(1); return error || null; }
      catch (e) { return null; }
    };
    const [he, pe] = await Promise.all([
      read('habits', HABIT_OPTIONAL_COLS.join(', ')),
      read('profiles', 'habit_goal'),
    ]);
    const missing = [];
    if (he && habitIsMissingColumn(he)) {
      // L'erreur ne nomme que la première colonne absente : on les essaie une à une.
      const errs = await Promise.all(HABIT_OPTIONAL_COLS.map(c => read('habits', c)));
      HABIT_OPTIONAL_COLS.forEach((c, i) => { if (errs[i] && habitIsMissingColumn(errs[i])) missing.push(c); });
      if (!missing.length) missing.push(habitMissingColumnName(he) || HABIT_OPTIONAL_COLS[0]);
    }
    if (pe && habitIsMissingColumn(pe)) missing.push('habit_goal');
    else if (pe && pe.code === 'PGRST205') {
      // Table profiles absente : pas d'objectif réglable, sans bruit (cas déjà géré par loadProfile).
      if (habitFeatRef.current.goal) { habitFeatRef.current = { ...habitFeatRef.current, goal: false }; setHabitFeatures(habitFeatRef.current); }
    }
    if (missing.length) markHabitColsMissing(missing);
  }

  // Écriture dans `habits` avec repli : les clés des colonnes connues comme
  // absentes sont retirées avant l'envoi ; une écriture refusée pour colonne
  // absente est rejouée sans ces clés. `run(payload)` lance la requête.
  // Renvoie { data, error, sent } (sent = ce qui a réellement été écrit).
  async function habitsWrite(payload, run) {
    const isList = Array.isArray(payload);
    const strip = (obj) => {
      const f = habitFeatRef.current;
      const out = { ...obj };
      if (!f.days) delete out.days;
      if (!f.archive) { delete out.archived_at; delete out.pauses; }
      return out;
    };
    const stripAll = (p) => (isList ? p.map(strip) : strip(p));
    const present = (p) => HABIT_OPTIONAL_COLS.filter(c => (isList ? p : [p]).some(o => Object.prototype.hasOwnProperty.call(o, c)));
    let sent = stripAll(payload);
    for (let attempt = 0; attempt < 4; attempt++) {
      if (!isList && Object.keys(sent).length === 0) return { data: null, error: null, sent, skipped: true };
      let res;
      try { res = await run(sent); }
      catch (e) { res = { data: null, error: e || { message: 'échec de l’enregistrement' } }; }
      if (!res.error) return { data: res.data, error: null, sent };
      if (!habitIsMissingColumn(res.error)) return { data: null, error: res.error, sent };
      const here = present(sent);
      const named = habitMissingColumnName(res.error);
      if (!here.length || (named && !here.includes(named))) return { data: null, error: res.error, sent };
      markHabitColsMissing(named ? [named] : here);
      sent = stripAll(sent);
    }
    return { data: null, error: { message: 'Enregistrement impossible pour le moment.' }, sent };
  }

  // Agenda du jour. En cas d'échec, agendaError passe à vrai et les événements
  // déjà chargés ne restent que s'ils sont ceux du jour demandé (charte §7) :
  // après un changement de jour, ceux de la veille ne sont jamais présentés
  // comme l'agenda du jour. Renvoie true si la lecture a été publiée. Ne lève
  // jamais d'exception : le démarrage l'attend avec le reste.
  async function loadAgenda(date) {
    const seq = ++agendaReqRef.current;
    let res;
    try { res = await window.sb.from('agenda_events').select('*').eq('date', date).order('time_at'); }
    catch (e) { res = { data: null, error: e || { message: 'échec du chargement' } }; }
    if (seq !== agendaReqRef.current) return false; // une lecture plus récente (ou une déconnexion) l'emporte
    setAgendaDay(date);
    if (res.error) {
      console.warn('[Tempo] agenda :', res.error.message || res.error);
      setAgendaRows(prev => (prev.some(r => agendaRowDay(r) !== date) ? prev.filter(r => agendaRowDay(r) === date) : prev));
      setAgendaError(true);
      return false;
    }
    setAgendaRows(res.data || []);
    setAgendaError(false);
    return true;
  }
  // « Réessayer » de l'agenda du jour.
  function reloadAgenda() {
    const now = todayISO();
    if (now !== today) setToday(now);
    return loadAgenda(now);
  }

  // Charge tous les trades. En cas d'échec, les trades déjà chargés sont gardés
  // et tradesError est renseigné (charte §7) : un échec ne ressemble jamais à un
  // compte vide, qui pousserait à réimporter et à créer des doublons.
  async function loadTrades() {
    const seq = ++tradesReqRef.current;
    tradesInFlightRef.current++;
    try {
      setTradesLoading(true);
      // Profil pas encore connu (démarrage, ou échec précédent) : on attend son
      // chargement, ou on le relance (« Réessayer »), avant de publier les trades.
      const profileLoad = profileKnownRef.current ? true
        : (profilePendingRef.current || startProfileLoad(userIdRef.current));
      let res;
      try {
        res = await Promise.all([listTradesResult({}), profileLoad]);
      } catch (e) {
        console.warn('[Tempo] trades :', (e && e.message) || e);
        res = [{ data: null, error: e || { message: 'échec du chargement' } }, true];
      }
      if (seq !== tradesReqRef.current) return; // une requête plus récente (ou une déconnexion) l'emporte
      const [{ data, error }, profileOk] = res;
      if (error || !profileOk) {
        // Sans profil lisible, la devise et le fuseau sont inconnus : on ne publie
        // aucun montant plutôt qu'un montant dans une autre devise que la sienne.
        tradesFreshRef.current = false;
        setTradesError(TRADES_LOAD_ERROR);
      } else {
        tradesFreshRef.current = true;
        setTrades(data || []);
        setTradesKnown(true);
        setTradesError(null);
      }
      setTradesLoading(false);
    } finally { tradesInFlightRef.current--; }
  }

  // Après l'écriture d'UN trade (ajout, modification, suppression) : la ligne
  // renvoyée par le service est appliquée à la liste en mémoire, au lieu de
  // relire tout l'historique (une requête par millier de trades).
  //   change = { upsert: ligne complète écrite } ou { removeId: id supprimé }
  // (les deux à la fois sont acceptés). La ligne est rangée à sa place dans
  // l'ordre de listTradesResult : executed_at décroissant, puis id décroissant.
  // Relecture complète à la place (refreshTrades) quand la liste en mémoire
  // n'est pas sûre : jamais lue, dernière lecture en échec, ou lecture en
  // cours — partie avant l'écriture, son résultat effacerait la modification.
  // Import de fichier, synchronisation d'un courtier, renommage d'une
  // stratégie, « Actualiser » et « Réessayer » gardent refreshTrades.
  function tradeSortTime(t) {
    const ms = t && t.executed_at != null ? Date.parse(t.executed_at) : NaN;
    return Number.isFinite(ms) ? ms : Infinity; // sans date : en tête, comme le tri du service
  }
  function tradeSortCmp(a, b) {
    const ta = tradeSortTime(a), tb = tradeSortTime(b);
    if (ta !== tb) return ta > tb ? -1 : 1;
    const ia = a ? a.id : null, ib = b ? b.id : null;
    if (typeof ia === 'number' && typeof ib === 'number') return ib - ia;
    const ka = String(ia == null ? '' : ia), kb = String(ib == null ? '' : ib);
    return ka === kb ? 0 : (ka > kb ? -1 : 1);
  }
  async function applyTradeChange(change) {
    const up = change && change.upsert && typeof change.upsert === 'object' && change.upsert.id != null ? change.upsert : null;
    const removeId = change && change.removeId != null ? change.removeId : null;
    if (!up && removeId === null) return false;
    // Écriture d'un autre compte, arrivée après un changement de compte : sa
    // ligne n'a rien à faire dans la liste du compte affiché.
    const uid = userIdRef.current;
    if (up && up.user_id != null && uid && up.user_id !== uid) return false;
    if (!tradesFreshRef.current || tradesInFlightRef.current > 0) { await loadTrades(); return true; }
    setTrades(prev => {
      let next = prev;
      if (removeId !== null && (!up || up.id !== removeId)) {
        if (next.some(t => t && t.id === removeId)) next = next.filter(t => !t || t.id !== removeId);
      }
      if (up) {
        const old = next.find(t => t && t.id === up.id);
        const row = old ? { ...old, ...up } : up;
        const rest = old ? next.filter(t => !t || t.id !== up.id) : next;
        let at = rest.findIndex(t => tradeSortCmp(row, t) < 0);
        if (at < 0) at = rest.length;
        next = [...rest.slice(0, at), row, ...rest.slice(at)];
      }
      return next;
    });
    return true;
  }

  // Avis d'écriture non aboutie, pour un échec qui arrive après que l'écran
  // d'où l'écriture est partie a été quitté (charte §7 : un échec produit
  // toujours un message). L'application les affiche sur tous les écrans
  // jusqu'à leur fermeture. Trois avis au plus, le plus récent en premier ; un
  // texte déjà affiché remonte en tête au lieu d'être répété. Vidés au
  // changement de compte (resetAccountData).
  const WRITE_NOTICES_MAX = 3;
  function notifyWriteError(text) {
    // Espaces ordinaires et retours à la ligne regroupés ; les espaces
    // insécables du texte (charte §6, avant « : » ou « % ») sont conservées.
    const msg = String(text == null ? '' : text).replace(/[ \t\r\n]+/g, ' ').replace(/^ | $/g, '');
    if (!msg) return null;
    // Trace dans la console, comme tout autre échec d'écriture de ce fichier.
    console.warn('[Tempo] ' + msg);
    const id = ++writeNoticeSeqRef.current;
    setWriteNotices(prev => [{ id, text: msg }, ...prev.filter(n => n.text !== msg)].slice(0, WRITE_NOTICES_MAX));
    return id;
  }
  function dismissWriteNotice(id) {
    setWriteNotices(prev => prev.some(n => n.id === id) ? prev.filter(n => n.id !== id) : prev);
  }

  // Lance (ou relance) le chargement du profil et le note « en cours » pour que
  // loadTrades puisse l'attendre. La promesse indique si le profil est connu.
  function startProfileLoad(uid) {
    const p = loadProfile(uid);
    profilePendingRef.current = p;
    p.then(() => { if (profilePendingRef.current === p) profilePendingRef.current = null; });
    return p;
  }

  // Compte neuf (aucune ligne profiles) : son fuseau n'a jamais été choisi, et
  // la base y mettrait 'Europe/Paris' à la première écriture. On crée donc la
  // ligne avec le fuseau de l'appareil, une fois, sans rien faire attendre : en
  // attendant la réponse, `timezone` vaut déjà ce fuseau (repli ci-dessus), il
  // n'y a donc aucune bascule à l'écran. C'est un insert, pas un upsert : si la
  // ligne a été créée entre-temps (autre appareil), on ne touche pas à son
  // fuseau, on la relit. Un échec laisse le repli en mémoire et sera retenté à
  // la prochaine lecture du profil. Appareil sans fuseau utilisable (ou en
  // UTC) : rien n'est écrit.
  function adoptDeviceTz(uid) {
    const tz = stateDeviceTz();
    if (!uid || !tz || tzAdoptRef.current === uid) return;
    tzAdoptRef.current = uid;
    const acc = accountSeqRef.current;
    (async () => {
      let res;
      try {
        res = await window.sb.from('profiles')
          .insert({ id: uid, timezone: tz, updated_at: new Date().toISOString() })
          .select().single();
      } catch (e) { res = { data: null, error: e || { message: 'échec de l’enregistrement' } }; }
      // Compte changé entre-temps : resetAccountData a déjà tout remis à zéro.
      if (acc !== accountSeqRef.current) return;
      tzAdoptRef.current = null;
      if (res.error || !res.data) {
        const err = res.error || {};
        // Ligne créée entre-temps : c'est elle qui fait foi.
        if (err.code === '23505') { if (!profileRowRef.current) startProfileLoad(uid); return; }
        if (err.code !== 'PGRST205') console.warn('[Tempo] fuseau de l’appareil :', err.message || 'non enregistré');
        return;
      }
      // Un enregistrement du profil (Réglages, objectif) est arrivé pendant
      // l'écriture : sa réponse est plus récente que celle-ci.
      if (profileRowRef.current) return;
      // Comme saveProfile : une lecture du profil partie avant cette réponse
      // ne doit pas republier « aucune ligne » en arrivant après.
      profileReqRef.current++;
      profilePendingRef.current = null;
      publishProfile(res.data);
    })();
  }

  // Lit le profil du compte. Renvoie true quand la devise et le fuseau sont
  // connus (profil lu, pas encore créé, ou table absente), sinon false. Un échec
  // passager garde le profil déjà chargé : on ne revient jamais aux valeurs par
  // défaut ($, fuseau de l'appareil) sur une coupure réseau. Ne lève jamais
  // d'exception.
  async function loadProfile(knownUid) {
    const seq = ++profileReqRef.current;
    try {
      let uid = knownUid;
      if (!uid) {
        const { data } = await window.sb.auth.getUser();
        uid = data?.user?.id;
      }
      if (seq !== profileReqRef.current) return profileKnownRef.current;
      if (!uid) { console.warn('[Tempo] profile load : aucune session'); return profileKnownRef.current; }
      const { data, error } = await window.sb.from('profiles')
        .select('*').eq('id', uid).maybeSingle();
      if (seq !== profileReqRef.current) return profileKnownRef.current;
      if (error) {
        // Table absente (PGRST205) = migration pas encore exécutée : valeurs par
        // défaut attendues, sans bruit dans la console.
        if (error.code === 'PGRST205') { publishProfile(null); setProfileKnown(true); return true; }
        console.warn('[Tempo] profile load:', error.message || error);
        return profileKnownRef.current;
      }
      publishProfile(data || null);
      setProfileKnown(true);
      // Aucune ligne profiles : le fuseau n'a jamais été choisi.
      if (!data) adoptDeviceTz(uid);
      return true;
    } catch (e) {
      if (seq === profileReqRef.current) console.warn('[Tempo] profile load:', (e && e.message) || e);
      return profileKnownRef.current;
    }
  }

  // Journal et captures d'un jour : un échec de lecture LÈVE l'erreur, il ne
  // renvoie pas un journal vide. Le Journal du jour (pages-2.jsx) l'attrape et
  // affiche un message avec « Réessayer » : une saisie dans un journal affiché
  // vide à tort écraserait les notes existantes à l'enregistrement.
  //
  // Les caches dayJournals / dayAttachments ne contiennent QUE des jours lus en
  // entier : getDayJournal / getDayAttachments les servent sans relire. Une
  // écriture ne crée donc jamais d'entrée (elle met à jour celle qui existe),
  // et une réponse arrivée après un changement de compte n'y entre pas.
  const DAY_ACCOUNT_CHANGED = { message: 'Le compte a changé pendant le chargement.' };

  // Suivi des écritures d'un jour (clé `journal|jour` ou `captures|jour`).
  function dayWriteSlot(key) {
    let slot = dayWritesRef.current.get(key);
    if (!slot) { slot = { n: 0, jobs: new Set() }; dayWritesRef.current.set(key, slot); }
    return slot;
  }
  // Exécute une écriture en la déclarant « en cours » pour ce jour.
  async function trackDayWrite(key, run) {
    const slot = dayWriteSlot(key);
    slot.n++;
    const job = Promise.resolve().then(run);
    const settled = job.then(() => {}, () => {});
    slot.jobs.add(settled);
    try { return await job; }
    finally { slot.jobs.delete(settled); slot.n++; }
  }
  // Lecture d'un jour qui ne se laisse pas doubler par une écriture : elle
  // attend les écritures en cours de ce jour, et si une écriture a commencé ou
  // fini pendant la lecture, elle relit (2 fois au plus). `fresh` est faux si le
  // résultat peut encore dater d'avant une écriture : il n'est alors pas mis en
  // cache, et la prochaine ouverture du jour relira la base.
  async function readDayFresh(key, read) {
    for (let attempt = 0; ; attempt++) {
      const slot = dayWriteSlot(key);
      if (slot.jobs.size) await Promise.all(Array.from(slot.jobs));
      const before = slot.n;
      const res = await read();
      const fresh = slot.n === before && slot.jobs.size === 0;
      if (fresh || attempt >= 2 || (res && res.error)) return { res, fresh };
    }
  }

  async function loadDayJournal(date) {
    const acc = accountSeqRef.current;
    let out;
    try {
      out = await readDayFresh('journal|' + date, () => window.sb.from('day_journals')
        .select('*').eq('date', date).maybeSingle());
    } catch (e) { out = { res: { data: null, error: e || { message: 'échec du chargement' } }, fresh: false }; }
    const { data, error } = out.res || {};
    if (error) { console.warn('[Tempo] dayJournal :', error.message || error); throw error; }
    if (acc !== accountSeqRef.current) throw DAY_ACCOUNT_CHANGED;
    if (out.fresh) setDayJournals(prev => ({ ...prev, [date]: data || { notes: '', mood: null } }));
    return data;
  }

  async function loadDayAttachments(date) {
    const acc = accountSeqRef.current;
    let out;
    try {
      out = await readDayFresh('captures|' + date, () => window.sb.from('day_attachments')
        .select('*').eq('date', date).order('created_at', { ascending: false }));
    } catch (e) { out = { res: { data: null, error: e || { message: 'échec du chargement' } }, fresh: false }; }
    const { data, error } = out.res || {};
    if (error) { console.warn('[Tempo] dayAttach :', error.message || error); throw error; }
    if (acc !== accountSeqRef.current) throw DAY_ACCOUNT_CHANGED;
    if (out.fresh) setDayAttachments(prev => ({ ...prev, [date]: data || [] }));
    return data || [];
  }

  // ── Habitudes : écritures ───────────────────────────────────────
  // Toutes renvoient { data } ou { error }. Les lectures dérivées (habitRows,
  // habitCtx, groupes par moment, séries) sont plus bas, après tradesByDay.

  // Compte connecté, résolu au dernier moment (au démarrage, l'état `user`
  // peut être en retard d'un rendu).
  async function habitUid() {
    if (user && user.id) return user.id;
    if (!window.sb) return null;
    try { const { data } = await window.sb.auth.getUser(); return (data && data.user && data.user.id) || null; }
    catch (e) { return null; }
  }
  const HABIT_NO_SESSION = { message: 'Tu dois être connecté.' };
  const HABIT_NOT_FOUND = { message: 'Cette habitude n’existe plus. Recharge la page.' };
  const HABIT_MAX_ERROR = { message: 'Tu suis déjà ' + HABIT_MAX_ACTIVE + ' habitudes. Archive-en une pour en ajouter.' };
  const habitSectionOf = (r) => (SECTION_ORDER.includes(r.section) ? r.section : 'matin');
  const habitActiveCount = () => habitsRows.filter(r => r.archived !== true).length;
  // Position de fin d'un moment : plus grande position des habitudes actives + 1.
  function habitEndPos(section, exceptId) {
    let max = -1;
    for (const r of habitsRows) {
      if (r.archived === true || r.id === exceptId || habitSectionOf(r) !== section) continue;
      const n = Number(r.position);
      if (Number.isFinite(n) && n > max) max = n;
    }
    return max + 1;
  }
  // update d'une ligne `habits` (une requête, avec repli), puis mise à jour locale.
  async function habitsUpdate(id, patch, label) {
    const row = habitsRows.find(r => r.id === id);
    if (!row) return { error: HABIT_NOT_FOUND };
    const acc = accountSeqRef.current;
    const res = await habitsWrite(patch, p => window.sb.from('habits').update(p).eq('id', id).select());
    if (res.error) { console.warn('[Tempo] ' + label + ' :', res.error.message || res.error); return { error: res.error }; }
    if (res.skipped) return { data: row };
    const saved = Array.isArray(res.data) ? res.data[0] : res.data;
    if (acc !== accountSeqRef.current) return saved ? { data: { ...row, ...res.sent, ...saved } } : { error: HABIT_NOT_FOUND };
    if (!saved) { loadHabits(); return { error: HABIT_NOT_FOUND }; }
    const next = { ...row, ...res.sent, ...saved };
    setHabitsRows(prev => habitSortRows(prev.map(r => (r.id === id ? next : r))));
    return { data: next };
  }

  // Coche ou décoche une habitude pour un jour (aujourd'hui par défaut : le
  // jour civil du profil au moment du clic). Optimiste ; un échec rétablit la
  // case et renvoie { error }. Refuse un jour à venir et un jour hors suivi.
  async function toggleHabit(id, date) {
    const now = todayISO();
    // Minuit passé depuis la dernière vérification : l'écran bascule sur le
    // nouveau jour dès ce geste, au lieu de rester jusqu'à une minute sur la veille.
    if (now !== today) setToday(now);
    const day = (typeof date === 'string' && HABIT_DAY_RE.test(date.slice(0, 10))) ? date.slice(0, 10) : now;
    if (!window.sb) return { error: HABIT_NO_SESSION };
    if (day > now) return { error: { message: 'Tu ne peux pas cocher un jour à venir.' } };
    const h = habitRows.find(r => r.id === id);
    if (!h) return { error: HABIT_NOT_FOUND };
    if (!habitIsTracked(h, day, { ...habitCtx, today: now })) {
      return { error: { message: 'Cette habitude n’était pas suivie ce jour-là.' } };
    }
    const key = id + '|' + day;
    if (habitBusyRef.current.has(key)) return { data: null, busy: true }; // écriture déjà en cours pour cette case
    habitBusyRef.current.add(key);
    const set = habitHistRef.current.get(id);
    const was = !!(set && set.has(day));
    // L'écriture compte comme une lecture en cours : une lecture des coches
    // lancée entre-temps (changement de jour déclenché ci-dessus, « Réessayer »)
    // peut revenir avant que l'écriture soit enregistrée ; la bascule est alors
    // rejouée par-dessus son résultat au lieu d'être effacée de l'écran.
    habitHistLoadsRef.current++;
    editHabitHist(id, day, !was);
    const acc = accountSeqRef.current;
    let error = null;
    try {
      const uid = await habitUid();
      if (!uid) error = HABIT_NO_SESSION;
      else if (was) {
        ({ error } = await window.sb.from('habit_completions').delete().eq('habit_id', id).eq('date', day));
      } else {
        ({ error } = await window.sb.from('habit_completions').insert({ user_id: uid, habit_id: id, date: day }));
        if (error && String(error.code) === '23505') error = null; // déjà cochée (autre appareil) : c'est le résultat voulu
      }
    } catch (e) {
      error = e || { message: 'échec de l’enregistrement' };
    } finally {
      habitBusyRef.current.delete(key);
    }
    if (error) {
      console.warn('[Tempo] toggleHabit :', error.message || error);
      // Retour arrière, sauf si le compte a changé entre-temps (l'historique
      // affiché n'est plus celui de cette habitude).
      if (acc === accountSeqRef.current) editHabitHist(id, day, was);
      endHabitHistLoad();
      return { error };
    }
    endHabitHistLoad();
    return { data: { habit_id: id, date: day, done: !was } };
  }

  // Ajoute une habitude en fin de moment. `days` n'est envoyé que s'il diffère
  // de « tous les jours » (les vérifications avant trade l'ignorent).
  async function addHabit(groupLabelOrSection, name, opts) {
    const clean = habitCleanName(name);
    if (clean.error) return { error: { message: clean.error } };
    const uid = await habitUid();
    if (!uid) { console.warn('[Tempo] addHabit : pas de session active'); return { error: HABIT_NO_SESSION }; }
    const section = SECTION_ORDER.includes(groupLabelOrSection)
      ? groupLabelOrSection
      : sectionCodeFromLabel(groupLabelOrSection);
    if (habitActiveCount() >= HABIT_MAX_ACTIVE) return { error: HABIT_MAX_ERROR };
    const row = { user_id: uid, section, name: clean.name, position: habitEndPos(section) };
    const days = habitNormDays(opts && opts.days);
    if (section !== 'pretrade' && days.length < 7) row.days = days;
    const acc = accountSeqRef.current;
    const res = await habitsWrite(row, p => window.sb.from('habits').insert(p).select().single());
    if (res.error || !res.data) {
      const error = res.error || { message: 'Enregistrement impossible pour le moment.' };
      console.warn('[Tempo] addHabit :', error.message || error);
      return { error };
    }
    const saved = res.data;
    if (acc !== accountSeqRef.current) return { data: saved }; // compte changé : rien à afficher ici
    setHabitsRows(prev => habitSortRows([...prev.filter(r => r.id !== saved.id), saved]));
    return { data: saved };
  }

  // Ajoute plusieurs habitudes en UN insert (suggestions, « Tout ajouter »).
  async function addHabitsBulk(items) {
    const list = Array.isArray(items) ? items : [];
    const uid = await habitUid();
    if (!uid) return { error: HABIT_NO_SESSION };
    const endPos = {};
    const rows = [];
    for (const it of list) {
      const clean = habitCleanName(it && it.name);
      if (clean.error) continue;
      const section = SECTION_ORDER.includes(it.section) ? it.section : sectionCodeFromLabel(it.section);
      if (endPos[section] === undefined) endPos[section] = habitEndPos(section);
      const days = section === 'pretrade' ? HABIT_ALL_DAYS.slice() : habitNormDays(it.days);
      rows.push({ user_id: uid, section, name: clean.name, position: endPos[section]++, days });
    }
    if (!rows.length) return { error: { message: 'Donne un nom à l’habitude.' } };
    if (habitActiveCount() + rows.length > HABIT_MAX_ACTIVE) return { error: HABIT_MAX_ERROR };
    // Toutes les lignes d'un insert groupé portent les mêmes clés : `days`
    // part pour toutes dès qu'une seule n'est pas quotidienne, sinon pour aucune.
    if (rows.every(r => r.days.length === 7)) rows.forEach(r => { delete r.days; });
    const acc = accountSeqRef.current;
    const res = await habitsWrite(rows, p => window.sb.from('habits').insert(p).select());
    if (res.error) { console.warn('[Tempo] addHabitsBulk :', res.error.message || res.error); return { error: res.error }; }
    const saved = Array.isArray(res.data) ? res.data : [];
    if (acc !== accountSeqRef.current) return { data: saved }; // compte changé : rien à afficher ici
    const ids = new Set(saved.map(r => r.id));
    setHabitsRows(prev => habitSortRows([...prev.filter(r => !ids.has(r.id)), ...saved]));
    return { data: saved };
  }

  // Modifie le nom, le moment et/ou les jours prévus en une seule requête.
  // Changer de moment place l'habitude à la fin du moment cible.
  async function updateHabit(id, changes) {
    const row = habitsRows.find(r => r.id === id);
    if (!row) return { error: HABIT_NOT_FOUND };
    const c = changes || {};
    const patch = {};
    if (c.name !== undefined) {
      const clean = habitCleanName(c.name);
      if (clean.error) return { error: { message: clean.error } };
      if (clean.name !== row.name) patch.name = clean.name;
    }
    if (c.section !== undefined && SECTION_ORDER.includes(c.section) && c.section !== habitSectionOf(row)) {
      patch.section = c.section;
      patch.position = habitEndPos(c.section, id);
    }
    if (c.days !== undefined && c.days !== null) {
      const valid = Array.isArray(c.days) && c.days.some(x => Number.isInteger(Number(x)) && Number(x) >= 1 && Number(x) <= 7);
      if (!valid) return { error: { message: 'Choisis au moins un jour.' } };
      const days = habitNormDays(c.days);
      if (days.join(',') !== habitNormDays(row.days).join(',')) patch.days = days;
    }
    if (!Object.keys(patch).length) return { data: row };
    return habitsUpdate(id, patch, 'updateHabit');
  }

  function renameHabit(id, name) { return updateHabit(id, { name }); }

  // Monte (-1) ou descend (1) une habitude dans son moment : positions
  // renumérotées 0…n−1 puis échange ; seules les lignes modifiées sont écrites.
  async function moveHabit(id, dir) {
    const row = habitsRows.find(r => r.id === id && r.archived !== true);
    if (!row) return { error: HABIT_NOT_FOUND };
    const section = habitSectionOf(row);
    const list = habitSortRows(habitsRows.filter(r => r.archived !== true && habitSectionOf(r) === section));
    const i = list.findIndex(r => r.id === id);
    const j = i + (Number(dir) < 0 ? -1 : 1);
    if (j < 0 || j >= list.length) return { data: [] }; // déjà à l'extrémité
    const order = list.slice();
    order[i] = list[j]; order[j] = list[i];
    const changes = [];
    order.forEach((r, k) => { if (Number(r.position) !== k) changes.push({ id: r.id, position: k }); });
    const posById = new Map(changes.map(ch => [ch.id, ch.position]));
    setHabitsRows(prev => habitSortRows(prev.map(r => (posById.has(r.id) ? { ...r, position: posById.get(r.id) } : r))));
    const send = async () => {
      try {
        const res = await Promise.all(changes.map(ch => window.sb.from('habits').update({ position: ch.position }).eq('id', ch.id)));
        const bad = res.find(r => r && r.error);
        return bad ? bad.error : null;
      } catch (e) { return e || { message: 'échec de l’enregistrement' }; }
    };
    const job = habitMoveChainRef.current.then(send);
    habitMoveChainRef.current = job.then(() => {}, () => {});
    const error = await job;
    if (error) {
      console.warn('[Tempo] moveHabit :', error.message || error);
      reloadHabits();
      return { error };
    }
    return { data: changes };
  }

  // Archive : l'habitude quitte les listes, son historique est conservé.
  // Sans la colonne archived_at, seul { archived: true } est écrit et
  // l'habitude sort des statistiques tant qu'elle est archivée.
  function archiveHabit(id) {
    return habitsUpdate(id, { archived: true, archived_at: new Date().toISOString() }, 'archiveHabit');
  }

  // Restaure : les jours passés en archive (du jour d'archivage à hier)
  // deviennent une pause neutre, et l'habitude revient en fin de moment.
  async function restoreHabit(id) {
    const row = habitsRows.find(r => r.id === id);
    if (!row) return { error: HABIT_NOT_FOUND };
    if (row.archived === true && habitActiveCount() >= HABIT_MAX_ACTIVE) return { error: HABIT_MAX_ERROR };
    // Pas de doublon dans un même moment (§8.1, CA-24) : la restauration est
    // refusée tant qu'une habitude active de ce moment porte le même nom. Le
    // message ne cite pas le nom : il s'affiche tel quel sous la ligne archivée.
    const section = habitSectionOf(row);
    if (row.archived === true) {
      const key = habitNameKey(row.name);
      const taken = habitsRows.some(r => r.id !== id && r.archived !== true && habitSectionOf(r) === section && habitNameKey(r.name) === key);
      if (taken) {
        return { error: { message: 'Une habitude active porte déjà ce nom dans ' + SECTION_LABELS[section] + '. Renomme l’une des deux avant de restaurer.' } };
      }
    }
    const patch = { archived: false, archived_at: null, position: habitEndPos(section, id) };
    let from = habitInstantDay(row.archived_at, timezone);
    const to = habitShiftDay(todayISO(), -1);
    // Archivée SANS date (archivage fait avant la migration habitudes) : la
    // période d'archive doit rester neutre elle aussi. Elle est réputée
    // commencer le lendemain de la dernière coche connue ou, sans coche, au
    // début du suivi (même règle que le rétro-datage de la migration). Sans
    // l'historique des coches, on ne peut pas la dater : on le dit. Tant que
    // la migration n'est pas exécutée (colonne pauses absente), aucune pause ne
    // peut être écrite : la restauration reste { archived: false, position }.
    if (row.archived === true && !from && habitFeatRef.current.archive) {
      if (!habitHistReadyRef.current || habitHistError) {
        return { error: { message: 'Impossible de restaurer cette habitude pour le moment\u00A0: ton historique n’est pas chargé. Vérifie ta connexion, puis réessaie.' } };
      }
      const set = habitHistRef.current && habitHistRef.current.get(id);
      let last = null;
      if (set) set.forEach((d) => { if (last === null || d > last) last = d; });
      from = last ? habitShiftDay(last, 1) : habitInstantDay(row.created_at, timezone);
    }
    if (from && from <= to) patch.pauses = [...habitNormPauses(row.pauses), { from, to }];
    return habitsUpdate(id, patch, 'restoreHabit');
  }

  // Suppression définitive (habitude et coches, par cascade). La ligne ne
  // quitte l'écran qu'après la réponse.
  async function removeHabit(id) {
    if (!window.sb) return { error: HABIT_NO_SESSION };
    let error = null;
    try { ({ error } = await window.sb.from('habits').delete().eq('id', id)); }
    catch (e) { error = e || { message: 'échec de la suppression' }; }
    if (error) { console.warn('[Tempo] removeHabit :', error.message || error); return { error }; }
    setHabitsRows(prev => prev.filter(h => h.id !== id));
    setHabitHist(prev => { if (!prev.has(id)) return prev; const next = new Map(prev); next.delete(id); return next; });
    return { data: { id } };
  }

  // Objectif de régularité (50 à 100 %), enregistré dans le profil.
  const HABIT_GOAL_UNAVAILABLE = { message: 'L’objectif ne peut pas être modifié pour le moment.' };
  async function setHabitGoal(pct) {
    const n = Math.round(Number(pct));
    if (!Number.isFinite(n) || n < 50 || n > 100) return { error: { message: 'L’objectif doit être compris entre 50 et 100\u00A0%.' } };
    if (!habitFeatRef.current.goal) return { error: HABIT_GOAL_UNAVAILABLE };
    return saveProfile({ habit_goal: n });
  }

  // ── Agenda CRUD ─────────────────────────────────────────────────
  // Seules les lignes datées du jour courant : entre le changement de jour et
  // la réponse de la nouvelle lecture (ou si elle échoue), les événements de la
  // veille ne sont ni affichés ni cochables comme « agenda du jour ».
  const agenda = React.useMemo(() => {
    return agendaRows.filter(r => agendaRowDay(r) === today).map(r => ({
      id: r.id,
      h: (r.time_at || '00:00').slice(0, 5),
      e: r.title,
      tag: r.tag || '',
      done: !!r.is_done,
      _row: r,
    })).sort((a, b) => a.h.localeCompare(b.h));
  }, [agendaRows, today]);
  // Faux tant que l'agenda du jour courant n'a pas reçu de réponse (sans
  // Supabase, il n'y a rien à attendre).
  const agendaLoaded = agendaDay === today || typeof window === 'undefined' || !window.sb;

  // Bascule « fait ». `doneHint` (booléen, optionnel) permet de basculer un
  // événement d'un AUTRE jour que le jour courant (vue semaine) : il n'est pas
  // dans `agendaRows` (lignes du jour), on part donc de l'état connu par l'appelant.
  async function toggleAgenda(id, doneHint) {
    const row = agendaRows.find(r => r.id === id);
    const next = row ? !row.is_done : (typeof doneHint === 'boolean' ? !doneHint : null);
    if (next === null) return { data: null };
    if (row) setAgendaRows(prev => prev.map(r => r.id === id ? { ...r, is_done: next } : r));
    const acc = accountSeqRef.current;
    let error = null;
    try { ({ error } = await window.sb.from('agenda_events').update({ is_done: next }).eq('id', id)); }
    catch (e) { error = e || { message: 'échec de l’enregistrement' }; }
    if (error) {
      // Échec : la case du jour est rétablie ici ; l'appelant rétablit la sienne.
      console.warn('[Tempo] toggleAgenda :', error.message || error);
      if (row && acc === accountSeqRef.current) setAgendaRows(prev => prev.map(r => r.id === id ? { ...r, is_done: !next } : r));
      return { error };
    }
    if (acc !== accountSeqRef.current) return { data: { id, is_done: next } };
    bumpAgenda();
    return { data: { id, is_done: next } };
  }

  // Lignes brutes agenda_events sur une plage de dates incluse [fromISO, toISO]
  // (vue semaine de Routines). Renvoie toujours un tableau quand la lecture a
  // réussi ([] sans Supabase, sans dates, ou si le compte a changé pendant la
  // lecture). Un échec LÈVE l'erreur (charte §7) : une semaine qui n'a pas pu
  // être lue n'est pas une semaine sans événement, l'appelant doit pouvoir
  // afficher un message avec « Réessayer ».
  async function loadAgendaRange(fromISO, toISO) {
    if (!window.sb || !fromISO || !toISO) return [];
    const acc = accountSeqRef.current;
    let res;
    try {
      res = await window.sb.from('agenda_events')
        .select('*').gte('date', fromISO).lte('date', toISO)
        .order('date').order('time_at');
    } catch (e) { res = { data: null, error: e || { message: 'échec du chargement' } }; }
    if (acc !== accountSeqRef.current) return [];
    if (res.error) {
      console.warn('[Tempo] agenda de la semaine :', res.error.message || res.error);
      throw res.error;
    }
    return res.data || [];
  }

  async function addAgenda(item) {
    if (!item || !item.e || !item.e.trim()) return { error: { message: 'Titre vide.' } };
    let uid = user?.id;
    if (!uid && window.sb) {
      try { const { data } = await window.sb.auth.getUser(); uid = data?.user?.id; } catch (e) {}
    }
    if (!uid) { console.warn('[Tempo] addAgenda: pas de session active'); return { error: { message: 'Tu dois être connecté.' } }; }
    const row = {
      user_id: uid,
      date: item.date || today,
      time_at: item.h || '12:00',
      title: item.e.trim(),
      tag: item.tag || null,
      is_done: false,
    };
    const acc = accountSeqRef.current;
    let data = null, error = null;
    try { ({ data, error } = await window.sb.from('agenda_events').insert(row).select().single()); }
    catch (e) { error = e || { message: 'échec de l’enregistrement' }; }
    if (error) { console.warn('[Tempo] addAgenda :', error.message || error); return { error }; }
    if (acc !== accountSeqRef.current) return { data }; // compte changé : rien à afficher ici
    if (row.date === today) setAgendaRows(prev => [...prev, data]);
    bumpAgenda();
    return { data };
  }

  // Supprime un événement. La ligne n'est retirée qu'après la réponse : en cas
  // d'échec rien ne bouge et l'appelant reçoit { error }.
  async function removeAgenda(id) {
    let error = null;
    try { ({ error } = await window.sb.from('agenda_events').delete().eq('id', id)); }
    catch (e) { error = e || { message: 'échec de la suppression' }; }
    if (error) { console.warn('[Tempo] removeAgenda :', error.message || error); return { error }; }
    setAgendaRows(prev => prev.filter(r => r.id !== id));
    bumpAgenda();
    return { data: { id } };
  }

  // ── Jours avec journal ou captures (Journal quotidien) ──────────
  // Deux lectures légères, par pages (sbReadAllPages) : au-delà de 1 000
  // journaux ou de 1 000 captures, aucun jour ne manque. Un échec, même d'une
  // seule page, laisse la liste telle quelle (les jours tradés restent
  // affichés) et renvoie false.
  async function loadJournalDays() {
    if (!user || !window.sb) return false;
    const seq = ++journalDaysReqRef.current;
    try {
      const readAll = (table, cols) => sbReadAllPages((from, to) => window.sb.from(table).select(cols)
        .order('date', { ascending: false })
        .order('id') // ordre stable d'une page à l'autre
        .range(from, to));
      const [jr, ar] = await Promise.all([
        readAll('day_journals', 'date, notes, mood'),
        readAll('day_attachments', 'date'),
      ]);
      if (seq !== journalDaysReqRef.current) return false;
      if (jr.error || ar.error) {
        console.warn('[Tempo] journalDays :', (jr.error && (jr.error.message || jr.error)) || (ar.error && (ar.error.message || ar.error)));
        return false;
      }
      if (!jr.complete || !ar.complete) console.warn('[Tempo] journalDays : plus de ' + (SB_PAGE_SIZE * SB_MAX_PAGES) + ' lignes, lecture arrêtée.');
      const map = {};
      (jr.data || []).forEach(r => {
        if (!r || !r.date) return;
        const notes = typeof r.notes === 'string' && r.notes.trim() !== '';
        const mood = Number.isFinite(Number(r.mood)) && r.mood !== null ? Number(r.mood) : null;
        if (!notes && mood == null) return;
        const d = String(r.date).slice(0, 10);
        map[d] = { notes, mood, captures: 0 };
      });
      (ar.data || []).forEach(r => {
        if (!r || !r.date) return;
        const d = String(r.date).slice(0, 10);
        map[d] = map[d] || { notes: false, mood: null, captures: 0 };
        map[d].captures += 1;
      });
      setJournalDays(map);
      return true;
    } catch (e) {
      console.warn('[Tempo] journalDays :', (e && e.message) || e);
      return false;
    }
  }
  // Mise à jour locale après une saisie (seulement si la liste est déjà lue).
  // `patch` : objet, ou fonction (état actuel du jour) → objet.
  const patchJournalDay = (date, patch) => {
    setJournalDays(prev => {
      if (!prev) return prev;
      const cur = prev[date] || { notes: false, mood: null, captures: 0 };
      const next = { ...cur, ...(typeof patch === 'function' ? patch(cur) : patch) };
      const out = { ...prev };
      if (!next.notes && next.mood == null && !next.captures) delete out[date];
      else out[date] = next;
      return out;
    });
  };

  // ── Day journal (notes + mood) ──────────────────────────────────
  async function getDayJournal(date) {
    if (!date) return null;
    if (dayJournals[date]) return dayJournals[date];
    return await loadDayJournal(date);
  }

  // Enregistrement des notes / de l'humeur. Renvoie { error } : null si c'est
  // enregistré, sinon l'erreur brute (à afficher via sbErrorMessage).
  //
  // Le cache est mis à jour tout de suite, mais seulement pour un jour déjà lu :
  // une écriture ne crée jamais une entrée partielle ({ mood } sans les notes),
  // que getDayJournal servirait ensuite comme un journal complet — les notes
  // s'afficheraient vides et la frappe suivante les écraserait. Si l'écriture
  // échoue, l'entrée du jour est retirée du cache : la prochaine ouverture
  // relit la base, donc ce qui est affiché est toujours ce qui est enregistré
  // (remettre « la valeur d'avant » pourrait restaurer un texte lui-même
  // jamais enregistré quand deux enregistrements se chevauchent).
  async function saveDayJournalField(date, field, value, label) {
    if (!user) return { error: { message: 'Tu dois être connecté.' } };
    if (!date) return { error: { message: 'Jour manquant.' } };
    const acc = accountSeqRef.current;
    setDayJournals(prev => (prev[date] ? { ...prev, [date]: { ...prev[date], [field]: value } } : prev));
    const payload = { user_id: user.id, date, [field]: value, updated_at: new Date().toISOString() };
    let error = null;
    try {
      ({ error } = await trackDayWrite('journal|' + date, () => window.sb.from('day_journals')
        .upsert(payload, { onConflict: 'user_id,date' })));
    } catch (e) { error = e || { message: 'échec de l’enregistrement' }; }
    const sameAccount = acc === accountSeqRef.current;
    if (error) {
      console.warn('[Tempo] ' + label + ' :', error.message || error);
      if (sameAccount) setDayJournals(prev => { if (!prev[date]) return prev; const next = { ...prev }; delete next[date]; return next; });
      return { error };
    }
    if (!sameAccount) return { error: null }; // enregistré, mais le compte affiché a changé : rien à publier
    if (field === 'notes') patchJournalDay(date, { notes: typeof value === 'string' && value.trim() !== '' });
    else patchJournalDay(date, { mood: Number.isFinite(Number(value)) && value !== null ? Number(value) : null });
    return { error: null };
  }
  function setDayJournalNotes(date, notes) { return saveDayJournalField(date, 'notes', notes, 'setDayJournalNotes'); }
  function setDayJournalMood(date, mood) { return saveDayJournalField(date, 'mood', mood, 'setDayJournalMood'); }

  // ── Day attachments (storage) ───────────────────────────────────
  async function getDayAttachments(date) {
    if (!date) return [];
    if (dayAttachments[date]) return dayAttachments[date];
    return await loadDayAttachments(date);
  }

  async function uploadDayAttachment(date, file) {
    if (!user) return { error: { message: 'Tu dois être connecté.' } };
    if (!file || !date) return { error: { message: 'Aucun fichier à ajouter.' } };
    if (!DAY_ATTACHMENT_MIME.includes(String(file.type || '').toLowerCase())) {
      return { error: { message: 'Type de fichier non pris en charge (images PNG, JPEG, WebP, GIF ou HEIC uniquement).' } };
    }
    if ((file.size || 0) > DAY_ATTACHMENT_MAX_BYTES) {
      return { error: { message: 'Fichier trop volumineux (10 Mo maximum).' } };
    }
    const acc = accountSeqRef.current;
    const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_');
    const path = `${user.id}/${date}/${Date.now()}_${safeName}`;
    const { error: upErr } = await window.sb.storage.from('day-attachments')
      .upload(path, file, { cacheControl: '3600', upsert: false, contentType: file.type });
    if (upErr) { console.warn('[Tempo] storage upload', upErr.message || upErr); return { error: upErr }; }
    const { data, error } = await trackDayWrite('captures|' + date, () => window.sb.from('day_attachments').insert({
      user_id: user.id, date,
      file_path: path, file_name: file.name,
      size_bytes: file.size || 0, mime_type: file.type || null,
    }).select().single());
    if (error) {
      console.warn('[Tempo] day_attachments insert', error.message || error);
      // Capture refusée : on retire le fichier déjà envoyé pour ne pas laisser
      // un fichier orphelin dans le stockage.
      const { error: rmErr } = await window.sb.storage.from('day-attachments').remove([path]);
      if (rmErr) console.warn('[Tempo] storage remove (orphelin)', rmErr.message || rmErr);
      return { error };
    }
    if (acc !== accountSeqRef.current) return { data }; // compte changé : rien à afficher ici
    // Jour déjà lu : la capture rejoint la liste en cache. Sinon rien n'est créé
    // (une liste d'une seule capture passerait pour la liste complète du jour).
    setDayAttachments(prev => (prev[date] ? { ...prev, [date]: [data, ...prev[date].filter(r => r.id !== data.id)] } : prev));
    patchJournalDay(date, cur => ({ captures: (cur.captures || 0) + 1 }));
    return { data };
  }

  // Suppression d'une capture. Renvoie { error } : null si elle est supprimée.
  // La ligne est supprimée d'abord, le fichier ensuite : si la ligne ne peut
  // pas l'être, la capture reste intacte et visible, au lieu de pointer vers un
  // fichier déjà effacé.
  async function deleteDayAttachment(date, id) {
    const acc = accountSeqRef.current;
    const cached = (dayAttachments[date] || []).find(r => r.id === id);
    const { data: deleted, error } = await trackDayWrite('captures|' + date, () => window.sb.from('day_attachments')
      .delete().eq('id', id).select('file_path'));
    if (error) { console.warn('[Tempo] del attachment', error.message || error); return { error }; }
    if (acc === accountSeqRef.current) {
      setDayAttachments(prev => (prev[date] ? { ...prev, [date]: prev[date].filter(r => r.id !== id) } : prev));
      patchJournalDay(date, cur => ({ captures: Math.max(0, (cur.captures || 0) - 1) }));
    }
    // Chemin du fichier : celui de la ligne supprimée, sinon celui du cache.
    const filePath = (Array.isArray(deleted) && deleted[0] && deleted[0].file_path) || cached?.file_path;
    if (filePath) {
      const { error: rmErr } = await window.sb.storage.from('day-attachments').remove([filePath]);
      if (rmErr) console.warn('[Tempo] storage remove', rmErr.message || rmErr);
    }
    return { error: null };
  }

  async function getAttachmentUrl(file_path) {
    if (!file_path) return null;
    const { data, error } = await window.sb.storage.from('day-attachments')
      .createSignedUrl(file_path, 3600);
    if (error) { console.warn('[Tempo] signed url', error.message || error); return null; }
    return data?.signedUrl || null;
  }

  // ── Trades ──────────────────────────────────────────────────────
  const stats = React.useMemo(() => computeStats(trades), [trades]);
  // Groupement par jour LOCAL du fuseau de l'user (calendrier/daily cohérents).
  const tradesByDay = React.useMemo(() => groupTradesByDay(trades, timezone), [trades, timezone]);

  // ── Habitudes : lectures dérivées (après tradesByDay, dont elles dépendent) ──
  // Jours avec au moins un trade ; null tant que les trades ne sont pas connus
  // (chargement en cours ou en échec) : aucune vérification n'est alors comptée.
  const habitTradedDays = React.useMemo(() => {
    if (!tradesKnown) return null;
    const set = new Set();
    Object.keys(tradesByDay).forEach(d => { if (tradesByDay[d] && tradesByDay[d].trades && tradesByDay[d].trades.length) set.add(d); });
    return set;
  }, [tradesByDay, tradesKnown]);
  const habitCtx = React.useMemo(
    () => ({ today, tz: timezone, history: habitHist, tradedDays: habitTradedDays, tradesByDay }),
    [today, timezone, habitHist, habitTradedDays, tradesByDay]
  );
  // Lignes normalisées, actives et archivées (déjà triées).
  const habitRows = React.useMemo(
    () => habitsRows.map(r => habitNormRow(r, timezone, habitHist)),
    [habitsRows, timezone, habitHist]
  );
  // Série en cours et record de chaque habitude : Map(id → { current, best }).
  const habitStreakMap = React.useMemo(() => {
    const m = new Map();
    habitRows.forEach(h => m.set(h.id, habitStreaks(h, habitCtx)));
    return m;
  }, [habitRows, habitCtx]);

  // Habitudes ACTIVES par moment (les quatre) — forme historique conservée :
  // [{ g, sec, items: [{ id, t, d, section, days, due, streak, best }] }].
  const habitsBySectionAll = React.useMemo(() => {
    const buckets = { matin: [], journee: [], soir: [], pretrade: [] };
    for (const h of habitRows) {
      if (h.archived) continue;
      const st = habitStreakMap.get(h.id) || { current: 0, best: 0 };
      buckets[h.section].push({
        id: h.id,
        t: h.name,
        d: habitIsDone(h, today, habitCtx),
        section: h.section,
        days: h.days,
        due: habitIsDue(h, today, habitCtx),
        streak: st.current || 0,
        best: st.best || 0,
      });
    }
    return SECTION_ORDER.map(sec => ({ g: SECTION_LABELS[sec], sec, items: buckets[sec] }));
  }, [habitRows, habitStreakMap, habitCtx, today]);

  // Matin, Journée, Soir — carte « Habitudes » de l'Accueil.
  const habitsBySection = React.useMemo(
    () => habitsBySectionAll.filter(g => g.sec !== 'pretrade'),
    [habitsBySectionAll]
  );

  // Vérifications avant trade (liste à plat) — Accueil.
  const pretradeItems = React.useMemo(
    () => (habitsBySectionAll.find(g => g.sec === 'pretrade') || { items: [] }).items,
    [habitsBySectionAll]
  );

  // Habitudes archivées, de la plus récemment archivée à la plus ancienne.
  const habitsArchived = React.useMemo(() => {
    return habitRows.filter(h => h.archived).map(h => {
      const set = habitHist.get(h.id);
      return { id: h.id, t: h.name, section: h.section, archivedDay: h.archivedDay, doneCount: set ? set.size : 0 };
    }).sort((a, b) => String(b.archivedDay || '').localeCompare(String(a.archivedDay || '')));
  }, [habitRows, habitHist]);

  // 7 derniers jours, du plus ancien à aujourd'hui (colonnes de Routines).
  const recentDays = React.useMemo(() => {
    const arr = [];
    for (let i = 6; i >= 0; i--) arr.push(habitShiftDay(today, -i));
    return arr;
  }, [today]);
  const habitDoneOn = React.useCallback((id, date) => {
    const set = habitHist.get(id);
    return !!(set && set.has(date));
  }, [habitHist]);
  // Série en cours d'une habitude (0 si inconnue ou archivée).
  const habitStreak = React.useCallback((id) => {
    const st = habitStreakMap.get(id);
    return (st && st.current) || 0;
  }, [habitStreakMap]);

  // Objectif de régularité : profiles.habit_goal (50 à 100), sinon 80 %.
  const habitGoal = React.useMemo(() => {
    const n = profile && profile.habit_goal !== null && profile.habit_goal !== undefined ? Number(profile.habit_goal) : NaN;
    return habitFeatures.goal && Number.isFinite(n) && n >= 50 && n <= 100 ? Math.round(n) : HABIT_GOAL_DEFAULT;
  }, [profile, habitFeatures.goal]);
  const habitsError = (habitsRowsError || habitHistError) ? HABITS_LOAD_ERROR : null;
  // Un chargement en échec à l'écran : le prochain retour sur l'onglet recharge
  // sans attendre le délai minimal (voir onAuthStateChange).
  loadFailedRef.current = !!(tradesError || habitsRowsError || habitHistError || agendaError);
  // Jours tradés : 'ready' (connus), 'loading' (premier chargement des trades)
  // ou 'error' (jamais chargés). Hors 'ready', aucune vérification avant trade
  // n'est comptée (§5.4) : les écrans attendent ('loading') ou le disent ('error').
  const habitTradesState = habitTradedDays ? 'ready' : (tradesLoading ? 'loading' : 'error');

  async function refreshTrades() { await loadTrades(); }

  // ── Profile ─────────────────────────────────────────────────────
  // Upsert the user's profile row (on conflict id) and update local state.
  async function saveProfile(patch) {
    let uid = user?.id;
    if (!uid && window.sb) {
      try { const { data } = await window.sb.auth.getUser(); uid = data?.user?.id; } catch (e) {}
    }
    if (!uid) return { error: { message: 'Tu dois être connecté.' } };
    const acc = accountSeqRef.current;
    const payload = { id: uid, ...patch, updated_at: new Date().toISOString() };
    // Première écriture d'un compte sans ligne profiles, sans fuseau dans le
    // patch (objectif de régularité, par exemple) : on écrit le fuseau effectif,
    // sinon la base y mettrait son défaut 'Europe/Paris' en silence.
    if (profileKnownRef.current && !profileRowRef.current && !Object.prototype.hasOwnProperty.call(payload, 'timezone')) {
      payload.timezone = stateDeviceTz() || 'Europe/Paris';
    }
    const upsert = (p) => window.sb.from('profiles').upsert(p, { onConflict: 'id' }).select().single();
    // Objectif de régularité : colonne de supabase-migration-habitudes.sql.
    // Absente → l'écriture est rejouée sans elle (§6.3).
    const hasGoal = Object.prototype.hasOwnProperty.call(payload, 'habit_goal');
    const onlyGoal = hasGoal && Object.keys(patch || {}).length === 1;
    if (hasGoal && !habitFeatRef.current.goal) {
      if (onlyGoal) return { error: HABIT_GOAL_UNAVAILABLE };
      delete payload.habit_goal;
    }
    let { data, error } = await upsert(payload);
    if (error && Object.prototype.hasOwnProperty.call(payload, 'habit_goal') && habitIsMissingColumn(error)) {
      markHabitColsMissing(['habit_goal']);
      if (onlyGoal) return { error: HABIT_GOAL_UNAVAILABLE };
      delete payload.habit_goal;
      ({ data, error } = await upsert(payload));
    }
    if (error) {
      if (error.code !== 'PGRST205') console.warn('[Tempo] saveProfile:', error.message);
      return { error };
    }
    // Compte changé pendant l'enregistrement : le profil enregistré est celui du
    // compte précédent, il ne doit pas s'afficher sur le suivant.
    if (acc !== accountSeqRef.current) return { data };
    // Un chargement du profil parti avant l'enregistrement ne doit pas remettre
    // l'ancienne devise ou l'ancien fuseau en arrivant après.
    profileReqRef.current++;
    profilePendingRef.current = null;
    setProfileKnown(true);
    publishProfile(data);
    return { data };
  }

  // Derived display name: profile.display_name → profile.first_name →
  // email-derived first name → 'Trader'. Always a non-empty string.
  const displayName = React.useMemo(() => {
    const dn = (profile?.display_name || '').trim();
    if (dn) return dn;
    const fn = (profile?.first_name || '').trim();
    if (fn) return fn;
    const email = user?.email || '';
    if (email) {
      const handle = email.split('@')[0].split('.')[0];
      if (handle) return handle.charAt(0).toUpperCase() + handle.slice(1);
    }
    return 'Trader';
  }, [profile, user]);

  return {
    user, loading,
    // habits — daily groups (matin/journee/soir) for Home, all four for Routines.
    habits: habitsBySection,
    habitsAll: habitsBySectionAll,
    pretradeItems,
    toggleHabit, addHabit, removeHabit, renameHabit,
    addHabitsBulk, updateHabit, moveHabit, archiveHabit, restoreHabit,
    // suivi des habitudes dans le temps
    recentDays, habitDoneOn, habitStreak,
    // habitudes — moteur : lignes normalisées + contexte de calcul, états de
    // chargement, colonnes disponibles, objectif, archives
    habitRows, habitCtx, habitHistoryReady, habitTradesState, habitsLoading, habitsError, reloadHabits,
    habitFeatures, habitGoal, setHabitGoal, habitsArchived,
    // agenda du jour : agendaLoaded = la lecture du jour courant a répondu
    // (faux = encore en chargement, à ne pas présenter comme un agenda vide),
    // agendaError = la dernière lecture a échoué (les événements du jour déjà
    // chargés restent), reloadAgenda = « Réessayer »
    agenda, agendaLoaded, agendaError, reloadAgenda, toggleAgenda, addAgenda, removeAgenda,
    // vue semaine (Routines) : plage de dates + version pour recharger après mutation
    loadAgendaRange, agendaTick,
    // day journal
    dayJournals, getDayJournal, setDayJournalNotes, setDayJournalMood,
    // jours avec journal ou captures (Journal quotidien)
    journalDays, loadJournalDays,
    // attachments
    dayAttachments, getDayAttachments, uploadDayAttachment, deleteDayAttachment, getAttachmentUrl,
    // trades
    // applyTradeChange : après l'écriture d'un seul trade, met la liste en
    // mémoire à jour sans tout relire (repli sur la relecture complète)
    trades, tradesLoading, tradesError, stats, tradesByDay, refreshTrades, applyTradeChange,
    // avis d'écriture non aboutie après le départ de l'écran : [{ id, text }],
    // le plus récent en premier
    writeNotices, notifyWriteError, dismissWriteNotice,
    // profile
    profile, profileReady, saveProfile, displayName, currency, timezone,
    // misc
    // Relit tout le compte. Les caches du journal (notes, humeur, captures,
    // jours avec journal) sont vidés aussi : après une réinitialisation
    // partielle, la fenêtre d'un jour déjà ouvert ne doit pas réafficher des
    // notes effacées.
    refresh: () => {
      journalDaysReqRef.current++;
      setDayJournals({});
      setDayAttachments({});
      setJournalDays(null);
      setRefreshTick(t => t + 1);
    },
    today,
  };
}

Object.assign(window, {
  useAppState, sectionCodeFromLabel, SECTION_LABELS, SECTION_ORDER,
  // Habitudes — moteur de calcul
  HABIT_ALL_DAYS, HABIT_MAX_ACTIVE, HABIT_NAME_MAX, HABIT_GOAL_DEFAULT,
  habitShiftDay, habitWeekdayNum, habitWeekStart, habitNormDays, habitDaysLabel, habitNormRow,
  habitIsTracked, habitIsDone, habitIsDue, habitIsCounted, habitDayStatus,
  habitLevel, habitLevelStyle, habitDayTally, habitRangeStats, habitRate, habitStreaks,
  habitFirstDay, habitPrevRange, habitEvolution, habitWeekdayStats, habitDiscipline,
  habitIsMissingColumn, habitNameKey,
  // Captures du journal : types et taille acceptés (une seule liste pour l'état et l'écran)
  DAY_ATTACHMENT_MIME, DAY_ATTACHMENT_MAX_BYTES,
  // Habitudes — affichage partagé
  HabitCheck, HabitDayMark, HabitLegend, HabitRateBar, HabitLoadError, habitMarkLook, HabitGlyph,
});
