// Tempo — Routines page (habitudes + agenda hebdomadaire + calendrier externe ICS)

// ═════════════════════════════════════════════════════════════════════
// AGENDA HEBDOMADAIRE + CALENDRIER EXTERNE (ICS, lecture seule)
// ═════════════════════════════════════════════════════════════════════
// • Vue semaine (desktop + mobile) : bandeau 7 jours navigable, événements
//   Tempo (table agenda_events, colonne `date`) chargés par plage via
//   state.loadAgendaRange, ajout avec date libre (state.addAgenda accepte
//   item.date). Les cartes « Agenda du jour » de l'Accueil lisent toujours
//   state.agenda (lignes du jour) : elles ne sont pas touchées.
// • Abonnement ICS lecture seule : l'URL privée (Google « adresse secrète
//   iCal », Apple « calendrier public webcal:// », Outlook) est un SECRET.
//   Elle est stockée UNIQUEMENT en localStorage (tempo:agenda:icsUrl), jamais
//   envoyée à Supabase ni loggée (les logs sont tronqués au host). Le fichier
//   est relayé par la route GET /ics du worker tempo-econ-proxy (les serveurs
//   Google/Apple n'envoient pas de CORS) : l'URL est transmise dans l'EN-TÊTE
//   x-tempo-ics-url, jamais en query string (les invocation logs Cloudflare
//   journalisent l'URL de requête, pas les en-têtes custom) — voir
//   cloudflare-worker-proxy.js pour l'allowlist d'hôtes et les limites.
// • Test local sans worker déployé : window.TEMPO_ICS_FIXTURE = '<texte ICS>'
//   court-circuite le fetch (le parseur est aussi exposé : window.parseIcs).
//
// ── ICS-PARSER-BEGIN ─────────────────────────────────────────────────
// JS pur (aucun JSX, aucune dépendance DOM ni autre fichier de l'app) : ce
// bloc, des constantes ICS_* jusqu'à parseIcs, s'extrait tel quel entre les
// marqueurs pour être testé ou réutilisé isolément (jsc, node, worker).
// Ce qui suit le marqueur de fin n'en fait pas partie : les helpers
// d'affichage de l'agenda (agendaWeekLabel, agendaDayLabel, agendaMsToISO,
// agendaMsToHM, icsEventsToRows) s'appuient sur les formateurs communs de
// format-prefs.jsx ; normalizeIcsUrl est pur lui aussi, mais hors bloc.

// TZID Windows/Exchange courants → IANA (Intl ne connaît pas les noms Windows).
const ICS_WINDOWS_TZ = {
  'Romance Standard Time': 'Europe/Paris',
  'W. Europe Standard Time': 'Europe/Berlin',
  'Central Europe Standard Time': 'Europe/Budapest',
  'Central European Standard Time': 'Europe/Warsaw',
  'GMT Standard Time': 'Europe/London',
  'Greenwich Standard Time': 'UTC',
  'Eastern Standard Time': 'America/New_York',
  'Central Standard Time': 'America/Chicago',
  'Mountain Standard Time': 'America/Denver',
  'Pacific Standard Time': 'America/Los_Angeles',
  'Tokyo Standard Time': 'Asia/Tokyo',
  'China Standard Time': 'Asia/Shanghai',
  'Singapore Standard Time': 'Asia/Singapore',
  'AUS Eastern Standard Time': 'Australia/Sydney',
};
const ICS_SIMPLE_FREQ = { DAILY: true, WEEKLY: true };
const ICS_DAY_INDEX = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 };
const ICS_MAX_ITER = 3000; // garde-fou d'expansion RRULE
const ICS_MAX_DUR_MS = 31 * 86400000; // durée max d'un événement (icsEventsToRows tronque déjà à 31 jours)
const ICS_MAX_OUT = 20000; // plafond global d'occurrences (ICS hostile : DAILY + DTEND lointain)
const ICS_MAX_ROWS = 50000; // plafond global de lignes agenda
const ICS_MAX_TEXT = 300; // longueur max du titre / lieu conservés

// Dépliage RFC 5545 : CRLF (ou LF/CR) suivi d'un espace ou d'une tabulation.
function icsUnfold(text) {
  return String(text).replace(/\r\n|\r/g, '\n').replace(/\n[ \t]/g, '');
}

// Découpe `str` sur `sep` en respectant les segments entre guillemets.
function icsSplitQuoted(str, sep) {
  const out = []; let cur = ''; let inQ = false;
  for (let i = 0; i < str.length; i++) {
    const c = str[i];
    if (c === '"') { inQ = !inQ; cur += c; }
    else if (c === sep && !inQ) { out.push(cur); cur = ''; }
    else cur += c;
  }
  out.push(cur);
  return out;
}

// "NAME;P1=V1;P2="a:b":valeur" → { name, params, value } (name/params en MAJ).
function icsParseLine(line) {
  let i = 0, inQ = false;
  for (; i < line.length; i++) {
    const c = line[i];
    if (c === '"') inQ = !inQ;
    else if (c === ':' && !inQ) break;
  }
  if (i >= line.length) return null;
  const head = line.slice(0, i), value = line.slice(i + 1);
  const parts = icsSplitQuoted(head, ';');
  const name = String(parts[0] || '').trim().toUpperCase();
  if (!name) return null;
  const params = {};
  for (let k = 1; k < parts.length; k++) {
    const eq = parts[k].indexOf('=');
    if (eq < 0) continue;
    const pn = parts[k].slice(0, eq).trim().toUpperCase();
    const pv = parts[k].slice(eq + 1).trim().replace(/^"|"$/g, '');
    params[pn] = pv;
  }
  return { name, params, value };
}

// Déséchappement des valeurs TEXT : \n \N \, \; \\
function icsUnescape(v) {
  return String(v || '').replace(/\\([\\;,nN])/g, (_, c) => (c === 'n' || c === 'N') ? '\n' : c);
}

const icsDtfCache = Object.create(null); // sans prototype : un TZID « constructor » ne renvoie rien
function icsDtf(tz) {
  if (!icsDtfCache[tz]) {
    icsDtfCache[tz] = new Intl.DateTimeFormat('en-US', {
      timeZone: tz, hourCycle: 'h23',
      year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
    });
  }
  return icsDtfCache[tz];
}

// Décalage (ms) du fuseau `tz` à l'instant UTC `utcMs` (positif à l'est).
function icsTzOffsetMs(tz, utcMs) {
  const p = Object.create(null);
  icsDtf(tz).formatToParts(new Date(utcMs)).forEach(x => { p[x.type] = x.value; });
  const asUtc = Date.UTC(+p.year, +p.month - 1, +p.day, (+p.hour) % 24, +p.minute, +p.second);
  return asUtc - utcMs;
}

// TZID → nom IANA utilisable par Intl, ou null (= heure locale flottante).
function icsResolveTz(tzid) {
  if (!tzid) return null;
  let tz = String(tzid).trim().replace(/^"|"$/g, '');
  if (Object.prototype.hasOwnProperty.call(ICS_WINDOWS_TZ, tz)) tz = ICS_WINDOWS_TZ[tz];
  // Préfixes exotiques (« /mozilla.org/20050126_1/Europe/Paris ») → suffixe IANA.
  const m = tz.match(/([A-Za-z]+\/[A-Za-z_+\-\/]+)$/);
  if (m) tz = m[1];
  if (/^(UTC|GMT|Z)$/i.test(tz)) return 'UTC';
  try { icsDtf(tz); return tz; } catch (e) { return null; }
}

// Fuseau des heures « flottantes » et des journées entières : celui du profil
// (window.__appTz, charte §4) quand il est connu, sinon l'heure du navigateur
// (tests isolés du parseur, hors navigateur).
function icsLocalTz() {
  try { return (typeof window !== 'undefined' && window.__appTz) ? icsResolveTz(window.__appTz) : null; }
  catch (e) { return null; }
}

// Composants civils {y,m,d,h,mi,s} + mode ('utc' | 'local' | tz IANA) → ms UTC.
function icsCompsToMs(c, mode) {
  if (mode === 'utc') return Date.UTC(c.y, c.m - 1, c.d, c.h, c.mi, c.s);
  if (!mode || mode === 'local') {
    const tz = icsLocalTz();
    if (!tz) return new Date(c.y, c.m - 1, c.d, c.h, c.mi, c.s).getTime();
    mode = tz;
  }
  const guess = Date.UTC(c.y, c.m - 1, c.d, c.h, c.mi, c.s);
  const off1 = icsTzOffsetMs(mode, guess);
  let ts = guess - off1;
  const off2 = icsTzOffsetMs(mode, ts);
  ts = guess - off2; // 2e passe : bord de changement d'heure
  // Heure qui n'existe pas (passage à l'heure d'été) : décalée de la durée du
  // saut, donc une heure plus tard, comme zonedToUtcISO (format-prefs.jsx).
  if (off1 !== off2 && icsTzOffsetMs(mode, ts) !== off2) ts = guess - Math.min(off1, off2);
  return ts;
}

function icsShiftDays(c, days) {
  const d = new Date(Date.UTC(c.y, c.m - 1, c.d + days));
  return { y: d.getUTCFullYear(), m: d.getUTCMonth() + 1, d: d.getUTCDate(), h: c.h, mi: c.mi, s: c.s };
}
function icsWeekday(c) { return new Date(Date.UTC(c.y, c.m - 1, c.d)).getUTCDay(); } // 0 = dimanche

// DTSTART/DTEND/UNTIL/EXDATE → { comps, mode, allDay } ou null.
function icsParseDate(value, params) {
  const v = String(value || '').trim();
  const m = v.match(/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?(Z)?)?$/);
  if (!m) return null;
  const comps = { y: +m[1], m: +m[2], d: +m[3], h: m[4] ? +m[4] : 0, mi: m[5] ? +m[5] : 0, s: m[6] ? +m[6] : 0 };
  const isDate = (params && /DATE$/i.test(params.VALUE || '')) || !m[4];
  if (isDate) return { comps, mode: 'local', allDay: true };
  const mode = m[7] ? 'utc' : (icsResolveTz(params && params.TZID) || 'local');
  return { comps, mode, allDay: false };
}

// DURATION (P1DT2H30M, PT45M, P1W, -PT15M) → ms ; null si illisible.
function icsParseDuration(v) {
  const m = String(v || '').trim().match(/^([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/);
  if (!m) return null;
  const ms = ((+m[2] || 0) * 7 * 86400 + (+m[3] || 0) * 86400 + (+m[4] || 0) * 3600 + (+m[5] || 0) * 60 + (+m[6] || 0)) * 1000;
  return m[1] === '-' ? -ms : ms;
}

// RRULE → { freq, interval, count, until:{comps,mode}|null, byday:[idx], wkst, simple }
// simple = false si la règle sort du périmètre développé (MONTHLY/YEARLY,
// BYMONTHDAY, BYSETPOS, ordinaux « 1MO »…) → affichage de la 1re occurrence.
function icsParseRrule(v) {
  const r = { freq: '', interval: 1, count: null, until: null, byday: null, wkst: 1, simple: true };
  String(v || '').split(';').forEach(part => {
    const eq = part.indexOf('=');
    if (eq < 0) return;
    const k = part.slice(0, eq).trim().toUpperCase(), val = part.slice(eq + 1).trim().toUpperCase();
    if (k === 'FREQ') r.freq = val;
    else if (k === 'INTERVAL') r.interval = Math.max(1, parseInt(val, 10) || 1);
    else if (k === 'COUNT') r.count = Math.max(1, parseInt(val, 10) || 1);
    else if (k === 'UNTIL') r.until = icsParseDate(val, null);
    else if (k === 'WKST') r.wkst = ICS_DAY_INDEX[val] != null ? ICS_DAY_INDEX[val] : 1;
    else if (k === 'BYDAY') {
      const days = [];
      val.split(',').forEach(d => {
        const dm = d.trim().match(/^([+-]?\d+)?([A-Z]{2})$/);
        if (!dm || ICS_DAY_INDEX[dm[2]] == null) { r.simple = false; return; }
        if (dm[1]) r.simple = false; // ordinal (« 1MO ») = règle mensuelle/annuelle
        days.push(ICS_DAY_INDEX[dm[2]]);
      });
      r.byday = days;
    }
    else if (/^BY(MONTHDAY|MONTH|YEARDAY|WEEKNO|SETPOS|HOUR|MINUTE|SECOND)$/.test(k)) r.simple = false;
  });
  if (!ICS_SIMPLE_FREQ[r.freq]) r.simple = false;
  return r;
}

// Occurrences (composants civils du début) d'un événement dans une fenêtre
// [fromMs, toMs] — DAILY / WEEKLY seulement ; sinon la 1re occurrence.
function icsOccurrences(start, rrule, durMs, fromMs, toMs) {
  const out = [];
  if (!rrule || !rrule.simple) { out.push(start.comps); return out; }
  const startMs = icsCompsToMs(start.comps, start.mode);
  let untilMs = null;
  if (rrule.until) {
    untilMs = icsCompsToMs(rrule.until.comps, rrule.until.mode);
    if (rrule.until.allDay) untilMs += 86400000 - 1; // UNTIL en date seule = journée incluse
  }
  let produced = 0, iter = 0;
  const push = (c) => {
    const ts = icsCompsToMs(c, start.mode);
    if (ts < startMs) return 'skip';
    if (untilMs != null && ts > untilMs) return 'stop';
    if (rrule.count != null && produced >= rrule.count) return 'stop';
    produced++;
    if (ts > toMs) return 'stop';
    if (ts + Math.max(durMs, 0) >= fromMs) out.push(c);
    return 'ok';
  };
  // Avance rapide jusqu'à la fenêtre (récurrences anciennes : sans cela le
  // garde-fou ICS_MAX_ITER est consommé AVANT la fenêtre et l'événement
  // disparaît). Marge d'un pas (DST) + durée de l'événement ; les instances
  // sautées sont toutes ≥ DTSTART, donc comptées dans `produced` (COUNT).
  const dayMs = 86400000;
  const lead = Math.max(durMs, 0);
  if (rrule.freq === 'DAILY') {
    const stepMs = rrule.interval * dayMs;
    let k = 0;
    if (Number.isFinite(fromMs) && fromMs - lead > startMs) {
      const n = Math.max(0, Math.floor((fromMs - lead - startMs) / stepMs) - 1);
      k = n * rrule.interval; produced = n;
    }
    for (; iter < ICS_MAX_ITER; k += rrule.interval, iter++) {
      if (push(icsShiftDays(start.comps, k)) === 'stop') break;
    }
    return out;
  }
  // WEEKLY : jours BYDAY (défaut = jour de DTSTART), semaines espacées d'INTERVAL.
  const wkst = rrule.wkst;
  const startWd = icsWeekday(start.comps);
  const rel = (d) => (d - wkst + 7) % 7;
  let days = (rrule.byday && rrule.byday.length) ? rrule.byday.map(rel) : [rel(startWd)];
  days = days.filter((d, i, a) => a.indexOf(d) === i).sort((a, b) => a - b);
  const week0 = icsShiftDays(start.comps, -rel(startWd));
  // DTSTART est toujours la 1re instance (RFC 5545), même hors BYDAY.
  if (days.indexOf(rel(startWd)) < 0 && push(start.comps) === 'stop') return out;
  let w0 = 0;
  const week0Ms = icsCompsToMs(week0, start.mode);
  if (Number.isFinite(fromMs) && fromMs - lead > week0Ms) {
    const n = Math.max(0, Math.floor((fromMs - lead - week0Ms) / (rrule.interval * 7 * dayMs)) - 1);
    if (n > 0) {
      w0 = n * rrule.interval;
      // Semaine 0 : seules les instances ≥ DTSTART comptent (les autres = 'skip').
      produced += n * days.length - days.filter(d => d < rel(startWd)).length;
    }
  }
  let stop = false;
  for (let w = w0; !stop && iter < ICS_MAX_ITER; w += rrule.interval) {
    for (let j = 0; j < days.length; j++, iter++) {
      const r = push(icsShiftDays(week0, w * 7 + days[j]));
      if (r === 'stop') { stop = true; break; }
    }
  }
  return out;
}

// Parseur ICS défensif : texte iCalendar → événements dans [fromMs, toMs].
// Retourne toujours un tableau (un ICS corrompu → []). Chaque événement :
// { id, uid, ts, endTs, title, allDay, location, external:true, partialRecurrence? }
function parseIcs(text, fromMs, toMs) {
  try {
    if (typeof text !== 'string' || !text) return [];
    const from = Number.isFinite(fromMs) ? fromMs : -Infinity;
    const to = Number.isFinite(toMs) ? toMs : Infinity;
    const lines = icsUnfold(text.replace(/^\uFEFF/, '')).split('\n');
    // Passe 1 : collecte des VEVENT (propriétés brutes)
    const events = [];
    let cur = null, depth = 0;
    for (let i = 0; i < lines.length; i++) {
      const line = lines[i];
      if (!line) continue;
      const p = icsParseLine(line);
      if (!p) continue;
      if (p.name === 'BEGIN') {
        const v = p.value.trim().toUpperCase();
        if (v === 'VEVENT' && !cur) { cur = { props: [] }; depth = 0; }
        else if (cur) depth++; // VALARM etc. imbriqué : ignoré
        continue;
      }
      if (p.name === 'END') {
        const v = p.value.trim().toUpperCase();
        if (cur && depth > 0) { depth--; continue; }
        if (cur && v === 'VEVENT') { events.push(cur); cur = null; }
        continue;
      }
      if (cur && depth === 0) cur.props.push(p);
    }
    // Passe 2 : normalisation + index des instances modifiées (RECURRENCE-ID)
    const parsed = [];
    const overridesByUid = Object.create(null); // sans prototype : UID « constructor » / « __proto__ » inoffensifs
    events.forEach(ev => {
      try {
        const get = (n) => ev.props.find(x => x.name === n) || null;
        const all = (n) => ev.props.filter(x => x.name === n);
        const uidP = get('UID');
        const uid = uidP ? uidP.value.trim() : ('noid-' + parsed.length);
        // RECURRENCE-ID lu AVANT le test d'annulation : une occurrence annulée
        // sous forme de VEVENT dérogatoire (STATUS:CANCELLED + RECURRENCE-ID,
        // exports Exchange/CalDAV) doit masquer l'instance du maître.
        const rid = get('RECURRENCE-ID');
        let recId = null;
        if (rid) {
          const r = icsParseDate(rid.value, rid.params);
          if (r) { recId = icsCompsToMs(r.comps, r.mode); (overridesByUid[uid] = overridesByUid[uid] || []).push(recId); }
        }
        const st = get('STATUS');
        if (st && /CANCELLED/i.test(st.value)) return;
        const ds = get('DTSTART');
        if (!ds) return;
        const start = icsParseDate(ds.value, ds.params);
        if (!start) return;
        const de = get('DTEND');
        let durMs = null;
        if (de) {
          const end = icsParseDate(de.value, de.params);
          if (end) durMs = icsCompsToMs(end.comps, end.mode) - icsCompsToMs(start.comps, start.mode);
        }
        if (durMs == null) {
          const du = get('DURATION');
          const d = du ? icsParseDuration(du.value) : null;
          durMs = d != null ? d : (start.allDay ? 86400000 : 0);
        }
        if (!Number.isFinite(durMs) || durMs < 0) durMs = start.allDay ? 86400000 : 0;
        // Durée bornée : DTEND lointain / DURATION démesurée désactivaient l'avance
        // rapide de icsOccurrences (gel de l'onglet) et produisaient des NaN.
        if (durMs > ICS_MAX_DUR_MS) durMs = ICS_MAX_DUR_MS;
        const rr = get('RRULE');
        const rrule = rr ? icsParseRrule(rr.value) : null;
        const exdates = [];
        all('EXDATE').forEach(x => {
          String(x.value).split(',').forEach(v => {
            const d = icsParseDate(v, x.params);
            if (d) exdates.push(icsCompsToMs(d.comps, d.mode));
          });
        });
        const sm = get('SUMMARY'), lo = get('LOCATION');
        parsed.push({
          uid, start, durMs, rrule, recId, exdates,
          hasRdate: !!get('RDATE'),
          title: icsUnescape(sm ? sm.value : '').trim().slice(0, ICS_MAX_TEXT) || '(Sans titre)',
          location: icsUnescape(lo ? lo.value : '').trim().slice(0, ICS_MAX_TEXT),
        });
      } catch (e) { /* VEVENT illisible : ignoré, le reste continue */ }
    });
    // Passe 3 : expansion dans la fenêtre
    const out = [];
    parsed.forEach(ev => {
      try {
        if (out.length >= ICS_MAX_OUT) return;
        const partial = !!(ev.rrule && (!ev.rrule.simple || ev.hasRdate));
        const occ = icsOccurrences(ev.start, ev.rrule, ev.durMs, from, to);
        const skip = ev.recId == null ? (overridesByUid[ev.uid] || []) : [];
        occ.forEach(c => {
          if (out.length >= ICS_MAX_OUT) return;
          const ts = icsCompsToMs(c, ev.start.mode);
          if (ev.exdates.indexOf(ts) >= 0 || skip.indexOf(ts) >= 0) return;
          const endTs = ts + ev.durMs;
          if (ts > to || endTs < from) return;
          const item = {
            id: 'ics:' + ev.uid + ':' + ts, uid: ev.uid, ts, endTs,
            title: ev.title, allDay: ev.start.allDay, location: ev.location, external: true,
          };
          if (partial) item.partialRecurrence = true;
          out.push(item);
        });
      } catch (e) { /* expansion illisible : événement ignoré */ }
    });
    out.sort((a, b) => a.ts - b.ts || (a.title < b.title ? -1 : 1));
    return out;
  } catch (e) {
    return [];
  }
}
// ── ICS-PARSER-END ───────────────────────────────────────────────────

// ── Helpers de dates (jour civil YYYY-MM-DD, semaine lundi → dimanche) ──
// Libellés : formateurs communs de format-prefs.jsx (charte §1 et §4), jamais
// de tableau local de jours ou de mois. Les instants (événements externes) sont
// ramenés au jour et à l'heure du fuseau du profil.

// Étiquettes d'événement : la valeur enregistrée reste stable (données
// existantes), seul le libellé affiché suit le glossaire (Actualités ← News).
const AGENDA_TAGS = [
  { value: 'Trading', label: 'Trading' },
  { value: 'News',    label: 'Actualités' },
  { value: 'Perso',   label: 'Perso' },
];
function agendaTagLabel(tag) {
  const t = AGENDA_TAGS.find(x => x.value === tag);
  return t ? t.label : (tag || '');
}

function agendaISO(d) {
  return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
}
function agendaParts(iso) {
  const p = String(iso || '').slice(0, 10).split('-').map(Number);
  return { y: p[0], m: p[1], d: p[2] };
}
function agendaAddDays(iso, n) {
  const { y, m, d } = agendaParts(iso);
  return agendaISO(new Date(y, m - 1, d + n));
}
function agendaWeekStart(iso) {
  const { y, m, d } = agendaParts(iso);
  const wd = (new Date(y, m - 1, d).getDay() + 6) % 7; // 0 = lundi
  return agendaAddDays(iso, -wd);
}
function agendaWeekdayIndex(iso) {
  const { y, m, d } = agendaParts(iso);
  return (new Date(y, m - 1, d).getDay() + 6) % 7;
}
// « 21–27 sept. », « 28 sept. – 4 oct. » (année ajoutée hors année en cours).
function agendaWeekLabel(weekStart) {
  return fmtWeekRange(weekStart);
}
// « Dimanche 27 septembre » (majuscule en début de ligne).
function agendaDayLabel(iso) {
  const s = fmtDayLong(iso);
  return s.charAt(0).toUpperCase() + s.slice(1);
}
function agendaMsToISO(ms) { return localDayInTz(ms); }
function agendaMsToHM(ms) { return tzTimeIn(ms); }

// Événements externes (parseIcs) → lignes par jour ISO local. Un événement
// journée entière sur plusieurs jours est répété sur chacun (DTEND exclusif).
// Un événement HORAIRE à cheval sur minuit / plusieurs jours est aussi répété
// sur chaque jour couvert, avec span = 'start' (« 22:00 → »), 'mid'
// (« Journée ») ou 'end' (« → 01:00 ») et spanLabel (plage complète, tooltip).
function agendaSpanDayLabel(ms) {
  return fmtDayShort(agendaMsToISO(ms));
}
function icsEventsToRows(events) {
  const rows = [];
  (events || []).forEach(ev => {
    if (rows.length >= ICS_MAX_ROWS) return;
    const base = {
      id: ev.id, e: ev.title, tag: 'Externe', done: false, external: true,
      allDay: !!ev.allDay, location: ev.location || '', partialRecurrence: !!ev.partialRecurrence,
      h: ev.allDay ? '' : agendaMsToHM(ev.ts), hEnd: (ev.allDay || ev.endTs <= ev.ts) ? '' : agendaMsToHM(ev.endTs),
    };
    const first = agendaMsToISO(ev.ts);
    if (ev.allDay) {
      const nDays = Math.min(31, Math.max(1, Math.round((ev.endTs - ev.ts) / 86400000)));
      for (let k = 0; k < nDays; k++) rows.push({ ...base, id: base.id + ':' + k, date: agendaAddDays(first, k) });
      return;
    }
    // Fin à minuit pile = fin du jour précédent (pas de ligne « → 00:00 » le lendemain).
    const last = ev.endTs > ev.ts ? agendaMsToISO(ev.endTs - 1) : first;
    if (last <= first) { rows.push({ ...base, date: first }); return; }
    const spanLabel = 'Du ' + agendaSpanDayLabel(ev.ts) + ' ' + base.h + ' au ' + agendaSpanDayLabel(ev.endTs) + ' ' + base.hEnd;
    for (let k = 0, d = first; k < 31 && d <= last; k++, d = agendaAddDays(d, 1)) {
      const span = d === first ? 'start' : (d === last ? 'end' : 'mid');
      rows.push({
        ...base, id: base.id + ':' + k, date: d, span, spanLabel,
        h: span === 'start' ? base.h : '', hEnd: span === 'end' ? base.hEnd : '',
      });
    }
  });
  return rows;
}

// URL de calendrier : webcal://, webcals:// et http:// → https://, allowlist d'hôtes
// identique à celle du worker (retour immédiat à l'utilisateur, le worker
// reste l'autorité). Retourne { url } ou { error } (message FR, sans l'URL).
const AGENDA_ICS_HOSTS = ['calendar.google.com', 'outlook.live.com', 'outlook.office365.com'];
function normalizeIcsUrl(raw) {
  let s = String(raw || '').trim();
  if (!s) return { error: 'Colle l’adresse de ton calendrier.' };
  s = s.replace(/^(webcals?|http):\/\//i, 'https://');
  if (!/^https:\/\//i.test(s)) s = 'https://' + s;
  if (s.length > 2048) return { error: 'Adresse trop longue.' };
  let u;
  try { u = new URL(s); } catch (e) { return { error: 'Adresse invalide.' }; }
  if (u.username || u.password) return { error: 'Adresse refusée : elle contient un identifiant.' };
  if (u.port && u.port !== '443') return { error: 'Adresse refusée : port non standard.' };
  const host = u.hostname.toLowerCase().replace(/\.$/, '');
  const ok = AGENDA_ICS_HOSTS.indexOf(host) >= 0 || host === 'icloud.com' || /\.icloud\.com$/.test(host);
  if (!ok) return { error: 'Calendrier non pris en charge : Google Agenda, Apple iCloud ou Outlook uniquement.' };
  return { url: u.toString(), host };
}
function icsHostOf(url) {
  try { return new URL(url).hostname; } catch (e) { return '?'; }
}

// Réponse en erreur du relais → fin de phrase du bandeau « Calendrier externe
// indisponible — … » (charte §7 : jamais le texte technique du serveur à l'écran).
function agendaIcsErrorText(status, detail) {
  if (status === 429) return 'trop de demandes, réessaie dans quelques minutes';
  if (status === 413) return 'calendrier trop volumineux';
  if (status === 400) return 'adresse refusée, vérifie-la';
  if (/HTTP (401|403|404|410)\b/.test(detail || '')) return 'son adresse ne répond plus, reconnecte-le';
  if (/pas un calendrier/i.test(detail || '')) return 'son adresse ne renvoie pas un calendrier, vérifie-la';
  return '';
}

// ── Hooks agenda ─────────────────────────────────────────────────────
const AGENDA_ICS_URL_KEY = 'tempo:agenda:icsUrl';
const AGENDA_ICS_TIMEOUT_MS = 8000;
const AGENDA_ICS_MEM_TTL_MS = 10 * 60 * 1000;
const AGENDA_PROXY_DEFAULT = 'https://tempo-econ-proxy.guillaume-briere92.workers.dev';
// Même résolution que getEconProxyURL (econ.jsx, non exposé sur window) :
// la surcharge (localStorage 'tempo:econProxy' ou window.TEMPO_ECON_PROXY) n'est
// honorée QU'EN développement (localhost / 127.0.0.1) et QU'EN https://. En
// production elle est ignorée : cette base reçoit l'URL privée du calendrier
// ICS — un script injecté ne doit pas pouvoir la rediriger durablement.
function agendaIsDevHost() {
  try {
    return typeof location !== 'undefined' && /^(localhost|127\.0\.0\.1)$/.test(location.hostname);
  } catch (e) { return false; }
}
function agendaProxyBase() {
  if (agendaIsDevHost()) {
    try {
      const stored = localStorage.getItem('tempo:econProxy');
      if (stored && /^https:\/\//i.test(stored)) return stored.replace(/\/$/, '');
    } catch (e) {}
    if (typeof window.TEMPO_ECON_PROXY === 'string' && /^https:\/\//i.test(window.TEMPO_ECON_PROXY)) return window.TEMPO_ECON_PROXY.replace(/\/$/, '');
  }
  return AGENDA_PROXY_DEFAULT;
}
function readIcsUrl() {
  try { return localStorage.getItem(AGENDA_ICS_URL_KEY) || ''; } catch (e) { return ''; }
}
// Cache mémoire du texte ICS (10 min) : la navigation entre semaines ne
// refait pas de requête, seul « Actualiser » force un nouveau fetch.
const icsMemCache = { url: '', text: '', at: 0 };

function useIcsCalendar(weekStart) {
  const [url, setUrlState] = React.useState(readIcsUrl);
  const [text, setText] = React.useState(() => (url && icsMemCache.url === url) ? icsMemCache.text : '');
  const [status, setStatus] = React.useState(url ? 'loading' : 'idle'); // idle | loading | ok | error
  const [errorMsg, setErrorMsg] = React.useState('');
  const [reloadTick, setReloadTick] = React.useState(0);
  const forceRef = React.useRef(false);

  React.useEffect(() => {
    if (!url) { setStatus('idle'); setText(''); setErrorMsg(''); return; }
    let live = true;
    // Crochet de test (texte ICS injecté) : honoré uniquement en développement local.
    const fixture = agendaIsDevHost() ? window.TEMPO_ICS_FIXTURE : undefined;
    if (typeof fixture === 'string' && fixture) { setText(fixture); setStatus('ok'); setErrorMsg(''); return; }
    const force = forceRef.current; forceRef.current = false;
    if (!force && icsMemCache.url === url && icsMemCache.text && Date.now() - icsMemCache.at < AGENDA_ICS_MEM_TTL_MS) {
      setText(icsMemCache.text); setStatus('ok'); setErrorMsg(''); return;
    }
    setStatus('loading'); setErrorMsg('');
    const ctrl = new AbortController();
    const timer = setTimeout(() => ctrl.abort(), AGENDA_ICS_TIMEOUT_MS);
    // Secret dans l'en-tête (jamais dans l'URL de requête : journaux Cloudflare).
    fetch(agendaProxyBase() + '/ics', { signal: ctrl.signal, cache: 'no-store', headers: { 'x-tempo-ics-url': url } })
      .then(async res => {
        if (!res.ok) {
          let detail = '';
          try { const j = await res.json(); if (j && j.error) detail = String(j.error); } catch (e) {}
          const err = new Error(agendaIcsErrorText(res.status, detail));
          err.icsDetail = 'réponse ' + res.status + (detail ? ' — ' + detail : '');
          throw err;
        }
        return res.text();
      })
      .then(t => {
        if (!live) return;
        if (!/BEGIN:VCALENDAR/i.test(t)) { setText(''); setStatus('error'); setErrorMsg('Calendrier externe illisible'); return; }
        icsMemCache.url = url; icsMemCache.text = t; icsMemCache.at = Date.now();
        setText(t); setStatus('ok');
      })
      .catch(err => {
        if (!live) return;
        // TypeError = échec réseau du fetch (hors-ligne, CORS, route /ics absente…) :
        // le message navigateur (« Failed to fetch ») est anglais et peu parlant.
        const msg = (err && err.name === 'AbortError') ? 'délai dépassé'
          : (err instanceof TypeError) ? 'réseau injoignable'
          : (err && typeof err.icsDetail === 'string') ? err.message
          : '';
        // Jamais l'URL complète dans les logs (elle contient un secret) : host seul.
        console.warn('[Tempo] calendrier externe indisponible — host:', icsHostOf(url), '—', (err && err.icsDetail) || msg || (err && err.message) || 'erreur');
        setStatus('error'); setErrorMsg(msg);
      })
      .finally(() => clearTimeout(timer));
    return () => { live = false; ctrl.abort(); clearTimeout(timer); };
  }, [url, reloadTick]);

  // Fenêtre de développement des récurrences : ±4 semaines autour de la semaine affichée.
  const events = React.useMemo(() => {
    if (!text) return [];
    const p = agendaParts(weekStart);
    const fromMs = new Date(p.y, p.m - 1, p.d - 28).getTime();
    const toMs = new Date(p.y, p.m - 1, p.d + 7 + 28).getTime();
    return parseIcs(text, fromMs, toMs);
  }, [text, weekStart]);

  const rows = React.useMemo(() => icsEventsToRows(events), [events]);

  const connect = (raw) => {
    const n = normalizeIcsUrl(raw);
    if (n.error) return n;
    try { localStorage.setItem(AGENDA_ICS_URL_KEY, n.url); } catch (e) {}
    forceRef.current = true;
    setUrlState(n.url);
    return { ok: true };
  };
  const remove = () => {
    try { localStorage.removeItem(AGENDA_ICS_URL_KEY); } catch (e) {}
    icsMemCache.url = ''; icsMemCache.text = ''; icsMemCache.at = 0;
    setUrlState(''); setText('');
  };
  const refresh = () => { forceRef.current = true; setReloadTick(t => t + 1); };

  return { url, host: url ? icsHostOf(url) : '', status, errorMsg, rows, connect, remove, refresh };
}

// Événements Tempo (agenda_events) de la semaine [weekStart, weekStart+6].
// Recharge quand state.agendaTick change (mutation réussie ailleurs).
// Rétrocompatible : sans state.loadAgendaRange (ancien hook), repli sur les
// lignes du jour (state.agenda) — planification jour-même uniquement.
// Lecture en échec (loadAgendaRange lève l'erreur) : `error` passe à vrai, les
// lignes déjà chargées restent, `retry` relance la lecture (charte §7 : un
// échec ne ressemble jamais à une semaine vide).
// `unread` : la semaine demandée n'a encore jamais été lue (vrai dès le tout
// premier rendu et dès le rendu qui suit un changement de semaine, avant même
// que l'effet ne lance la lecture) — l'écran dit alors « Chargement… », jamais
// « 0 événement » (charte §7). Une relecture de la même semaine après une
// écriture ne repasse pas par là : ses lignes et ses compteurs restent affichés.
function useAgendaWeek(state, weekStart) {
  const st = state || {};
  const today = st.today || todayISO();
  const [rows, setRows] = React.useState([]);
  const [loading, setLoading] = React.useState(false);
  const [error, setError] = React.useState(false);
  const [retryTick, setRetryTick] = React.useState(0);
  const canRange = typeof st.loadAgendaRange === 'function';
  const uid = st.user && st.user.id;
  // Semaine (et compte) dont `rows` contient la dernière lecture réussie.
  const [loadedKey, setLoadedKey] = React.useState(null);
  const weekKey = weekStart + '|' + (uid || '');

  React.useEffect(() => {
    let live = true;
    if (!canRange) {
      console.warn('[Tempo] agenda semaine : loadAgendaRange absent — repli sur le jour courant');
      setRows((st.agenda || []).map(e => (e._row || { id: e.id, date: today, time_at: e.h, title: e.e, tag: e.tag, is_done: e.done })));
      setError(false);
      return;
    }
    setLoading(true);
    st.loadAgendaRange(weekStart, agendaAddDays(weekStart, 6)).then(r => {
      if (!live) return;
      setRows(Array.isArray(r) ? r : []); setLoadedKey(weekKey); setLoading(false); setError(false);
    }).catch(() => { if (live) { setLoading(false); setError(true); } });
    return () => { live = false; };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [weekStart, st.agendaTick, uid, canRange, canRange ? null : st.agenda, retryTick]);

  const events = React.useMemo(() => rows.map(r => ({
    id: r.id, date: String(r.date || today).slice(0, 10),
    h: (r.time_at || '00:00').slice(0, 5), e: r.title, tag: r.tag || '', done: !!r.is_done, external: false,
  })), [rows, today]);

  const unread = canRange && !error && loadedKey !== weekKey;
  return {
    events, loading: canRange && !error && (loading || unread), unread, error,
    // « Réessayer » : relit la semaine, et l'agenda du jour s'il était lui aussi en échec.
    retry: () => {
      setRetryTick(t => t + 1);
      if (st.agendaError && typeof st.reloadAgenda === 'function') st.reloadAgenda();
    },
    addRow: (row) => { if (row && row.id) setRows(prev => prev.some(r => r.id === row.id) ? prev : [...prev, row]); },
    removeRow: (id) => setRows(prev => prev.filter(r => r.id !== id)),
    toggleRow: (id) => setRows(prev => prev.map(r => r.id === id ? { ...r, is_done: !r.is_done } : r)),
  };
}

// ── Composants ───────────────────────────────────────────────────────
function AgendaExternalBadge({ partial }) {
  return (
    <span title={partial ? 'Événement externe (lecture seule). Récurrence partielle : la règle (mensuelle, annuelle…) n’est pas développée, seule la première occurrence est affichée.' : 'Événement de ton calendrier externe (lecture seule)'} style={{
      fontSize: 10, fontWeight: 600, letterSpacing: '.02em', color: 'var(--amber-text)', background: 'var(--amber-soft)',
      border: '1px solid var(--amber-border)', borderRadius: 4, padding: '1px 6px', whiteSpace: 'nowrap', flexShrink: 0,
    }}>Externe{partial ? ' · partiel' : ''}</span>
  );
}

// Ligne d'événement (Tempo : case à cocher + suppression ; externe : badge, lecture seule).
// Mobile : zones tactiles de 44 px (charte §8, taille tactile)
// sans changer le dessin — le bouton est la zone tactile (transparente, marges
// négatives pour ne pas déplacer ses voisins) et la case de 18 px ou la croix
// est dessinée au centre. La classe .habit-hit n'est pas reprise : elle désigne
// les cases d'habitude. La ligne fait au moins 44 px, pour que les zones de
// deux lignes voisines ne se chevauchent pas.
function AgendaEventRow({ ev, mobile, onToggle, onRemove }) {
  // Externe multi-jours (span) : « 22:00 → » le 1er jour, « Journée » entre, « → 01:00 » le dernier.
  const timeLabel = !ev.external ? ev.h
    : ev.allDay ? 'Journée'
    : ev.span === 'start' ? ev.h + ' →'
    : ev.span === 'mid' ? 'Journée'
    : ev.span === 'end' ? '→ ' + ev.hEnd
    : ev.h + (ev.hEnd ? '–' + ev.hEnd : '');
  const tip = ev.external ? [ev.e, ev.location ? 'Lieu : ' + ev.location : '', ev.allDay ? 'Journée entière' : (ev.spanLabel || (ev.h + (ev.hEnd ? ' – ' + ev.hEnd : ''))), ev.partialRecurrence ? 'Récurrence partielle : première occurrence seulement' : ''].filter(Boolean).join('\n') : undefined;
  const checkLabel = (ev.e || 'Événement') + (ev.h ? ' à ' + ev.h : '');
  const checkTitle = ev.done ? 'Marquer comme à faire' : 'Marquer comme fait';
  // Dessin de la case : porté par le bouton sur ordinateur, par un <span> au
  // centre de la zone tactile sur mobile.
  const boxStyle = {
    borderRadius: 5, boxSizing: 'border-box',
    // Contour de la case vide : jeton de contour de champ du socle (--field-border,
    // index.html, 3:1 au moins ; charte §8), --line-2 en simple repli. Même écriture
    // que la case d'habitude du jour (habitMarkLook dans state.jsx) pour que les
    // deux cases de la page restent identiques.
    border: '1.5px solid ' + (ev.done ? 'var(--blue)' : 'var(--field-border, var(--line-2))'),
    background: ev.done ? 'var(--blue)' : 'var(--bg-card)',
    display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff',
  };
  const checkbox = ev.external ? (
    <span title="Lecture seule" style={{ width: mobile ? 18 : 16, height: mobile ? 18 : 16, flexShrink: 0, marginTop: mobile ? 1 : 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
      <span style={{ width: 8, height: 8, borderRadius: '50%', background: 'var(--amber)' }}/>
    </span>
  ) : mobile ? (
    <button type="button" role="checkbox" aria-checked={ev.done ? 'true' : 'false'}
      aria-label={checkLabel} title={checkTitle} onClick={onToggle} className="tap" style={{
      width: 44, height: 44, margin: '-12px -13px -12px', padding: 0, flexShrink: 0, boxSizing: 'border-box', fontFamily: 'inherit',
      border: 'none', background: 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
    }}><span aria-hidden="true" style={{ ...boxStyle, width: 18, height: 18 }}>{ev.done && Ico.check}</span></button>
  ) : (
    <button type="button" role="checkbox" aria-checked={ev.done ? 'true' : 'false'}
      aria-label={checkLabel} title={checkTitle} onClick={onToggle} className="tap" style={{
      ...boxStyle, width: 16, height: 16, flexShrink: 0, padding: 0, fontFamily: 'inherit', cursor: 'pointer',
    }}>{ev.done && Ico.check}</button>
  );
  const titleStyle = { fontSize: 13, color: ev.done ? 'var(--fg-3)' : 'var(--fg)', textDecoration: ev.done ? 'line-through' : 'none', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
  const removeBtn = ev.external ? <span style={{ width: mobile ? 26 : 22, flexShrink: 0 }}/> : (
    <button type="button" onClick={onRemove} data-agenda-remove={ev.id} className="tap" title="Supprimer" aria-label={'Supprimer « ' + ev.e + ' »'} style={{
      width: mobile ? 44 : 22, height: mobile ? 44 : 22, margin: mobile ? -9 : 0, padding: 0, borderRadius: 6, border: 'none', flexShrink: 0,
      background: 'transparent', cursor: 'pointer', color: 'var(--fg-3)',
      display: 'flex', alignItems: 'center', justifyContent: 'center',
    }}>{Ico.x}</button>
  );
  if (mobile) {
    return (
      <div title={tip} style={{ display: 'flex', alignItems: 'flex-start', gap: 10, padding: '8px 0', minHeight: 44, boxSizing: 'border-box', borderTop: '1px solid var(--line)' }}>
        {checkbox}
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
            <span className="mono" style={{ fontSize: 11.5, color: 'var(--fg-2)', fontWeight: 500, flexShrink: 0 }}>{timeLabel}</span>
            <span style={titleStyle}>{ev.e}</span>
          </div>
          <div style={{ display: 'flex', gap: 6, marginTop: 3, alignItems: 'center', flexWrap: 'wrap' }}>
            {ev.external ? <AgendaExternalBadge partial={ev.partialRecurrence}/> : (ev.tag && (
              <span style={{ display: 'inline-block', fontSize: 10.5, color: 'var(--fg-2)', fontWeight: 500, padding: '2px 8px', borderRadius: 4, background: 'var(--bg-elev)' }}>{agendaTagLabel(ev.tag)}</span>
            ))}
            {ev.external && ev.location && <span style={{ fontSize: 10.5, color: 'var(--fg-3)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 180 }}>{ev.location}</span>}
          </div>
        </div>
        {removeBtn}
      </div>
    );
  }
  return (
    <div title={tip} style={{
      display: 'grid', gridTemplateColumns: '20px 72px minmax(0,1fr) auto 22px',
      alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8, transition: 'background .15s var(--ease)',
    }} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-soft)'}
       onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
      {checkbox}
      <span className="mono" style={{ fontSize: 11.5, color: 'var(--fg-2)', fontWeight: 500, whiteSpace: 'nowrap' }}>{timeLabel}</span>
      <span style={titleStyle}>{ev.e}</span>
      {ev.external ? <AgendaExternalBadge partial={ev.partialRecurrence}/> : (
        <span style={{ fontSize: 10.5, color: 'var(--fg-2)', fontWeight: 500, padding: '2px 8px', borderRadius: 4, background: ev.tag ? 'var(--bg-elev)' : 'transparent', textAlign: 'center', minWidth: 60 }}>{agendaTagLabel(ev.tag)}</span>
      )}
      {removeBtn}
    </div>
  );
}

// Bandeau 7 jours (lun → dim) : jour courant surligné, compteurs d'événements.
// `countsKnown` : la semaine est lue ; sinon le nom d'un jour n'annonce aucun
// nombre (un chargement ou un échec ne se lit jamais « aucun événement »).
function AgendaWeekStrip({ weekStart, selected, today, counts, countsKnown = true, onSelect, mobile }) {
  return (
    <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0,1fr))', gap: mobile ? 4 : 6 }}>
      {FR_WEEKDAYS_SHORT.map((lbl, i) => {
        const iso = agendaAddDays(weekStart, i);
        const isSel = iso === selected, isToday = iso === today;
        const c = counts[iso] || { tempo: 0, ext: 0 };
        const total = c.tempo + c.ext;
        return (
          <button key={iso} type="button" onClick={() => onSelect(iso)} className="tap" title={agendaDayLabel(iso)}
            data-agenda-day={iso} aria-pressed={isSel ? 'true' : 'false'} aria-current={isToday ? 'date' : undefined}
            aria-label={agendaDayLabel(iso) + (isToday ? ', aujourd’hui' : '')
              + (countsKnown ? ', ' + (total ? total + ' événement' + (total > 1 ? 's' : '') : 'aucun événement') : '')}
            style={{
            display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, padding: mobile ? '6px 0 5px' : '7px 0 6px',
            borderRadius: 9, cursor: 'pointer', fontFamily: 'inherit',
            border: '1.5px solid ' + (isSel ? 'var(--blue)' : (isToday ? 'var(--blue-border)' : 'var(--line)')),
            background: isSel ? 'var(--blue)' : (isToday ? 'var(--blue-soft)' : 'var(--bg-card)'),
            color: isSel ? '#fff' : 'var(--fg)',
          }}>
            <span style={{ fontSize: 10, fontWeight: 600, letterSpacing: '.06em', textTransform: 'uppercase', color: isSel ? '#fff' : 'var(--fg-3)' }}>{lbl}</span>
            <span className="mono" style={{ fontSize: mobile ? 14 : 15, fontWeight: 700, lineHeight: 1.1 }}>{agendaParts(iso).d}</span>
            <span aria-hidden={total === 0 ? 'true' : undefined} style={{
              minWidth: 16, height: 14, borderRadius: 7, fontSize: 9.5, fontWeight: 700, lineHeight: '14px', padding: '0 4px',
              // Jour sélectionné : pastille blanche, chiffre en --blue (5,2:1 dans les
              // deux thèmes ; --blue-600 s'éclaircit en sombre et ne tiendrait pas sur blanc).
              background: total === 0 ? 'transparent' : (isSel ? '#fff' : (c.tempo > 0 ? 'var(--blue-soft)' : 'var(--amber-soft)')),
              color: total === 0 ? 'transparent' : (isSel ? 'var(--blue)' : (c.tempo > 0 ? 'var(--blue-600)' : 'var(--amber-text)')),
            }}>{total || '·'}</span>
          </button>
        );
      })}
    </div>
  );
}

// Configuration du calendrier externe (URL privée Google / Apple / Outlook).
// Focus clavier : chaque action remplace l'élément qu'on vient d'actionner
// (« Connecter un calendrier externe » laisse la place au formulaire, le
// formulaire à la vue « Connecté », « Retirer » au formulaire) ; sans reprise,
// le focus retomberait sur la page. Il est donc rendu, après une action de
// l'utilisateur seulement : au champ d'adresse à l'ouverture (`focusOnOpen`)
// et après « Retirer », au bouton « Actualiser » après une connexion réussie.
// Jamais au chargement de la page avec une adresse déjà enregistrée.
// `onRemove` : prévient la carte qu'un retrait a lieu, pour qu'elle garde le
// bloc ouvert (formulaire d'adresse) au lieu de le replier.
function AgendaIcsConfig({ ics, mobile, focusOnOpen, onRemove }) {
  const [draft, setDraft] = React.useState('');
  const [err, setErr] = React.useState('');
  const uid = React.useRef('ics-' + Math.random().toString(36).slice(2, 8)).current;
  const urlRef = React.useRef(null);
  const refreshRef = React.useRef(null);
  const focusNext = React.useRef(focusOnOpen ? 'url' : ''); // '' | 'url' | 'refresh'
  React.useEffect(() => {
    const want = focusNext.current;
    if (!want) return;
    const el = want === 'refresh' ? refreshRef.current : urlRef.current;
    // L'élément attendu n'est pas encore à l'écran : l'effet se rejoue au
    // prochain changement d'adresse.
    if (!el) return;
    focusNext.current = '';
    el.focus();
  }, [ics.url]);
  const submit = (e) => {
    e.preventDefault();
    const r = ics.connect(draft);
    if (r && r.error) { setErr(r.error); return; }
    setErr(''); setDraft('');
    focusNext.current = 'refresh';
  };
  const remove = () => {
    focusNext.current = 'url';
    if (typeof onRemove === 'function') onRemove();
    ics.remove();
  };
  const small = { fontSize: 11.5, color: 'var(--fg-3)', lineHeight: 1.45 };
  const smallBtn = { padding: mobile ? '0 12px' : '4px 9px', minHeight: mobile ? 44 : 0, fontSize: 11.5 };
  return (
    <div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--line)' }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 8 }}>
        <span style={{ fontSize: 12.5, fontWeight: 600 }}>Calendrier externe</span>
        {ics.url && (
          <span style={{ fontSize: 11, fontWeight: 600, color: ics.status === 'error' ? 'var(--amber-text)' : 'var(--green-text)' }}>
            {ics.status === 'loading' ? 'Chargement…' : ics.status === 'error' ? 'Indisponible' : 'Connecté'}
          </span>
        )}
      </div>
      {ics.url ? (
        <div>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
            {/* Mobile : le host prend sa propre ligne (jamais tronqué), les boutons passent dessous. */}
            <span style={{ flex: '1 1 ' + (mobile ? '100%' : '120px'), minWidth: 0, display: 'flex', alignItems: 'baseline', gap: 6, overflow: 'hidden' }}>
              <span className="mono" title={ics.host} style={{ fontSize: 11.5, color: 'var(--fg-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{ics.host}</span>
              <span style={{ fontSize: 11, color: 'var(--fg-3)', whiteSpace: 'nowrap' }}>· lecture seule</span>
            </span>
            <span style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
              <button ref={refreshRef} type="button" onClick={ics.refresh} className="btn tap" style={smallBtn}>{Ico.refresh} Actualiser</button>
              <button type="button" onClick={remove} className="btn tap" style={{ ...smallBtn, color: 'var(--red-text)' }}>Retirer</button>
            </span>
          </div>
          <div style={{ ...small, marginTop: 6 }}>L’adresse n’est enregistrée que sur cet appareil (jamais dans ton compte Tempo) et retirée à la déconnexion. Elle transite par le serveur Tempo uniquement pour récupérer le calendrier, conservé au plus 10 minutes en cache.</div>
        </div>
      ) : (
        <form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
          <div style={{ display: mobile ? 'flex' : 'grid', gridTemplateColumns: '1fr auto', flexDirection: 'column', gap: 8 }}>
            <input ref={urlRef} className="input" value={draft} onChange={e => { setDraft(e.target.value); if (err) setErr(''); }}
              placeholder="https://calendar.google.com/calendar/ical/…/basic.ics ou webcal://p##-caldav.icloud.com/published/…"
              aria-label="Adresse du calendrier (iCal ou webcal)" aria-invalid={err ? 'true' : undefined}
              aria-describedby={(err ? uid + '-err ' : '') + uid + '-help'}
              autoComplete="off" spellCheck={false} style={{ padding: '7px 10px', fontSize: 12, minHeight: mobile ? 44 : undefined }}/>
            <button type="submit" disabled={!draft.trim()} className="btn btn-blue tap" style={{ justifyContent: 'center', padding: '7px 12px', minHeight: mobile ? 44 : undefined, fontSize: 12, opacity: draft.trim() ? 1 : .5 }}>Connecter</button>
          </div>
          {err && <div id={uid + '-err'} role="alert" style={{ fontSize: 12, color: 'var(--red-text)' }}>{err}</div>}
          <div id={uid + '-help'} style={small}>
            <b style={{ color: 'var(--fg-2)' }}>Google Agenda</b> : Paramètres → ton agenda → « Adresse secrète au format iCal ». <b style={{ color: 'var(--fg-2)' }}>Apple Calendrier</b> : clic droit sur l’agenda → Partage → « Calendrier public » → copier l’adresse webcal://.
          </div>
          <div style={small}>Lecture seule : ces événements s’affichent avec le badge « Externe » et ne peuvent pas être modifiés depuis Tempo. L’adresse n’est enregistrée que sur cet appareil (jamais dans ton compte Tempo) et retirée à la déconnexion ; elle transite par le serveur Tempo uniquement pour récupérer le calendrier, conservé au plus 10 minutes en cache.</div>
        </form>
      )}
    </div>
  );
}

// Carte « Agenda » (vue semaine) partagée desktop / mobile.
function AgendaWeekCard({ state, mobile }) {
  const st = state || {};
  const today = st.today || todayISO();
  const [weekStart, setWeekStart] = React.useState(() => agendaWeekStart(today));
  const [selected, setSelected] = React.useState(today);
  const week = useAgendaWeek(st, weekStart);
  const ics = useIcsCalendar(weekStart);
  const [form, setForm] = React.useState({ date: today, h: '14:30', e: '', tag: '' });
  const [errorMsg, setErrorMsg] = React.useState('');
  const [showIcs, setShowIcs] = React.useState(false);
  // Vrai quand le bloc « Calendrier externe » vient d'être ouvert par son bouton
  // (le focus va alors au champ d'adresse) ; faux au chargement de la page.
  const icsOpenedByUser = React.useRef(false);
  // §8.6 — ajout : une seule écriture à la fois ; suppression : confirmation en ligne.
  const [adding, setAdding] = React.useState(false);
  const addGuard = React.useRef(false);
  const [askDelId, setAskDelId] = React.useState(null);
  const [deleting, setDeleting] = React.useState(false);
  const [delErr, setDelErr] = React.useState('');
  const delGuard = React.useRef(false);
  const alive = useRoutinesAlive();

  // Changement de jour, écran ouvert (spec-habitudes § Changement de jour) : si
  // l'Agenda était sur « aujourd'hui », il suit le nouveau jour (jour
  // sélectionné, semaine, date du formulaire) ; un jour choisi à la main ne
  // bouge pas. Le jour sélectionné est toujours dans la semaine affichée : un
  // seul test suffit donc pour déplacer les deux ensemble (jamais la semaine
  // sans le jour). Une confirmation de suppression ouverte ou une écriture en
  // cours retardent la bascule : l'effet se rejoue quand elles se terminent.
  const prevToday = React.useRef(today);
  const autoShift = React.useRef(null);
  React.useEffect(() => {
    const prev = prevToday.current;
    if (prev === today) return;
    if (askDelId !== null || deleting || adding) return;
    prevToday.current = today;
    if (selected === prev) {
      autoShift.current = prev;
      setSelected(today);
      setWeekStart(agendaWeekStart(today));
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [today, askDelId, deleting, adding]);
  // La date du formulaire suit le jour sélectionné. Lors d'une bascule
  // automatique, elle n'est remplacée que si elle valait la veille : une date
  // choisie à la main pour un autre jour est conservée (le titre l'est toujours).
  React.useEffect(() => {
    const from = autoShift.current;
    autoShift.current = null;
    setForm(f => ((from && f.date !== from) ? f : { ...f, date: selected }));
  }, [selected]);
  React.useEffect(() => { setAskDelId(null); setDelErr(''); }, [selected, weekStart]);

  const weekEnd = agendaAddDays(weekStart, 6);
  const { byDay, counts, weekTotal, tempoTotal } = React.useMemo(() => {
    const byDay = {}, counts = {};
    let weekTotal = 0, tempoTotal = 0;
    const push = (ev) => {
      if (ev.date < weekStart || ev.date > weekEnd) return;
      (byDay[ev.date] = byDay[ev.date] || []).push(ev);
      const c = counts[ev.date] = counts[ev.date] || { tempo: 0, ext: 0 };
      if (ev.external) c.ext++; else { c.tempo++; tempoTotal++; }
      weekTotal++;
    };
    week.events.forEach(push);
    ics.rows.forEach(push);
    Object.keys(byDay).forEach(k => byDay[k].sort((a, b) => {
      if (!!a.allDay !== !!b.allDay) return a.allDay ? -1 : 1;
      return a.h.localeCompare(b.h) || (a.external === b.external ? 0 : (a.external ? 1 : -1));
    }));
    return { byDay, counts, weekTotal, tempoTotal };
  }, [week.events, ics.rows, weekStart, weekEnd]);

  const dayEvents = byDay[selected] || [];
  const isCurrentWeek = weekStart === agendaWeekStart(today);
  const goWeek = (delta) => { setWeekStart(w => agendaAddDays(w, delta * 7)); setSelected(s => agendaAddDays(s, delta * 7)); };
  const goToday = () => { setWeekStart(agendaWeekStart(today)); setSelected(today); };

  const submit = async () => {
    const title = (form.e || '').trim();
    if (addGuard.current || !title || typeof st.addAgenda !== 'function') return;
    if (!/^\d{4}-\d{2}-\d{2}$/.test(form.date)) { setErrorMsg('Date invalide.'); return; }
    setErrorMsg('');
    addGuard.current = true; setAdding(true);
    let res;
    try { res = await st.addAgenda({ date: form.date, h: form.h || '12:00', e: title, tag: form.tag }); }
    catch (e) { res = { error: e }; }
    addGuard.current = false;
    if (!alive.current) {
      if (res && res.error) routinesNotifyLost(st, 'L’événement ' + routinesQuote(title) + ' n’a pas été ajouté.', res.error);
      return;
    }
    setAdding(false);
    if (res && res.error) {
      console.warn('[Tempo] agenda : ajout impossible', res.error);
      setErrorMsg('Impossible d’ajouter l’événement. ' + sbErrorMessage(res.error));
      return;
    }
    if (res && res.data) week.addRow(res.data);
    const ws = agendaWeekStart(form.date);
    if (ws !== weekStart) setWeekStart(ws);
    setSelected(form.date);
    setForm(f => ({ ...f, e: '', tag: '' }));
  };
  // Bascule « fait » : optimiste ; un échec rétablit la case et l'explique (§9).
  const onToggle = async (ev) => {
    if (typeof st.toggleAgenda !== 'function') return;
    setErrorMsg('');
    week.toggleRow(ev.id);
    let res;
    try { res = await st.toggleAgenda(ev.id, ev.done); } catch (e) { res = { error: e }; }
    if (!(res && res.error)) return;
    if (!alive.current) { routinesNotifyLost(st, 'La coche de l’événement ' + routinesQuote(ev.e) + ' n’a pas été enregistrée.', res.error); return; }
    week.toggleRow(ev.id);
    setErrorMsg('Impossible d’enregistrer «' + ROUTINES_NB + ev.e + ROUTINES_NB + '». ' + sbErrorMessage(res.error));
  };
  // Suppression : la ligne ne quitte la liste qu'après une réponse sans erreur.
  // En cas d'échec, la confirmation reste ouverte avec le message.
  const confirmRemove = async (ev) => {
    if (delGuard.current || typeof st.removeAgenda !== 'function') return;
    delGuard.current = true; setDeleting(true); setDelErr('');
    let res;
    try { res = await st.removeAgenda(ev.id); } catch (e) { res = { error: e }; }
    delGuard.current = false;
    if (!alive.current) {
      if (res && res.error) routinesNotifyLost(st, 'L’événement ' + routinesQuote(ev.e) + ' n’a pas été supprimé.', res.error);
      return;
    }
    setDeleting(false);
    if (res && res.error) { setDelErr(sbErrorMessage(res.error)); return; }
    week.removeRow(ev.id);
    setAskDelId(null);
  };

  const navBtn = (onClick, ico, title) => (
    <button type="button" onClick={onClick} className="tap" title={title} aria-label={title} style={{
      width: mobile ? 44 : 26, height: mobile ? 44 : 26, borderRadius: 7, border: '1px solid var(--line)', background: 'var(--bg-card)',
      color: 'var(--fg-2)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
    }}>{ico}</button>
  );
  const pad = mobile ? '16px 16px 14px' : '22px 24px';
  // Champs du formulaire d'ajout : 44 px de haut au moins sur mobile.
  const fieldStyle = { padding: '7px 10px', fontSize: 12.5, width: '100%', minHeight: mobile ? 44 : undefined };

  return (
    <div className="card" style={{ padding: pad, marginBottom: mobile ? 14 : 0 }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 10, gap: 8 }}>
        <CardTitle>Agenda</CardTitle>
        {/* Semaine pas encore lue et aucun événement Tempo de cette semaine en mémoire :
            « Chargement… », jamais « 0 événement cette semaine » (charte §7). */}
        {!(week.error && weekTotal === 0) && (
          <span style={{ fontSize: 11.5, color: 'var(--fg-2)' }}>
            {week.unread && tempoTotal === 0 ? 'Chargement…' : weekTotal + ' événement' + (weekTotal > 1 ? 's' : '') + ' cette semaine'}
          </span>
        )}
      </div>

      {/* Navigation de semaine. Mobile : les flèches de 44 px laissent moins de place ;
          le libellé de la semaine n'est jamais coupé, c'est « Cette semaine » qui
          passe à la ligne (à droite) quand la largeur manque. */}
      <div style={{ display: 'flex', alignItems: 'center', flexWrap: mobile ? 'wrap' : 'nowrap', gap: 8, marginBottom: 10 }}>
        {navBtn(() => goWeek(-1), Ico.arrL, 'Semaine précédente')}
        <span style={{ flex: 1, textAlign: 'center', fontSize: 12.5, fontWeight: 600, color: 'var(--fg)', letterSpacing: '-.005em', whiteSpace: mobile ? 'nowrap' : undefined }}>{agendaWeekLabel(weekStart)}</span>
        {navBtn(() => goWeek(1), Ico.arrR, 'Semaine suivante')}
        {!isCurrentWeek && (
          <button type="button" onClick={goToday} className="btn tap" style={{ padding: mobile ? '0 10px' : '4px 9px', minHeight: mobile ? 44 : 0, fontSize: 11.5, color: 'var(--blue-600)', whiteSpace: 'nowrap', flexShrink: 0, marginLeft: mobile ? 'auto' : undefined }}>Cette semaine</button>
        )}
      </div>

      <AgendaWeekStrip weekStart={weekStart} selected={selected} today={today} counts={counts}
        countsKnown={!week.unread && !week.error} onSelect={setSelected} mobile={mobile}/>

      {/* Événements Tempo non lus : jamais « 0 événement » ni « Aucun événement ce jour » à la place */}
      {week.error && (
        <div role="alert" data-agenda-error style={{ marginTop: 10, padding: '7px 10px', borderRadius: 8, background: 'var(--red-soft)', border: '1px solid var(--line)', color: 'var(--red-text)', fontSize: 12, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
          <span style={{ flex: 1, minWidth: 0 }}>Impossible de charger ton agenda. Vérifie ta connexion.</span>
          <button type="button" onClick={week.retry} className="btn tap" style={{ padding: mobile ? '0 12px' : '4px 10px', minHeight: mobile ? 44 : 0, fontSize: 12, color: 'var(--fg)' }}>Réessayer</button>
        </div>
      )}
      {ics.status === 'error' && (
        <div role="status" data-agenda-ics-error style={{ marginTop: 10, padding: '7px 10px', borderRadius: 8, background: 'var(--amber-soft)', border: '1px solid var(--amber-border)', color: 'var(--amber-text)', fontSize: 11.5, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
          <span style={{ flex: 1, minWidth: 0 }}>{/^Calendrier externe/.test(ics.errorMsg) ? ics.errorMsg : 'Calendrier externe indisponible' + (ics.errorMsg ? ' — ' + ics.errorMsg : '')}. Tes événements Tempo restent affichés.</span>
          <button type="button" onClick={ics.refresh} className="tap" style={{ background: 'transparent', border: 'none', color: 'inherit', fontWeight: 600, fontSize: 11.5, cursor: 'pointer', fontFamily: 'inherit', textDecoration: 'underline', padding: mobile ? '0 6px' : 0, minHeight: mobile ? 44 : 0, display: 'inline-flex', alignItems: 'center' }}>Réessayer</button>
        </div>
      )}
      {errorMsg && (
        <div role="alert" data-agenda-write-error style={{ marginTop: 10, padding: '7px 10px', borderRadius: 8, background: 'var(--red-soft)', color: 'var(--red-text)', fontSize: 12, border: '1px solid var(--line)' }}>{errorMsg}</div>
      )}

      {/* Jour sélectionné */}
      <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8, margin: '14px 0 4px' }}>
        <span style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--fg)' }}>{agendaDayLabel(selected)}{selected === today ? ' · aujourd’hui' : ''}</span>
        <span style={{ fontSize: 11, color: 'var(--fg-3)' }}>{week.unread ? 'Chargement…' : (week.error && dayEvents.length === 0) ? '' : dayEvents.length + ' événement' + (dayEvents.length > 1 ? 's' : '')}</span>
      </div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? 0 : 2, marginBottom: 12 }}>
        {dayEvents.length === 0 && !week.unread && !week.error && (
          <div style={{ fontSize: 12, color: 'var(--fg-3)', padding: mobile ? '6px 0' : '8px 10px' }}>Aucun événement ce jour — ajoute-en ci-dessous.</div>
        )}
        {dayEvents.map(ev => (askDelId === ev.id && !ev.external) ? (
          <RoutinesConfirm key={ev.id} danger label={'Supprimer « ' + ev.e + ' »'}
            text={'Supprimer « ' + ev.e + ' »\u202F?'}
            confirmLabel="Supprimer" busyLabel="Suppression…" busy={deleting} error={delErr}
            returnFocus={routinesAttr('data-agenda-remove', ev.id)}
            onConfirm={() => confirmRemove(ev)} onCancel={() => { setAskDelId(null); setDelErr(''); }} style={{ margin: '2px 0' }}/>
        ) : (
          <AgendaEventRow key={ev.id} ev={ev} mobile={mobile} onToggle={() => onToggle(ev)} onRemove={() => { setDelErr(''); setAskDelId(ev.id); }}/>
        ))}
      </div>

      {/* Ajout d'événement (date libre, défaut = jour sélectionné) */}
      <form onSubmit={e => { e.preventDefault(); submit(); }} style={{
        padding: mobile ? '12px 12px' : '12px 14px', border: '1.5px dashed var(--line-2)', borderRadius: 10, background: 'var(--bg-soft)',
        display: 'flex', flexDirection: 'column', gap: mobile ? 8 : 10,
      }}>
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
          <input className="input" type="date" aria-label="Date" value={form.date} onChange={e => setForm({ ...form, date: e.target.value })} style={fieldStyle}/>
          <input className="input" type="time" aria-label="Heure" value={form.h} onChange={e => setForm({ ...form, h: e.target.value })} style={fieldStyle}/>
          <select className="input" aria-label="Étiquette" value={form.tag} onChange={e => setForm({ ...form, tag: e.target.value })} style={{ ...fieldStyle, fontFamily: 'inherit', gridColumn: '1 / -1' }}>
            <option value="">Aucune étiquette</option>
            {AGENDA_TAGS.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
          </select>
        </div>
        <input className="input" placeholder="Événement…" aria-label="Titre de l’événement" value={form.e} onChange={e => setForm({ ...form, e: e.target.value })} style={fieldStyle}/>
        <button type="submit" disabled={!form.e.trim() || adding} className="btn btn-blue tap" style={{ justifyContent: 'center', minHeight: mobile ? 44 : undefined, opacity: form.e.trim() && !adding ? 1 : .5 }}>
          {adding ? 'Ajout…' : <React.Fragment>{Ico.plus} Ajouter{form.date === today ? ' à aujourd’hui' : (/^\d{4}-\d{2}-\d{2}$/.test(form.date) ? ' le ' + agendaDayLabel(form.date).toLowerCase() : '')}</React.Fragment>}
        </button>
      </form>

      {/* Calendrier externe : replié quand rien n'est configuré */}
      {(ics.url || showIcs) ? (
        <AgendaIcsConfig ics={ics} mobile={mobile} focusOnOpen={icsOpenedByUser.current} onRemove={() => setShowIcs(true)}/>
      ) : (
        <button type="button" onClick={() => { icsOpenedByUser.current = true; setShowIcs(true); }} className="tap" style={{ marginTop: mobile ? 4 : 12, minHeight: mobile ? 44 : 0, background: 'transparent', border: 'none', color: 'var(--blue-600)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', padding: 0, display: 'inline-flex', alignItems: 'center', gap: 4, textAlign: 'left' }}>
          {Ico.plus} Connecter un calendrier externe (Google, Apple)
        </button>
      )}
    </div>
  );
}

// ═════════════════════════════════════════════════════════════════════
// HABITUDES ET VÉRIFICATIONS AVANT TRADE (docs/spec-habitudes.md §8.1, §8.2, §9)
// ═════════════════════════════════════════════════════════════════════
// Même structure sur ordinateur et sur mobile (seules les dimensions changent) :
// bandeau « Aujourd'hui », carte « Habitudes » (Matin, Journée, Soir), carte
// « Vérifications avant trade », carte « Agenda ».
// AUCUN calcul de série, de taux ni de date dans ce fichier : tout vient de
// state (habitRows, habitCtx, habitsAll, habitsArchived, habitFeatures,
// habitGoal…) et du moteur de design/state.jsx (habitDayStatus, habitDayTally,
// habitRangeStats, habitShiftDay, habitWeekdayNum, habitDaysLabel) ; les cases
// sont les primitives partagées HabitCheck, HabitDayMark, HabitLegend.

const ROUTINES_NB = ' '; // espace insécable (avant « : », « % », dans les guillemets)
// Colonnes d'une ligne d'habitude (ordinateur) : nom, 7 jours, série, « Modifier ».
const ROUTINES_GRID = 'minmax(0, 1fr) repeat(7, 30px) 62px 30px';
const ROUTINES_GRID_MOBILE = 'repeat(7, minmax(0, 1fr))';
const ROUTINES_BAND = 'var(--blue-soft-2)'; // bande de la colonne « Auj. »
const ROUTINES_WEEKDAYS = [1, 2, 3, 4, 5];

const ROUTINES_ADD_PLACEHOLDER = {
  matin: 'Ajouter une habitude du matin…',
  journee: 'Ajouter une habitude de la journée…',
  soir: 'Ajouter une habitude du soir…',
  pretrade: 'Ajouter une vérification avant trade…',
};

// Suggestions à un clic d'un compte sans habitude (§8.1).
const ROUTINES_SUGGESTIONS = {
  habits: [
    { section: 'matin', name: 'Lire le calendrier économique' },
    { section: 'matin', name: 'Repérer mes niveaux clés' },
    { section: 'journee', name: 'Pas plus de 3 trades' },
    { section: 'journee', name: 'Pause après une perte' },
    { section: 'soir', name: 'Remplir le journal de la séance' },
  ],
  pretrade: [
    { section: 'pretrade', name: 'Stop placé avant l’entrée' },
    { section: 'pretrade', name: 'Risque ≤ 1' + ROUTINES_NB + '% du compte' },
    { section: 'pretrade', name: 'Pas d’annonce majeure dans les 30 minutes' },
    { section: 'pretrade', name: 'Trade conforme à ma stratégie' },
  ],
};

const ROUTINES_CARDS = {
  habits: {
    title: 'Habitudes',
    sections: ['matin', 'journee', 'soir'],
    subtitle: 'Comptées les jours prévus. La journée en cours n’est jugée que demain.',
    allDone: 'Tout est fait',
    intro: 'Commence par 2 ou 3 gestes simples. Tu les coches chaque jour, ici ou sur l’Accueil.',
    archives: 'Habitudes archivées',
    loadingLabel: 'Chargement de tes habitudes',
  },
  pretrade: {
    title: 'Vérifications avant trade',
    sections: ['pretrade'],
    subtitle: 'À valider avant ta première position du jour. Comptées les jours où tu as tradé.',
    allDone: 'Tout est vérifié',
    intro: 'Les points à contrôler avant d’ouvrir une position.',
    archives: 'Vérifications archivées',
    loadingLabel: 'Chargement de tes vérifications',
  },
};

// Petits styles impossibles à écrire en ligne (focus interne, coupe à 2 lignes).
(function () {
  if (typeof document === 'undefined' || document.getElementById('routines-css')) return;
  const el = document.createElement('style');
  el.id = 'routines-css';
  // Cadre du champ d'ajout : la bordure est ici, pas en ligne, sinon la règle de
  // focus ne pourrait pas la remplacer. Le champ lui-même n'a pas de contour :
  // c'est le cadre qui montre le focus (bordure pleine bleue et halo de .input:focus).
  el.textContent = '.routines-add{border:1.5px dashed var(--line-2)}'
    + '.routines-add:focus-within{border-color:var(--blue);border-style:solid;box-shadow:0 0 0 3px rgba(59,130,246,.12)}'
    + '.routines-clamp{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}'
    + '@keyframes routines-pulse{0%,100%{opacity:1}50%{opacity:.45}}'
    + '.routines-skel{animation:routines-pulse 1.4s ease-in-out infinite}'
    + '.routines-chip:not(:disabled):hover{border-color:var(--blue-border);background:var(--blue-soft)}'
    + '@media (max-width:899px){.routines-touch{min-height:44px}}';
  document.head.appendChild(el);
})();

function routinesQuote(s) { return '«' + ROUTINES_NB + s + ROUTINES_NB + '»'; }

// Écriture terminée en échec après le départ de Routines (ou de la carte qui
// l'a lancée) : son message local n'a plus d'écran. L'échec part vers l'avis
// commun de l'application (state.notifyWriteError, affiché sur tous les
// écrans). `what` nomme l'objet : « L’habitude « Respirer » n’a pas été
// ajoutée. » ; la cause suit (sbErrorMessage).
function routinesNotifyLost(st, what, error) {
  const text = what + ' ' + sbErrorMessage(error);
  if (st && typeof st.notifyWriteError === 'function') { st.notifyWriteError(text); return; }
  console.warn('[Tempo] ' + text);
}
function routinesCap(s) { const t = String(s || ''); return t.charAt(0).toUpperCase() + t.slice(1); }
function routinesCleanName(s) { return String(s || '').replace(/\s+/g, ' ').trim(); }
// Clé de comparaison de deux noms (casse, espaces et apostrophes indifférentes) :
// la règle unique de state.jsx (habitNameKey), pour que le formulaire et la
// restauration d'une archive refusent exactement les mêmes doublons.
function routinesNameKey(s) { return habitNameKey(s); }
function routinesDaysText(n) { return n + ROUTINES_NB + (n > 1 ? 'jours' : 'jour'); }
function routinesCheckedText(n) { return n + ROUTINES_NB + (n > 1 ? 'jours cochés' : 'jour coché'); }
function routinesSur(done, planned) { return done + ' sur ' + planned; }

// Libellé d'un statut de case (§5.5), en minuscules pour les aria-label.
// tradesKnown = false : les trades n'ont pas pu être chargés, on ne sait donc
// pas si le jour était tradé (jamais « pas de trade ce jour » à tort).
function routinesStatusText(status, section, tradesKnown = true) {
  const pre = section === 'pretrade';
  const why = tradesKnown ? 'pas de trade ce jour' : 'trades non chargés';
  switch (status) {
    case 'done': case 'today-done': return 'fait';
    case 'missed': return 'manqué';
    case 'today': return 'à faire aujourd’hui';
    case 'extra': return pre ? 'fait, ' + why + ROUTINES_NB + ': non compté' : 'fait, non compté';
    case 'off': return pre ? why + ROUTINES_NB + ': non compté' : 'pas prévu';
    case 'future': return 'à venir';
    default: return 'pas encore suivie';
  }
}

function useRoutinesAlive() {
  const ref = React.useRef(true);
  React.useEffect(() => { ref.current = true; return () => { ref.current = false; }; }, []);
  return ref;
}

// Largeur d'un élément (pour passer la grille de la page sur une colonne).
function useRoutinesWidth(ref) {
  const [w, setW] = React.useState(0);
  React.useLayoutEffect(() => {
    const el = ref.current;
    if (!el) return undefined;
    const read = () => setW(el.clientWidth || 0);
    read();
    if (typeof ResizeObserver === 'function') {
      const ro = new ResizeObserver(read);
      ro.observe(el);
      return () => ro.disconnect();
    }
    window.addEventListener('resize', read);
    return () => window.removeEventListener('resize', read);
  }, []);
  return w;
}

function routinesAttr(name, id) {
  return '[' + name + '="' + (window.CSS && CSS.escape ? CSS.escape(String(id)) : String(id)) + '"]';
}
function routinesFocusSel(sel) {
  setTimeout(() => {
    try {
      const btn = document.querySelector(sel);
      if (btn) btn.focus();
    } catch (e) {}
  }, 40);
}
function routinesFocusEdit(id) { routinesFocusSel(routinesAttr('data-habit-edit', id)); }

function routinesLinkStyle(mobile, extra) {
  return {
    display: 'inline-flex', alignItems: 'center', gap: 4, background: 'transparent', border: 'none', padding: 0,
    minHeight: mobile ? 44 : 24, color: 'var(--blue-600)', fontSize: 12.5, fontWeight: 500, cursor: 'pointer',
    fontFamily: 'inherit', whiteSpace: 'nowrap', ...extra,
  };
}

function RoutinesBar({ w, h = 10, style }) {
  return <span aria-hidden="true" className="routines-skel" style={{ display: 'block', width: w, height: h, borderRadius: 5, background: 'var(--bg-elev)', flex: 'none', ...style }}/>;
}

// Trois lignes de squelette pendant le premier chargement (§9).
function RoutinesSkeleton({ label, mobile }) {
  return (
    <div role="status" aria-busy="true" aria-label={label} style={{ display: 'flex', flexDirection: 'column', gap: 18, padding: '10px 0 6px' }}>
      {[0, 1, 2].map(i => (
        <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
          <RoutinesBar w={['46%', '38%', '52%'][i]} h={11}/>
          <span style={{ flex: 1 }}/>
          {!mobile && <RoutinesBar w={150} h={18}/>}
          <RoutinesBar w={32} h={12}/>
        </div>
      ))}
    </div>
  );
}

// Message d'erreur d'écriture, en ligne sous l'élément concerné (§9).
function RoutinesInlineError({ text, style }) {
  if (!text) return null;
  return <div role="status" style={{ fontSize: 12, lineHeight: 1.4, color: 'var(--red-text)', ...style }}>{text}</div>;
}

// Confirmation en ligne (charte §8) : la question nomme l'objet et la
// conséquence, puis [action] [Annuler]. Échap annule ; le focus arrive sur
// « Annuler » pour qu'une touche Entrée distraite ne valide rien.
function RoutinesConfirm({ label, text, confirmLabel, busyLabel, danger, busy, error, onConfirm, onCancel, returnFocus, style }) {
  const cancelRef = React.useRef(null);
  const cancel = () => { onCancel(); if (returnFocus) routinesFocusSel(returnFocus); };
  React.useEffect(() => { if (cancelRef.current) cancelRef.current.focus(); }, []);
  const small = { padding: '5px 11px', fontSize: 12.5, justifyContent: 'center' };
  return (
    <div role="alertdialog" aria-label={label}
      onKeyDown={e => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); if (!busy) cancel(); } }}
      style={{
        display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', padding: '10px 12px', borderRadius: 8,
        background: danger ? 'var(--red-soft)' : 'var(--bg-card)', border: '1px solid ' + (danger ? 'var(--line)' : 'var(--line-2)'), ...style,
      }}>
      <span style={{ flex: '1 1 200px', minWidth: 0, fontSize: 12.5, color: 'var(--fg)', lineHeight: 1.45, overflowWrap: 'anywhere' }}>{text}</span>
      <span style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
        <button type="button" onClick={onConfirm} disabled={busy} className={'btn tap routines-touch' + (danger ? '' : ' btn-blue')}
          style={danger ? { ...small, background: 'var(--sell-bg)', borderColor: 'var(--sell-bg)', color: '#fff' } : { ...small, color: '#fff' }}>
          {busy ? busyLabel : confirmLabel}
        </button>
        <button ref={cancelRef} type="button" onClick={cancel} disabled={busy} className="btn tap routines-touch" style={{ ...small, color: 'var(--fg)' }}>Annuler</button>
      </span>
      {error && <RoutinesInlineError text={error} style={{ flexBasis: '100%' }}/>}
    </div>
  );
}

// ── Bandeau « Aujourd'hui » ──────────────────────────────────────────
function RoutinesToday({ st, mobile, navStats }) {
  const alive = useRoutinesAlive();
  const today = st.today || todayISO();
  const ctx = st.habitCtx || null;
  const rows = st.habitRows || [];
  const active = React.useMemo(() => rows.filter(h => !h.archived), [rows]);
  const features = st.habitFeatures || {};
  const goal = Number.isFinite(Number(st.habitGoal)) ? Number(st.habitGoal) : HABIT_GOAL_DEFAULT;
  const loading = !!st.habitsLoading && rows.length === 0;
  const failed = !!st.habitsError && rows.length === 0;

  const all = React.useMemo(() => habitDayTally(active, today, ctx), [active, today, ctx]);
  const moments = React.useMemo(() => SECTION_ORDER.map(sec => {
    const t = habitDayTally(active.filter(h => h.section === sec), today, ctx);
    return { sec, label: SECTION_LABELS[sec], done: t.done, planned: t.planned };
  }).filter(m => m.planned > 0), [active, today, ctx]);
  // Régularité des 30 derniers jours terminés (hier compris, aujourd'hui exclu).
  const month = React.useMemo(
    () => (ctx && st.habitHistoryReady ? habitRangeStats(rows, habitShiftDay(today, -30), habitShiftDay(today, -1), ctx) : null),
    [rows, today, ctx, st.habitHistoryReady]
  );

  // Jours tradés inconnus : la régularité attend (chargement) ou précise
  // qu'elle exclut les vérifications avant trade (échec).
  const tradesState = st.habitTradesState || 'ready';
  const partial = tradesState === 'error' && !failed && !!st.habitHistoryReady && active.some(h => h.section === 'pretrade');

  const [goalOpen, setGoalOpen] = React.useState(false);
  const [goalBusy, setGoalBusy] = React.useState(false);
  const [goalErr, setGoalErr] = React.useState('');
  const goalGuard = React.useRef(false);
  const goalBtn = React.useRef(null);
  const goalReturn = React.useRef(false);
  React.useEffect(() => {
    if (!goalOpen && goalReturn.current) { goalReturn.current = false; if (goalBtn.current) goalBtn.current.focus(); }
  }, [goalOpen]);
  const goalOptions = React.useMemo(() => {
    const list = [];
    for (let v = 50; v <= 100; v += 5) list.push(v);
    if (!list.includes(goal)) { list.push(goal); list.sort((a, b) => a - b); }
    return list;
  }, [goal]);
  const closeGoal = () => { goalReturn.current = true; setGoalOpen(false); };
  const saveGoal = async (value) => {
    if (goalGuard.current || typeof st.setHabitGoal !== 'function') return;
    const pct = Number(value);
    if (pct === goal) { closeGoal(); return; }
    goalGuard.current = true; setGoalBusy(true); setGoalErr('');
    let res;
    try { res = await st.setHabitGoal(pct); } catch (e) { res = { error: e }; }
    goalGuard.current = false;
    if (!alive.current) {
      if (res && res.error) routinesNotifyLost(st, 'Ton objectif de régularité n’a pas été enregistré.', res.error);
      return;
    }
    setGoalBusy(false);
    if (res && res.error) { setGoalErr(sbErrorMessage(res.error)); return; }
    closeGoal();
  };

  const complete = all.planned > 0 && all.done === all.planned;
  let summary;
  if (loading) summary = <RoutinesBar w={150} h={14} style={{ margin: '4px 0' }}/>;
  else if (failed) summary = <span style={{ color: 'var(--fg-2)' }}>—</span>;
  else if (active.length === 0) summary = <span style={{ color: 'var(--fg-2)' }}>Aucune habitude pour l’instant. Choisis tes premières juste en dessous.</span>;
  else if (all.planned === 0) summary = <span style={{ color: 'var(--fg-2)' }}>Rien de prévu aujourd’hui.</span>;
  else summary = (
    <span data-habit-today-count>
      <b style={{ fontSize: 15, fontWeight: 700, color: 'var(--fg)' }}>{routinesSur(all.done, all.planned)}</b>
      <span style={{ color: 'var(--fg-2)' }}>{all.done > 1 ? ' faites' : ' faite'}</span>
    </span>
  );

  const rateText = month && month.rate !== null ? fmtPct(month.rate, 0) : '—';
  const selectStyle = { width: 'auto', padding: mobile ? '9px 10px' : '4px 8px', fontSize: 12.5, fontFamily: 'inherit' };

  return (
    <div className="card" data-habit-today style={{
      display: 'flex', flexDirection: mobile ? 'column' : 'row', alignItems: mobile ? 'stretch' : 'center',
      justifyContent: 'space-between', gap: mobile ? 12 : 28, padding: mobile ? '16px 14px 12px' : '18px 24px', marginBottom: mobile ? 14 : 16,
    }}>
      <div style={{ flex: '1 1 auto', minWidth: 0 }}>
        <div style={{ fontSize: 10.5, fontWeight: 600, letterSpacing: '.08em', textTransform: 'uppercase', color: 'var(--fg-3)' }}>Aujourd’hui</div>
        <div style={{ fontSize: mobile ? 17 : 18, fontWeight: 700, letterSpacing: '-.02em', color: 'var(--fg)', marginTop: 2 }}>{routinesCap(fmtDayLong(today))}</div>
        <div style={{ fontSize: 13, marginTop: 8, lineHeight: 1.4 }}>{summary}</div>
        {all.planned > 0 && (
          <div aria-hidden="true" style={{ height: 4, borderRadius: 2, background: 'var(--bg-elev)', overflow: 'hidden', marginTop: 8, maxWidth: mobile ? 'none' : 420 }}>
            <div style={{ width: (all.done / all.planned * 100) + '%', height: '100%', borderRadius: 2, background: 'var(--green)', transition: 'width .3s var(--ease)' }}/>
          </div>
        )}
        {moments.length > 0 && (
          <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 10 }}>
            {moments.map(m => (
              <span key={m.sec} data-habit-moment={m.sec} className={'pill ' + (m.done === m.planned ? 'pill-green' : 'pill-gray')}
                style={{ padding: '3px 9px', fontSize: 11.5, whiteSpace: 'nowrap' }}>
                {m.label}{ROUTINES_NB}<b style={{ fontWeight: 700 }}>{routinesSur(m.done, m.planned)}</b>
              </span>
            ))}
            {complete && <span className="pill pill-green" style={{ padding: '3px 9px', fontSize: 11.5, fontWeight: 600, whiteSpace: 'nowrap' }}>Tout est fait</span>}
          </div>
        )}
      </div>

      <div style={{
        flex: 'none', display: 'flex', flexDirection: 'column', gap: mobile ? 2 : 6, alignItems: mobile ? 'stretch' : 'flex-end',
        borderTop: mobile ? '1px solid var(--line)' : 'none', paddingTop: mobile ? 10 : 0,
      }}>
        <div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flexWrap: 'wrap', justifyContent: mobile ? 'flex-start' : 'flex-end' }}>
          <span style={{ fontSize: 12.5, color: 'var(--fg-2)' }}>{'Régularité sur 30 jours' + ROUTINES_NB + ':'}</span>
          {(!st.habitHistoryReady || tradesState === 'loading') && !failed
            ? <RoutinesBar w={44} h={16} style={{ alignSelf: 'center' }}/>
            : <b data-kpi="regularite-30" title="Habitudes faites sur habitudes prévues, sur les 30 derniers jours terminés." style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-.02em', color: 'var(--fg)', whiteSpace: 'nowrap' }}>{rateText}</b>}
        </div>
        {partial && <div data-habit-partial style={{ fontSize: 11.5, color: 'var(--fg-3)', textAlign: mobile ? 'left' : 'right' }}>hors vérifications avant trade (trades non chargés)</div>}
        <div style={{ display: 'flex', alignItems: 'center', gap: mobile ? 14 : 12, flexWrap: 'wrap', justifyContent: mobile ? 'flex-start' : 'flex-end' }}>
          <span data-habit-goal-label style={{ fontSize: 12.5, color: 'var(--fg-2)', whiteSpace: 'nowrap' }}>{'Objectif ' + fmtPct(goal, 0)}</span>
          {features.goal && !goalOpen && (
            <button ref={goalBtn} type="button" className="tap" data-habit-goal-edit onClick={() => { setGoalErr(''); setGoalOpen(true); }}
              aria-label="Modifier l’objectif de régularité" style={routinesLinkStyle(mobile)}>Modifier</button>
          )}
          {features.goal && goalOpen && (
            <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
              <select className="input" autoFocus aria-label="Objectif de régularité" value={goal} disabled={goalBusy}
                onChange={e => saveGoal(e.target.value)}
                onBlur={() => { if (!goalGuard.current) setGoalOpen(false); }}
                onKeyDown={e => { if (e.key === 'Escape') { e.preventDefault(); closeGoal(); } }}
                style={selectStyle}>
                {goalOptions.map(v => <option key={v} value={v}>{fmtPct(v, 0)}</option>)}
              </select>
              {goalBusy && <span role="status" style={{ fontSize: 12, color: 'var(--fg-2)' }}>Enregistrement…</span>}
            </span>
          )}
          {typeof navStats === 'function' && (
            <button type="button" className="tap" onClick={() => navStats('habits')} style={routinesLinkStyle(mobile)}>Statistiques ›</button>
          )}
        </div>
        <RoutinesInlineError text={goalErr}/>
      </div>
    </div>
  );
}

// ── Ligne d'en-tête d'une section : nom du moment + 7 jours nommés + « Série » ──
function RoutinesDayHead({ iso, isToday, mobile }) {
  const wd = FR_WEEKDAYS_2[habitWeekdayNum(iso) - 1];
  const num = Number(String(iso).slice(8, 10));
  return (
    <div data-habit-col={iso} title={routinesCap(fmtDayLong(iso))} style={{
      textAlign: 'center', fontSize: mobile ? 10.5 : 10, lineHeight: 1.3, padding: '5px 0 5px',
      color: isToday ? 'var(--blue-600)' : 'var(--fg-3)', fontWeight: isToday ? 700 : 500,
      background: isToday ? ROUTINES_BAND : 'transparent', borderRadius: isToday ? '7px 7px 0 0' : 0, whiteSpace: 'nowrap',
    }}>
      <span style={{ display: 'block' }}>{isToday ? 'Auj.' : wd}</span>
      <span style={{ display: 'block' }}>{num}</span>
    </div>
  );
}

function RoutinesSectionHead({ label, tally, days, today, mobile, withColumns }) {
  const name = (
    <span style={{ display: 'inline-flex', alignItems: 'baseline', flexWrap: 'wrap', gap: '0 6px', minWidth: 0 }}>
      <span style={{ fontSize: 10.5, fontWeight: 600, letterSpacing: '.08em', textTransform: 'uppercase', color: 'var(--fg-2)', whiteSpace: 'nowrap' }}>{label}</span>
      {tally.planned > 0 && (
        <span style={{ fontSize: 11.5, fontWeight: 500, color: tally.done === tally.planned ? 'var(--green-text)' : 'var(--fg-3)', whiteSpace: 'nowrap' }}>
          {'· ' + routinesSur(tally.done, tally.planned)}
        </span>
      )}
    </span>
  );
  if (!withColumns) return <div style={{ paddingBottom: 6 }}>{name}</div>;
  const serieTitle = 'Jours prévus d’affilée où l’habitude est faite. La journée en cours ne casse pas la série.';
  if (mobile) {
    // Les 7 jours nommés restent collés en haut de l'écran tant que la section
    // défile : avec beaucoup d'habitudes, aucune rangée de cases ne reste sans
    // nom de colonne. Enfants directs de la section (portée du « sticky »).
    return (
      <React.Fragment>
        <div style={{ paddingBottom: 6 }}>{name}</div>
        <div data-habit-dayhead style={{
          display: 'grid', gridTemplateColumns: ROUTINES_GRID_MOBILE,
          position: 'sticky', top: 0, zIndex: 2, background: 'var(--bg-card)',
        }}>
          {days.map(iso => <RoutinesDayHead key={iso} iso={iso} isToday={iso === today} mobile/>)}
        </div>
      </React.Fragment>
    );
  }
  return (
    <div style={{ display: 'grid', gridTemplateColumns: ROUTINES_GRID, columnGap: 6, alignItems: 'stretch' }}>
      <div style={{ alignSelf: 'end', paddingBottom: 6, minWidth: 0 }}>{name}</div>
      {days.map(iso => <RoutinesDayHead key={iso} iso={iso} isToday={iso === today}/>)}
      <div title={serieTitle} style={{ alignSelf: 'end', paddingBottom: 6, textAlign: 'center', fontSize: 10, fontWeight: 600, letterSpacing: '.06em', textTransform: 'uppercase', color: 'var(--fg-3)', cursor: 'help' }}>Série</div>
      <div/>
    </div>
  );
}

// ── Ligne d'habitude : nom, 6 jours passés rattrapables, case du jour, série, « Modifier » ──
// `ready` : historique des coches chargé. `tradesState` : 'ready' | 'loading' |
// 'error' (jours tradés, dont dépendent les vérifications avant trade). Tant
// qu'une donnée manque, les jours passés sont neutres (jamais « Manqué » à
// tort) et la série est en attente ; la case du jour reste cochable.
function RoutinesHabitRow({ item, row, section, days, today, ctx, features, ready, tradesState = 'ready', mobile, isLast, editing, onEdit, onToggle, error }) {
  const name = item.t;
  const pre = section === 'pretrade';
  const pending = !ready || (pre && tradesState === 'loading');
  const tradesKnown = !pre || tradesState !== 'error';
  const daysLabel = features.days && section !== 'pretrade' && item.days && item.days.length < 7 ? habitDaysLabel(item.days) : '';
  const streak = Number(item.streak) || 0;
  const best = Number(item.best) || 0;
  const streakTitle = 'Série en cours' + ROUTINES_NB + ': ' + routinesDaysText(streak) + ' · record ' + routinesDaysText(best);

  const cells = days.map((iso, i) => {
    const isToday = iso === today;
    const status = row && ctx ? habitDayStatus(row, iso, ctx) : 'none';
    const waiting = pending && !isToday && status !== 'none' && status !== 'future';
    const label = name + ' — ' + fmtDayLong(iso) + ROUTINES_NB + ': ' + (waiting ? 'chargement' : routinesStatusText(status, section, tradesKnown));
    const toggle = () => onToggle(item.id, iso);
    const mark = waiting
      ? <span role="img" aria-label={label} title={label} style={{ display: 'inline-flex' }}><RoutinesBar w={22} h={22} style={{ borderRadius: 6 }}/></span>
      : (status === 'today' || status === 'today-done')
        ? <HabitCheck checked={status === 'today-done'} onToggle={toggle} label={label} size={20}/>
        : <HabitDayMark status={status} onToggle={toggle} label={label} size={22}/>;
    return (
      <div key={iso} data-habit-day={item.id + '|' + iso} data-habit-wait={waiting ? '1' : undefined} style={{
        display: 'flex', alignItems: 'center', justifyContent: 'center', minWidth: 0,
        background: isToday ? ROUTINES_BAND : 'transparent',
        borderRadius: isToday && (isLast || mobile) ? '0 0 7px 7px' : 0,
      }}>{mark}</div>
    );
  });

  const nameBlock = (
    <div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', minWidth: 0, padding: mobile ? 0 : '6px 0' }}>
      <span className="routines-clamp" title={name} style={{ fontSize: 13.5, lineHeight: 1.3, color: 'var(--fg)', letterSpacing: '-.005em', overflowWrap: 'anywhere' }}>{name}</span>
      {daysLabel && <span style={{ fontSize: 11, lineHeight: 1.3, color: 'var(--fg-3)', marginTop: 1 }}>{daysLabel}</span>}
    </div>
  );
  const streakNode = pending
    ? <RoutinesBar w={28} h={10}/>
    : !tradesKnown ? (
      <span data-habit-streak="" title="Série inconnue : tes trades n’ont pas pu être chargés." style={{ fontSize: 11.5, fontWeight: 600, color: 'var(--fg-3)', padding: '2px 0', whiteSpace: 'nowrap' }}>
        {(mobile ? 'série ' : '') + '—'}
      </span>
    )
    : (
      <span data-habit-streak={streak} title={streakTitle} style={streak >= 1
        ? { fontSize: 11.5, fontWeight: 700, color: 'var(--green-text)', background: 'var(--green-soft)', borderRadius: 6, padding: '2px 7px', whiteSpace: 'nowrap' }
        : { fontSize: 11.5, fontWeight: 600, color: 'var(--fg-3)', padding: '2px 0', whiteSpace: 'nowrap' }}>
        {(mobile ? 'série ' : '') + streak + ROUTINES_NB + 'j'}
      </span>
    );
  const editBtn = (
    <button type="button" className="tap" data-habit-edit={item.id} onClick={onEdit}
      aria-label={'Modifier ' + routinesQuote(name)} aria-expanded={editing ? 'true' : 'false'} title="Modifier"
      style={{
        width: mobile ? 44 : 30, height: mobile ? 44 : 30, flex: 'none', alignSelf: 'center', padding: 0, borderRadius: 8,
        border: '1px solid ' + (editing ? 'var(--line-2)' : 'transparent'), background: editing ? 'var(--bg-elev)' : 'transparent',
        color: 'var(--fg-2)', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
      }}>{Ico.more}</button>
  );
  const errorNode = error ? <RoutinesInlineError text={error} style={{ padding: mobile ? '2px 0 6px' : '0 0 6px' }}/> : null;

  if (mobile) {
    return (
      <div data-habit-row={item.id} style={{ borderTop: '1px solid var(--line)', paddingTop: 4 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, minHeight: 44 }}>
          <div style={{ flex: 1, minWidth: 0 }}>{nameBlock}</div>
          <span style={{ flex: 'none' }}>{streakNode}</span>
          {editBtn}
        </div>
        <div style={{ display: 'grid', gridTemplateColumns: ROUTINES_GRID_MOBILE, height: 44, marginBottom: 4 }}>{cells}</div>
        {errorNode}
      </div>
    );
  }
  return (
    <div data-habit-row={item.id}>
      <div style={{ display: 'grid', gridTemplateColumns: ROUTINES_GRID, columnGap: 6, alignItems: 'stretch', minHeight: 40 }}>
        {nameBlock}
        {cells}
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>{streakNode}</div>
        {editBtn}
      </div>
      {errorNode}
    </div>
  );
}

// ── Panneau « Modifier » (en ligne sous la ligne, un seul ouvert à la fois) ──
// `openSeq()` : numéro de l'ouverture en cours (voir RoutinesBody). Une réponse
// d'écriture le relit après coup : s'il a changé, l'utilisateur a refermé,
// rouvert ou changé de panneau entre-temps, et cette réponse ne doit plus rien
// fermer. `onError(msg)` reçoit l'échec d'une écriture dont le panneau n'est
// plus à l'écran, pour qu'il soit dit sous la ligne au lieu d'être perdu.
function RoutinesEditPanel({ item, st, features, index, count, mobile, onClose, openSeq, onError }) {
  const alive = useRoutinesAlive();
  const origDays = React.useMemo(() => habitNormDays(item.days), [item.days]);
  const [name, setName] = React.useState(item.t || '');
  const [section, setSection] = React.useState(item.section);
  const [days, setDays] = React.useState(origDays);
  const [busy, setBusy] = React.useState(false);
  const [error, setError] = React.useState('');
  const [askArchive, setAskArchive] = React.useState(false);
  const [archiving, setArchiving] = React.useState(false);
  const [archiveErr, setArchiveErr] = React.useState('');
  const guard = React.useRef(false);
  const nameRef = React.useRef(null);
  const uid = React.useRef('rt-' + Math.random().toString(36).slice(2, 8)).current;
  React.useEffect(() => { if (nameRef.current) nameRef.current.focus(); }, []);
  // « Monter » / « Descendre » : arrivé à une extrémité, le bouton qu'on vient
  // d'actionner se désactive (et la ligne peut être déplacée dans la page) : le
  // navigateur lui retire le focus, qui retomberait sur <body> — Échap ne
  // fermerait plus le panneau. Après un déplacement, le focus est donc rendu au
  // même bouton s'il reste actif, sinon à l'autre, sinon au champ Nom. Rien
  // n'est fait si le focus est parti ailleurs de lui-même (autre champ, autre
  // carte) : movedRef vaut le sens du dernier déplacement (-1 / 1), 0 sinon.
  const upRef = React.useRef(null);
  const downRef = React.useRef(null);
  const movedRef = React.useRef(0);
  React.useEffect(() => {
    if (!movedRef.current) return;
    const up = upRef.current, down = downRef.current;
    const a = document.activeElement;
    const lost = !a || a === document.body || ((a === up || a === down) && a.disabled);
    if (!lost) return;
    const order = movedRef.current < 0 ? [up, down] : [down, up];
    const t = order.find(b => b && !b.disabled) || nameRef.current;
    if (t) t.focus();
  }, [index, count]);

  const clean = routinesCleanName(name);
  const duplicate = React.useMemo(() => {
    if (!clean) return false;
    const key = routinesNameKey(clean);
    const group = (st.habitsAll || []).find(g => g.sec === section);
    return !!(group && group.items.some(it => it.id !== item.id && routinesNameKey(it.t) === key));
  }, [clean, section, st.habitsAll, item.id]);
  const showDays = !!features.days;
  const needDays = showDays && section !== 'pretrade';
  const noDay = needDays && days.length === 0;
  // Un nom hérité peut dépasser la limite : le message est celui de Tempo, et
  // « Enregistrer » reste inactif tant que le nom est trop long.
  const nameError = !clean ? 'Donne un nom à l’habitude.'
    : clean.length > HABIT_NAME_MAX ? 'Le nom ne peut pas dépasser ' + HABIT_NAME_MAX + ' caractères (' + clean.length + ' actuellement).'
    : duplicate ? routinesQuote(clean) + ' existe déjà dans ' + SECTION_LABELS[section] + '.' : '';
  const changes = {};
  if (clean !== item.t) changes.name = clean;
  if (section !== item.section) changes.section = section;
  if (needDays && days.join(',') !== origDays.join(',')) changes.days = days;
  const dirty = Object.keys(changes).length > 0;
  const canSave = dirty && !nameError && !noDay && !busy && !archiving;
  const readSeq = () => (typeof openSeq === 'function' ? openSeq() : undefined);

  const submit = async (e) => {
    e.preventDefault();
    if (guard.current || !canSave || typeof st.updateHabit !== 'function') return;
    guard.current = true; setBusy(true); setError('');
    const seq = readSeq(); // lu AVANT l'envoi : identifie cette ouverture
    let res;
    try { res = await st.updateHabit(item.id, changes); } catch (err) { res = { error: err }; }
    guard.current = false;
    if (alive.current) setBusy(false);
    if (res && res.error) {
      const msg = sbErrorMessage(res.error);
      if (alive.current) setError(msg);
      else if (typeof onError === 'function') onError('Modification non enregistrée. ' + msg, 'Les modifications de l’habitude ' + routinesQuote(item.t) + ' n’ont pas été enregistrées.', res.error);
      return;
    }
    // Pas de « if (!alive.current) return » ici : un changement de moment
    // démonte ce panneau et en remonte un autre sans action de l'utilisateur,
    // et celui-là doit se fermer. C'est le numéro d'ouverture qui tranche.
    onClose(false, seq);
  };
  const move = async (dir) => {
    if (typeof st.moveHabit !== 'function') return;
    movedRef.current = dir < 0 ? -1 : 1;
    setError('');
    let res;
    try { res = await st.moveHabit(item.id, dir); } catch (err) { res = { error: err }; }
    if (!(res && res.error)) return;
    if (alive.current) setError(sbErrorMessage(res.error));
    else if (typeof onError === 'function') onError('Déplacement non enregistré. ' + sbErrorMessage(res.error), 'Le déplacement de l’habitude ' + routinesQuote(item.t) + ' n’a pas été enregistré.', res.error);
  };
  const archive = async () => {
    if (guard.current || typeof st.archiveHabit !== 'function') return;
    guard.current = true; setArchiving(true); setArchiveErr('');
    const seq = readSeq();
    let res;
    try { res = await st.archiveHabit(item.id); } catch (err) { res = { error: err }; }
    guard.current = false;
    if (!alive.current) {
      // Panneau refermé pendant l'envoi : une réussite retire la ligne d'elle-même,
      // un échec est dit sous la ligne.
      if (res && res.error && typeof onError === 'function') onError('Archivage impossible. ' + sbErrorMessage(res.error), 'L’habitude ' + routinesQuote(item.t) + ' n’a pas été archivée.', res.error);
      return;
    }
    setArchiving(false);
    if (res && res.error) { setArchiveErr(sbErrorMessage(res.error)); return; }
    onClose(true, seq);
  };
  const toggleDay = (n) => setDays(prev => (prev.includes(n) ? prev.filter(x => x !== n) : [...prev, n].sort((a, b) => a - b)));

  const label = { display: 'block', fontSize: 11.5, fontWeight: 600, color: 'var(--fg-2)', marginBottom: 5 };
  const help = { fontSize: 11.5, lineHeight: 1.45, color: 'var(--fg-3)', marginTop: 6 };
  const field = { padding: mobile ? '10px 12px' : '7px 10px', fontSize: 13, fontFamily: 'inherit' };
  const smallBtn = { padding: mobile ? '0 12px' : '5px 11px', minHeight: mobile ? 44 : 0, fontSize: 12.5, justifyContent: 'center', color: 'var(--fg)' };
  const textBtn = routinesLinkStyle(mobile, { fontSize: 12 });
  const isAll = days.length === 7;
  const isWeek = days.join(',') === ROUTINES_WEEKDAYS.join(',');

  return (
    <form onSubmit={submit} noValidate data-habit-panel={item.id} aria-label={'Modifier ' + routinesQuote(item.t)}
      onFocus={e => { if (e.target !== upRef.current && e.target !== downRef.current) movedRef.current = 0; }}
      onKeyDown={e => { if (e.key === 'Escape') { e.preventDefault(); if (!busy && !archiving) onClose(); } }}
      style={{
        margin: mobile ? '2px 0 10px' : '2px 0 10px', padding: mobile ? '14px 12px' : '14px 16px', borderRadius: 10,
        background: 'var(--bg-soft)', border: '1px solid var(--line-2)', display: 'flex', flexDirection: 'column', gap: 14,
      }}>
      <div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr' : 'minmax(0, 1fr) 168px', gap: 12 }}>
        <div style={{ minWidth: 0 }}>
          <label htmlFor={uid + '-name'} style={label}>Nom</label>
          <input id={uid + '-name'} ref={nameRef} className="input" value={name} maxLength={HABIT_NAME_MAX} autoComplete="off"
            aria-invalid={nameError ? 'true' : undefined} aria-describedby={nameError ? uid + '-name-err' : undefined}
            onChange={e => { setName(e.target.value); if (error) setError(''); }} style={field}/>
          {nameError && <div id={uid + '-name-err'} role="status" style={{ fontSize: 12, color: 'var(--red-text)', marginTop: 5 }}>{nameError}</div>}
        </div>
        <div>
          <label htmlFor={uid + '-moment'} style={label}>Moment</label>
          <select id={uid + '-moment'} className="input" value={section} onChange={e => setSection(e.target.value)} style={field}>
            {SECTION_ORDER.map(sec => <option key={sec} value={sec}>{SECTION_LABELS[sec]}</option>)}
          </select>
        </div>
      </div>

      {showDays && (
        <div data-habit-days>
          <div id={uid + '-days'} style={label}>Jours prévus</div>
          {section === 'pretrade' ? (
            <div style={{ fontSize: 12.5, lineHeight: 1.45, color: 'var(--fg-2)' }}>Comptée seulement les jours où tu as tradé.</div>
          ) : (
            <React.Fragment>
              <div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: mobile ? '4px 14px' : '6px 12px' }}>
                <div role="group" aria-labelledby={uid + '-days'} style={{
                  display: mobile ? 'grid' : 'flex', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', gap: 4, flex: mobile ? '1 1 100%' : 'none',
                }}>
                  {FR_WEEKDAYS_2.map((lbl, i) => {
                    const on = days.includes(i + 1);
                    return (
                      <button key={lbl} type="button" className="tap" aria-pressed={on ? 'true' : 'false'} aria-label={FR_WEEKDAYS_LONG[i]}
                        title={routinesCap(FR_WEEKDAYS_LONG[i])} onClick={() => toggleDay(i + 1)} style={{
                          minWidth: mobile ? 0 : 32, height: mobile ? 44 : 30, padding: 0, borderRadius: 8, cursor: 'pointer', fontFamily: 'inherit',
                          fontSize: 12, fontWeight: 600, border: '1px solid ' + (on ? 'var(--blue)' : 'var(--line-2)'),
                          background: on ? 'var(--blue)' : 'var(--bg-card)', color: on ? '#fff' : 'var(--fg-2)',
                        }}>{lbl}</button>
                    );
                  })}
                </div>
                <span style={{ display: 'inline-flex', alignItems: 'center', gap: 14, whiteSpace: 'nowrap' }}>
                  <button type="button" className="tap" onClick={() => setDays(HABIT_ALL_DAYS.slice())} disabled={isAll} style={{ ...textBtn, opacity: isAll ? .55 : 1 }}>Tous les jours</button>
                  <button type="button" className="tap" onClick={() => setDays(ROUTINES_WEEKDAYS.slice())} disabled={isWeek} style={{ ...textBtn, opacity: isWeek ? .55 : 1 }}>Jours de semaine</button>
                </span>
              </div>
              {noDay && <div role="status" style={{ fontSize: 12, color: 'var(--red-text)', marginTop: 6 }}>Choisis au moins un jour.</div>}
              <div style={help}>Les jours non prévus ne comptent pas. S’applique à tout l’historique de cette habitude.</div>
            </React.Fragment>
          )}
        </div>
      )}

      <div>
        <div style={label}>Ordre</div>
        <div style={{ display: 'flex', gap: 6 }}>
          <button ref={upRef} type="button" className="btn tap" onClick={() => move(-1)} disabled={index <= 0} style={{ ...smallBtn, flex: mobile ? 1 : 'none' }}>Monter</button>
          <button ref={downRef} type="button" className="btn tap" onClick={() => move(1)} disabled={index < 0 || index >= count - 1} style={{ ...smallBtn, flex: mobile ? 1 : 'none' }}>Descendre</button>
        </div>
      </div>

      <RoutinesInlineError text={error}/>

      {askArchive ? (
        <RoutinesConfirm label={'Archiver ' + routinesQuote(item.t)}
          text={'Archiver ' + routinesQuote(item.t) + ' ? Elle quitte tes listes, son historique est conservé.'}
          confirmLabel="Archiver" busyLabel="Archivage…" busy={archiving} error={archiveErr}
          returnFocus={routinesAttr('data-habit-panel', item.id) + ' [data-habit-archive]'}
          onConfirm={archive} onCancel={() => { setAskArchive(false); setArchiveErr(''); }}/>
      ) : (
        <div style={{
          display: 'flex', flexDirection: mobile ? 'column-reverse' : 'row', alignItems: mobile ? 'stretch' : 'center',
          justifyContent: 'space-between', gap: 8, borderTop: '1px solid var(--line)', paddingTop: 12,
        }}>
          <button type="button" className="btn tap" data-habit-archive onClick={() => setAskArchive(true)} disabled={busy} style={smallBtn}>Archiver</button>
          <span style={{ display: 'flex', flexDirection: mobile ? 'column-reverse' : 'row', gap: 8 }}>
            <button type="button" className="btn tap" onClick={() => onClose()} disabled={busy} style={smallBtn}>Annuler</button>
            <button type="submit" className="btn btn-blue tap" disabled={!canSave} style={{ ...smallBtn, color: '#fff' }}>{busy ? 'Enregistrement…' : 'Enregistrer'}</button>
          </span>
        </div>
      )}
    </form>
  );
}

// ── Ajout en fin de section ──────────────────────────────────────────
function RoutinesAddForm({ section, st, mobile }) {
  const alive = useRoutinesAlive();
  const [draft, setDraft] = React.useState('');
  const [busy, setBusy] = React.useState(false);
  const [error, setError] = React.useState('');
  const guard = React.useRef(false);
  const inputRef = React.useRef(null);
  // Noms ajoutés avec succès mais pas encore revenus dans state.habitsAll : un
  // second envoi parti avant le nouvel affichage est refusé comme un doublon.
  const justAdded = React.useRef(new Set());
  React.useEffect(() => {
    const g = (st.habitsAll || []).find(x => x.sec === section);
    const present = new Set(g ? g.items.map(it => routinesNameKey(it.t)) : []);
    justAdded.current.forEach(k => { if (present.has(k)) justAdded.current.delete(k); });
  }, [st.habitsAll, section]);
  const placeholder = ROUTINES_ADD_PLACEHOLDER[section];
  const filled = !!routinesCleanName(draft);

  const submit = async (e) => {
    e.preventDefault();
    if (guard.current || typeof st.addHabit !== 'function') return;
    const name = routinesCleanName(draft);
    if (!name) return;
    const key = routinesNameKey(name);
    const group = (st.habitsAll || []).find(g => g.sec === section);
    if (justAdded.current.has(key) || (group && group.items.some(it => routinesNameKey(it.t) === key))) {
      setError(routinesQuote(name) + ' existe déjà dans ' + SECTION_LABELS[section] + '.');
      return;
    }
    guard.current = true; setBusy(true); setError('');
    justAdded.current.add(key);
    let res;
    try { res = await st.addHabit(section, name); } catch (err) { res = { error: err }; }
    if (res && res.error) justAdded.current.delete(key);
    guard.current = false;
    if (!alive.current) {
      if (res && res.error) routinesNotifyLost(st, 'L’habitude ' + routinesQuote(name) + ' n’a pas été ajoutée.', res.error);
      return;
    }
    setBusy(false);
    // Le champ n'est vidé qu'après la réponse : en cas d'échec le texte reste.
    if (res && res.error) { setError(sbErrorMessage(res.error)); return; }
    setDraft('');
    if (inputRef.current) inputRef.current.focus();
  };

  return (
    <div data-habit-add={section} style={{ marginTop: 8 }}>
      <form onSubmit={submit} className="routines-add" style={{
        display: 'flex', alignItems: 'center', gap: mobile ? 8 : 10, padding: mobile ? '4px 6px 4px 10px' : '6px 8px 6px 10px',
        borderRadius: 8, background: 'var(--bg-soft)', transition: 'border-color .15s var(--ease), box-shadow .15s var(--ease)',
      }}>
        {!mobile && <span aria-hidden="true" style={{ width: 18, height: 18, borderRadius: 5, border: '1.5px dashed var(--line-2)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--fg-3)', flexShrink: 0 }}>{Ico.plus}</span>}
        <input ref={inputRef} value={draft} maxLength={HABIT_NAME_MAX} placeholder={placeholder} aria-label={placeholder.replace(/…$/, '')}
          autoComplete="off" readOnly={busy}
          onChange={e => { setDraft(e.target.value); if (error) setError(''); }}
          style={{ flex: 1, minWidth: 0, height: mobile ? 44 : 28, background: 'transparent', border: 'none', outline: 'none', fontSize: 13, color: 'var(--fg)', fontFamily: 'inherit', padding: 0, textOverflow: 'ellipsis' }}/>
        <button type="submit" disabled={!filled || busy} className="btn btn-blue tap" style={{ padding: mobile ? '0 12px' : '4px 11px', minHeight: mobile ? 44 : 0, fontSize: 12, fontWeight: 600, flexShrink: 0, color: '#fff' }}>
          {busy ? 'Ajout…' : 'Ajouter'}
        </button>
      </form>
      <RoutinesInlineError text={error} style={{ marginTop: 5 }}/>
    </div>
  );
}

// ── Suggestions à un clic (compte sans habitude) ─────────────────────
function RoutinesSuggestions({ kind, st, features, mobile, empty }) {
  const alive = useRoutinesAlive();
  const meta = ROUTINES_CARDS[kind];
  const [busyKey, setBusyKey] = React.useState('');
  const [error, setError] = React.useState('');
  const guard = React.useRef(false);
  // Un nom déjà présent (actif ou archivé) n'est pas proposé.
  const taken = React.useMemo(() => new Set((st.habitRows || []).map(h => routinesNameKey(h.name))), [st.habitRows]);
  const list = ROUTINES_SUGGESTIONS[kind].filter(s => !taken.has(routinesNameKey(s.name)));
  if (!list.length && !empty) return null;

  const run = async (key, call) => {
    if (guard.current) return;
    guard.current = true; setBusyKey(key); setError('');
    let res;
    try { res = await call(); } catch (err) { res = { error: err }; }
    guard.current = false;
    if (!alive.current) {
      if (res && res.error) {
        routinesNotifyLost(st, key === '*' ? 'Les habitudes suggérées n’ont pas été ajoutées.' : 'L’habitude ' + routinesQuote(key) + ' n’a pas été ajoutée.', res.error);
      }
      return;
    }
    setBusyKey('');
    if (res && res.error) setError(sbErrorMessage(res.error));
  };
  const withDays = (s) => (features.days && s.section !== 'pretrade' ? { days: ROUTINES_WEEKDAYS.slice() } : undefined);
  const addOne = (s) => run(s.name, () => st.addHabit(s.section, s.name, withDays(s)));
  const addAll = () => run('*', () => st.addHabitsBulk(list.map(s => ({ section: s.section, name: s.name }))));

  const chip = (s) => (
    <button key={s.name} type="button" className="tap routines-chip" data-habit-suggest={s.section} onClick={() => addOne(s)} disabled={!!busyKey}
      aria-label={'Ajouter ' + routinesQuote(s.name)} style={{
        display: 'inline-flex', alignItems: 'center', gap: 6, minHeight: mobile ? 44 : 30, padding: mobile ? '6px 12px' : '4px 11px',
        borderRadius: 999, border: '1px solid var(--line-2)', background: 'var(--bg-card)', color: 'var(--fg)',
        fontSize: 12.5, fontFamily: 'inherit', cursor: 'pointer', textAlign: 'left', lineHeight: 1.3,
      }}>
      <span aria-hidden="true" style={{ color: 'var(--blue-600)', fontWeight: 700, fontSize: 14, lineHeight: 1 }}>+</span>
      <span>{busyKey === s.name ? 'Ajout…' : s.name}</span>
    </button>
  );
  const groups = meta.sections.map(sec => ({ sec, items: list.filter(s => s.section === sec) })).filter(g => g.items.length);

  return (
    <div data-habit-suggestions={kind} style={{ padding: mobile ? '12px 12px' : '14px 16px', borderRadius: 10, background: 'var(--bg-soft)', border: '1px solid var(--line)', marginBottom: 16 }}>
      <div style={{ fontSize: 13, lineHeight: 1.5, color: 'var(--fg)', marginBottom: list.length ? 10 : 0 }}>
        {empty ? meta.intro : 'Autres idées à ajouter en un clic' + ROUTINES_NB + ':'}
      </div>
      {groups.map((g, gi) => (
        <div key={g.sec} style={{ display: 'flex', alignItems: mobile ? 'stretch' : 'center', flexDirection: mobile ? 'column' : 'row', gap: mobile ? 6 : 10, marginTop: gi ? 8 : 0 }}>
          {meta.sections.length > 1 && (
            <span style={{ flex: 'none', width: mobile ? 'auto' : 58, fontSize: 10.5, fontWeight: 600, letterSpacing: '.08em', textTransform: 'uppercase', color: 'var(--fg-3)' }}>{SECTION_LABELS[g.sec]}</span>
          )}
          <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>{g.items.map(chip)}</div>
        </div>
      ))}
      {kind === 'pretrade' && list.length > 1 && typeof st.addHabitsBulk === 'function' && (
        <button type="button" className="btn tap" data-habit-suggest-all onClick={addAll} disabled={!!busyKey}
          style={{ marginTop: 12, padding: mobile ? '0 12px' : '6px 12px', minHeight: mobile ? 44 : 0, fontSize: 12.5, color: 'var(--fg)', justifyContent: 'center', width: mobile ? '100%' : 'auto' }}>
          {busyKey === '*' ? 'Ajout…' : 'Tout ajouter'}
        </button>
      )}
      <RoutinesInlineError text={error} style={{ marginTop: 8 }}/>
    </div>
  );
}

// ── Archives : Restaurer, Supprimer définitivement (avec confirmation) ──
function RoutinesArchives({ kind, st, mobile }) {
  const alive = useRoutinesAlive();
  const meta = ROUTINES_CARDS[kind];
  const list = (st.habitsArchived || []).filter(a => (a.section === 'pretrade') === (kind === 'pretrade'));
  const [open, setOpen] = React.useState(false);
  const [askId, setAskId] = React.useState(null);
  const [busy, setBusy] = React.useState(null); // { id, action }
  const [err, setErr] = React.useState(null);   // { id, msg }
  const guard = React.useRef(false);
  if (!list.length) return null;

  const run = async (id, action, fn) => {
    if (guard.current || typeof fn !== 'function') return;
    guard.current = true; setBusy({ id, action }); setErr(null);
    let res;
    try { res = await fn(id); } catch (e) { res = { error: e }; }
    guard.current = false;
    if (!alive.current) {
      if (res && res.error) {
        const a = list.find(x => x.id === id);
        routinesNotifyLost(st, 'L’habitude ' + routinesQuote(a && a.t ? a.t : 'archivée') + (action === 'remove' ? ' n’a pas été supprimée.' : ' n’a pas été restaurée.'), res.error);
      }
      return;
    }
    setBusy(null);
    if (res && res.error) { setErr({ id, msg: sbErrorMessage(res.error) }); return; }
    setAskId(null);
  };
  const smallBtn = { padding: mobile ? '0 12px' : '4px 10px', minHeight: mobile ? 44 : 0, fontSize: 12, justifyContent: 'center', color: 'var(--fg)' };

  return (
    <React.Fragment>
      <button type="button" className="tap" data-habit-archives={kind} aria-expanded={open ? 'true' : 'false'} onClick={() => setOpen(o => !o)}
        style={routinesLinkStyle(mobile, { display: 'flex', width: '100%', marginTop: 12, paddingTop: mobile ? 4 : 10, borderTop: '1px solid var(--line)', borderRadius: 0, minHeight: mobile ? 44 : 34 })}>
        <span aria-hidden="true" style={{ display: 'inline-flex', transform: open ? 'rotate(90deg)' : 'none', transition: 'transform .2s var(--ease)' }}>{Ico.chev}</span>
        {meta.archives + ' (' + list.length + ')'}
      </button>
      {open && (
        <div data-habit-archives-list={kind} style={{ display: 'flex', flexDirection: 'column', marginTop: 4 }}>
          {list.map((a, i) => {
            const counted = st.habitHistoryReady ? routinesCheckedText(a.doneCount || 0) : '';
            const details = [SECTION_LABELS[a.section] || '', a.archivedDay ? 'archivée le ' + fmtDayShort(a.archivedDay) : 'archivée', counted].filter(Boolean).join(' · ');
            const rowBusy = busy && busy.id === a.id;
            const rowErr = err && err.id === a.id ? err.msg : '';
            return (
              <div key={a.id} data-habit-archived={a.id} style={{ padding: '10px 0', borderTop: i ? '1px solid var(--line)' : 'none' }}>
                {askId === a.id ? (
                  <RoutinesConfirm danger label={'Supprimer ' + routinesQuote(a.t)}
                    text={'Supprimer ' + routinesQuote(a.t) + ' et tout son historique' + (counted ? ' (' + counted + ')' : '') + ' ? Cette action est définitive.'}
                    confirmLabel="Supprimer" busyLabel="Suppression…" busy={!!rowBusy} error={rowErr}
                    returnFocus={routinesAttr('data-habit-archived', a.id) + ' [data-habit-remove]'}
                    onConfirm={() => run(a.id, 'remove', st.removeHabit)} onCancel={() => { setAskId(null); setErr(null); }}/>
                ) : (
                  <div style={{ display: 'flex', alignItems: mobile ? 'stretch' : 'center', flexDirection: mobile ? 'column' : 'row', gap: mobile ? 8 : 12 }}>
                    <div style={{ flex: 1, minWidth: 0 }}>
                      <div style={{ fontSize: 13, color: 'var(--fg)', lineHeight: 1.35, overflowWrap: 'anywhere' }}>{a.t}</div>
                      <div style={{ fontSize: 11.5, color: 'var(--fg-3)', marginTop: 2, lineHeight: 1.4 }}>{details}</div>
                    </div>
                    <span style={{ display: 'flex', gap: 6, flexShrink: 0, flexWrap: 'wrap' }}>
                      <button type="button" className="btn tap" disabled={!!busy} onClick={() => run(a.id, 'restore', st.restoreHabit)} style={{ ...smallBtn, flex: mobile ? 1 : 'none' }}>
                        {rowBusy && busy.action === 'restore' ? 'Restauration…' : 'Restaurer'}
                      </button>
                      <button type="button" data-habit-remove className="btn tap" disabled={!!busy} onClick={() => { setErr(null); setAskId(a.id); }} style={{ ...smallBtn, flex: mobile ? 1 : 'none', color: 'var(--red-text)' }}>
                        Supprimer définitivement
                      </button>
                    </span>
                  </div>
                )}
                {askId !== a.id && <RoutinesInlineError text={rowErr} style={{ marginTop: 6 }}/>}
              </div>
            );
          })}
        </div>
      )}
    </React.Fragment>
  );
}

// ── Carte « Habitudes » ou « Vérifications avant trade » ─────────────
function RoutinesHabitCard({ kind, st, mobile, ui }) {
  const meta = ROUTINES_CARDS[kind];
  const today = st.today || todayISO();
  const ctx = st.habitCtx || null;
  const features = st.habitFeatures || {};
  const rows = st.habitRows || [];
  const rowById = React.useMemo(() => { const m = new Map(); rows.forEach(h => m.set(h.id, h)); return m; }, [rows]);
  const days = React.useMemo(() => {
    if (Array.isArray(st.recentDays) && st.recentDays.length === 7) return st.recentDays;
    const list = [];
    for (let i = 6; i >= 0; i--) list.push(habitShiftDay(today, -i));
    return list;
  }, [st.recentDays, today]);
  const groups = (st.habitsAll || []).filter(g => meta.sections.includes(g.sec));
  const total = groups.reduce((n, g) => n + g.items.length, 0);
  const activeRows = React.useMemo(
    () => rows.filter(h => !h.archived && meta.sections.includes(h.section)),
    [rows, meta]
  );
  const tally = React.useMemo(() => habitDayTally(activeRows, today, ctx), [activeRows, today, ctx]);
  const hasOffDays = !!features.days && groups.some(g => g.items.some(it => it.days && it.days.length < 7));
  const tradesState = st.habitTradesState || 'ready';
  const loading = !!st.habitsLoading && rows.length === 0;
  const failed = !!st.habitsError && rows.length === 0;
  const empty = !loading && !failed && total === 0;
  // Les suggestions restent proposées tant que l'écran est ouvert, une fois
  // qu'elles ont été montrées (sinon le premier ajout ferait disparaître les autres).
  const suggestLatch = React.useRef(false);
  if (empty) suggestLatch.current = true;

  let badge = null;
  if (!loading && !failed && total > 0) {
    if (tally.planned === 0) badge = <span style={{ fontSize: 12, color: 'var(--fg-2)', whiteSpace: 'nowrap' }}>Rien de prévu aujourd’hui</span>;
    else if (tally.done === tally.planned) badge = <span className="pill pill-green" style={{ padding: '3px 9px', fontSize: 11.5, fontWeight: 600, whiteSpace: 'nowrap' }}>{meta.allDone}</span>;
    else badge = <span style={{ fontSize: 12, color: 'var(--fg-2)', whiteSpace: 'nowrap' }}>{'Aujourd’hui' + ROUTINES_NB + ': '}<b style={{ color: 'var(--fg)', fontWeight: 600 }}>{routinesSur(tally.done, tally.planned)}</b></span>;
  }

  return (
    <div className="card" data-habit-card={kind} aria-busy={loading ? 'true' : undefined} style={{ padding: mobile ? '16px 14px 14px' : '20px 22px 18px', marginBottom: mobile ? 14 : 16 }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
        <CardTitle style={{ color: 'var(--fg)' }}>{meta.title}</CardTitle>
        {badge}
      </div>
      <div style={{ fontSize: 12, lineHeight: 1.45, color: 'var(--fg-2)', margin: '4px 0 14px' }}>{meta.subtitle}</div>

      {loading && <RoutinesSkeleton label={meta.loadingLabel} mobile={mobile}/>}
      {failed && (
        <div style={{ fontSize: 12.5, lineHeight: 1.5, color: 'var(--fg-2)', padding: '6px 0' }}>
          {kind === 'pretrade' ? 'Tes vérifications s’afficheront ici dès que la connexion sera rétablie.' : 'Tes habitudes s’afficheront ici dès que la connexion sera rétablie.'}
        </div>
      )}

      {/* Trades jamais chargés : les vérifications ne peuvent pas être comptées, on le dit ici. */}
      {kind === 'pretrade' && !loading && !failed && total > 0 && tradesState === 'error' && (
        <div data-habit-trades-error style={{ marginBottom: 12 }}>
          <HabitLoadError onRetry={typeof st.refreshTrades === 'function' ? st.refreshTrades : undefined}
            text="Impossible de charger tes trades : tes vérifications ne sont pas comptées pour l’instant."/>
        </div>
      )}

      {!loading && !failed && (
        <React.Fragment>
          {suggestLatch.current && <RoutinesSuggestions kind={kind} st={st} features={features} mobile={mobile} empty={empty}/>}
          {groups.map((g, gi) => {
            const secRows = activeRows.filter(h => h.section === g.sec);
            const secTally = habitDayTally(secRows, today, ctx);
            return (
              <div key={g.sec} data-habit-section={g.sec} style={{ marginTop: gi ? (mobile ? 18 : 20) : 0 }}>
                <RoutinesSectionHead label={g.g} tally={secTally} days={days} today={today} mobile={mobile} withColumns={g.items.length > 0}/>
                {g.items.map((it, i) => (
                  <React.Fragment key={it.id}>
                    <RoutinesHabitRow item={it} row={rowById.get(it.id)} section={g.sec} days={days} today={today} ctx={ctx}
                      features={features} ready={!!st.habitHistoryReady} tradesState={tradesState} mobile={mobile}
                      isLast={i === g.items.length - 1 || ui.editId === it.id}
                      editing={ui.editId === it.id} onEdit={() => ui.toggleEdit(it.id)} onToggle={ui.onToggle}
                      error={ui.rowErr && ui.rowErr.id === it.id ? ui.rowErr.msg : ''}/>
                    {ui.editId === it.id && (
                      <RoutinesEditPanel item={it} st={st} features={features} index={i} count={g.items.length} mobile={mobile}
                        openSeq={ui.openSeq} onError={(msg, lost, error) => ui.reportError(it.id, msg, lost, error)}
                        onClose={(gone, seq) => ui.closeEdit(it.id, gone, seq)}/>
                    )}
                  </React.Fragment>
                ))}
                <RoutinesAddForm section={g.sec} st={st} mobile={mobile}/>
              </div>
            );
          })}
          {total > 0 && (
            <div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--line)' }}>
              {/* « Fait, non compté » (§5.5) : mêmes mots que la légende des Statistiques. */}
              <HabitLegend mode="marks" future={false} pretrade={kind === 'pretrade'} extra={kind === 'pretrade' || hasOffDays}/>
            </div>
          )}
        </React.Fragment>
      )}
      <RoutinesArchives kind={kind} st={st} mobile={mobile}/>
    </div>
  );
}

// ── Corps commun ordinateur / mobile ─────────────────────────────────
function RoutinesBody({ state, mobile, navStats }) {
  const st = state || {};
  const alive = useRoutinesAlive();
  const [editId, setEditId] = React.useState(null);
  // Dernier panneau ouvert, lisible depuis une réponse d'écriture arrivée après coup.
  const editIdRef = React.useRef(null);
  editIdRef.current = editId;
  // Numéro de l'ouverture en cours : il change à chaque action de l'utilisateur
  // sur un bouton « Modifier » (ouvrir, fermer, passer à une autre habitude),
  // jamais quand un panneau est seulement remonté (changement de moment).
  const openSeqRef = React.useRef(0);
  // { id, msg, edit } — erreur d'écriture sous la ligne ; edit : venue d'un
  // panneau « Modifier » qui n'est plus à l'écran (effacée en rouvrant le panneau).
  const [rowErr, setRowErr] = React.useState(null);
  const wrapRef = React.useRef(null);
  const width = useRoutinesWidth(wrapRef);
  // Deux colonnes dès 960 px de contenu, soit 1 240 px de fenêtre menu déplié
  // (232 px de menu et 2 × 24 px de marges déduits). En dessous, la grille des
  // jours (356 px fixes) ne laisse plus assez de place aux noms d'habitude et
  // aux titres de moment : une seule colonne.
  const twoCols = !mobile && (width ? width >= 960 : (typeof window !== 'undefined' && window.innerWidth >= 1240));

  // Une habitude qui disparaît (archivée ailleurs, rechargement) referme son panneau.
  const activeIds = React.useMemo(() => {
    const s = new Set();
    (st.habitsAll || []).forEach(g => g.items.forEach(it => s.add(it.id)));
    return s;
  }, [st.habitsAll]);
  React.useEffect(() => { if (editId !== null && !activeIds.has(editId)) setEditId(null); }, [editId, activeIds]);

  const ui = {
    editId, rowErr,
    toggleEdit: (id) => {
      openSeqRef.current++;
      setRowErr(e => (e && e.edit && e.id === id ? null : e));
      setEditId(cur => (cur === id ? null : id));
    },
    openSeq: () => openSeqRef.current,
    // Ne ferme que le panneau de CETTE habitude, s'il est encore ouvert : une
    // réponse d'enregistrement qui arrive après sa fermeture ou après
    // l'ouverture d'un autre panneau ne doit ni fermer ni prendre le focus.
    // `seq` (fourni par une réponse d'écriture, absent pour Annuler et Échap)
    // est le numéro de l'ouverture qui a envoyé l'écriture : si l'habitude a
    // été refermée puis rouverte entre-temps, le panneau à l'écran est une
    // autre ouverture, avec peut-être une saisie en cours — on n'y touche pas.
    closeEdit: (id, gone, seq) => {
      if (editIdRef.current !== id) return;
      if (seq !== undefined && seq !== openSeqRef.current) return;
      openSeqRef.current++;
      setEditId(null);
      if (!gone) routinesFocusEdit(id);
    },
    // Échec d'une écriture dont le panneau n'est plus à l'écran : sous la ligne.
    // Routines quitté entre-temps : avis commun de l'application (`lost` nomme
    // l'habitude, `error` donne la cause).
    reportError: (id, msg, lost, error) => {
      if (alive.current) setRowErr({ id, msg, edit: true });
      else if (lost) routinesNotifyLost(st, lost, error);
    },
    onToggle: async (id, iso) => {
      if (typeof st.toggleHabit !== 'function') return;
      setRowErr(e => (e && e.id === id ? null : e));
      let res;
      try { res = await st.toggleHabit(id, iso); } catch (e) { res = { error: e }; }
      if (!(res && res.error)) return;
      if (alive.current) { setRowErr({ id, msg: sbErrorMessage(res.error) }); return; }
      const row = (st.habitRows || []).find(h => h.id === id);
      routinesNotifyLost(st, 'La coche de ' + routinesQuote(row && row.name ? row.name : 'cette habitude') + ' n’a pas été enregistrée.', res.error);
    },
  };

  return (
    <div ref={wrapRef} data-routines={mobile ? 'mobile' : 'desktop'}>
      {st.habitsError && (
        <div style={{ marginBottom: mobile ? 14 : 16 }}>
          <HabitLoadError onRetry={st.reloadHabits} text={typeof st.habitsError === 'string' ? st.habitsError : undefined}/>
        </div>
      )}
      <RoutinesToday st={st} mobile={mobile} navStats={navStats}/>
      <div style={{ display: 'grid', gridTemplateColumns: twoCols ? 'minmax(0, 1.35fr) minmax(0, 1fr)' : 'minmax(0, 1fr)', gap: twoCols ? 16 : 0, alignItems: 'start' }}>
        <div style={{ minWidth: 0 }}>
          <RoutinesHabitCard kind="habits" st={st} mobile={mobile} ui={ui}/>
          <RoutinesHabitCard kind="pretrade" st={st} mobile={mobile} ui={ui}/>
        </div>
        {/* Agenda — vue semaine + calendrier externe */}
        <AgendaWeekCard state={st} mobile={mobile}/>
      </div>
    </div>
  );
}

// Les props nav, navStats et openJournal viennent d'app.jsx ; l'écran fonctionne sans.
function RoutinesPage({ state, nav, navStats, openJournal }) {
  return (
    <div className="scroll" style={{ width: '100%', height: '100%', overflow: 'auto' }}>
      <PageHeader title="Routines"/>
      <div style={{ ...PAGE_BODY, padding: '20px 24px 40px' }}>
        <RoutinesBody state={state} navStats={navStats}/>
      </div>
    </div>
  );
}

// ─── Version mobile (< 900 px) ────────────────────────────────────────
// Même contenu et mêmes sections que RoutinesPage, sans accordéon.
function RoutinesMobile({ state, nav, navStats, openJournal }) {
  return (
    <div style={{ padding: '14px var(--m-gutter) calc(28px + env(safe-area-inset-bottom))' }}>
      <h1 style={{ fontSize: 24, fontWeight: 700, letterSpacing: '-.025em', margin: '0 0 14px' }}>Routines</h1>
      <RoutinesBody state={state} navStats={navStats} mobile/>
    </div>
  );
}

Object.assign(window, {
  RoutinesPage, RoutinesMobile, AgendaWeekCard, AGENDA_TAGS, agendaTagLabel,
  // exposés pour les tests : parseIcs (bloc pur ICS-PARSER) ; helpers de dates et
  // d'URL de l'agenda (hors bloc : les libellés passent par format-prefs.jsx)
  parseIcs, normalizeIcsUrl, icsEventsToRows, agendaWeekStart, agendaAddDays, agendaWeekLabel, agendaDayLabel,
});
