// Vanta — shared pieces: the task list (with AI/manual breakdowns), folds,
// the habit-rhythm calendar, and the mood face. Used by the station page and
// the review page. Components attach to window (no bundler).

// Height-tweened reveal. Mounts children on open, measures, animates to
// their height, then settles on 'auto' so content that grows while open
// never clips; collapses back to 0 before unmounting.
function VReveal({ open, children, style }) {
  const ref = React.useRef(null);
  const [mounted, setMounted] = React.useState(open);
  const [h, setH] = React.useState(open ? 'auto' : 0);
  const first = React.useRef(true);
  React.useEffect(() => { if (open) setMounted(true); }, [open]);
  React.useEffect(() => {
    if (first.current) { first.current = false; return; }
    const el = ref.current;
    if (!el) return;
    if (open) {
      if (!mounted) return;
      setH(el.scrollHeight);
      const t = setTimeout(() => setH('auto'), 380);
      return () => clearTimeout(t);
    }
    if (!mounted) return;
    setH(el.scrollHeight);
    let raf2 = 0;
    const raf = requestAnimationFrame(() => { raf2 = requestAnimationFrame(() => setH(0)); });
    const t = setTimeout(() => setMounted(false), 380);
    return () => { cancelAnimationFrame(raf); cancelAnimationFrame(raf2); clearTimeout(t); };
  }, [open, mounted]);
  return (
    <div aria-hidden={!open} style={{
      height: h, overflow: h === 'auto' ? 'visible' : 'hidden',
      opacity: open ? 1 : 0,
      transition: 'height .36s cubic-bezier(.4,0,.2,1), opacity .3s ease',
      ...style,
    }}>
      <div ref={ref}>{mounted ? children : null}</div>
    </div>
  );
}

function VChevron({ open, color }) {
  return (
    <svg className="v-chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.2"
      strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"
      style={{ transform: open ? 'rotate(180deg)' : 'none', flex: '0 0 auto' }}>
      <path d="M6 9l6 6 6-6" />
    </svg>
  );
}

// A folded card: one bar (label · summary · tend/tuck) that grows into a
// body when open. Rhythm, PM Track and the resting areas fold this way so
// history stays one tap away without sitting on the page. `plain` bodies
// (a grid of tiles) skip the card wrapper.
function VFold({ P, label, labelColor, summary, summaryColor, open, onToggle, flush, style, plain = false, children }) {
  const S = vStyles(P);
  return (
    <section style={{ margin: flush ? 0 : '14px 20px 0', ...style }}>
      <button onClick={onToggle} aria-expanded={open} className="v-fold" style={{
        appearance: 'none', border: 0, cursor: 'pointer', width: '100%', textAlign: 'left', boxSizing: 'border-box',
        background: P.tile, padding: '13px 18px', fontFamily: 'inherit',
        borderRadius: open && !plain ? '18px 18px 0 0' : 18,
        display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, color: P.ink2,
        transition: 'border-radius .2s ease',
      }}>
        <span style={{ ...S.serif, fontSize: 16, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
          <span style={{ color: labelColor || P.ink }}>{label}</span>
          {summary && <span style={{ ...S.caps, color: summaryColor || P.ink4, marginLeft: 10 }}>{summary}</span>}
        </span>
        <span style={{ ...S.caps, color: P.ink3, flex: '0 0 auto', display: 'flex', alignItems: 'center', gap: 6 }}>
          {open ? 'tuck' : 'tend'} <VChevron open={open} color={P.ink3} />
        </span>
      </button>
      <VReveal open={open}>
        {plain
          ? <div style={{ paddingTop: 12 }}>{children}</div>
          : <div style={{ background: P.tile, borderRadius: '0 0 18px 18px', padding: '2px 18px 18px', boxSizing: 'border-box' }}>{children}</div>}
      </VReveal>
    </section>
  );
}

function vCloneTask(t) { return { ...t, steps: (t.steps || []).map(s => ({ ...s })) }; }

// Shared check circle for task + step rows.
function VCheck({ P, done, color, size = 20 }) {
  return (
    <span style={{
      width: size, height: size, borderRadius: '50%', flex: '0 0 auto',
      border: `1.2px solid ${done ? color : P.ink3}`,
      background: done ? color : 'transparent',
      display: 'grid', placeItems: 'center', transition: 'background .15s',
    }}>
      {done && <svg width={size * 0.55} height={size * 0.55} viewBox="0 0 24 24" fill="none" stroke={P.bg} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>}
    </span>
  );
}

// The AI (or manual) breakdown panel for one task. mode='ai' auto-drafts steps
// + up to 3 follow-up questions (answering refines once); mode='manual' is a
// plain step editor. An unreachable model degrades to manual entry with an
// honest note — never a fabricated breakdown.
function VBreakdown({ P, task, mode, cacheRef, onSaved, onClose }) {
  const S = vStyles(P);
  const seed = (task.steps || []).map(s => s.text);
  const c = (cacheRef && cacheRef.current) || null;   // preserved across collapse/reopen
  const [loading, setLoading] = React.useState(false);
  const [steps, setSteps] = React.useState(c ? c.steps : seed);
  const [questions, setQuestions] = React.useState(c ? c.questions : []);
  const [answers, setAnswers] = React.useState(c ? c.answers : {});
  const [note, setNote] = React.useState(c ? c.note : '');
  const [degraded, setDegraded] = React.useState(c ? c.degraded : false);
  const [manual, setManual] = React.useState(c ? c.manual : (mode === 'manual'));
  const [manualText, setManualText] = React.useState(c ? c.manualText : seed.join('\n'));
  const [remember, setRemember] = React.useState(c ? c.remember : false);
  const drafted = React.useRef(c ? c.drafted : false);

  // Persist the working state so collapsing/reopening the row keeps edits (and
  // doesn't re-call the model).
  React.useEffect(() => {
    if (cacheRef) cacheRef.current = { steps, questions, answers, note, degraded, manual, manualText, remember, drafted: drafted.current };
  });

  const call = (qa) => {
    setLoading(true); setNote(''); drafted.current = true;
    fetch('/vanta/task/breakdown', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text: task.text, qa: qa || undefined }),
    }).then(r => (r.ok ? r.json() : null)).then(d => {
      if (!d || d.degraded) {
        setDegraded(true); setManual(true);
        setNote((d && d.note) || 'Couldn’t reach the assistant — add steps yourself below.');
        return;
      }
      setNote(d.note || '');
      if (d.steps && d.steps.length) setSteps(d.steps);
      setQuestions(d.questions || []);
    }).catch(() => { setDegraded(true); setManual(true); setNote('Couldn’t reach the assistant — add steps yourself below.'); })
      .finally(() => setLoading(false));
  };
  React.useEffect(() => { if (mode === 'ai' && !drafted.current) call(null); }, []);   // auto-start once
  const refine = () => call(questions.map(q => ({ id: q.id, q: q.q, a: answers[q.id] || '' })));

  const collect = () => (manual
    ? manualText.split('\n').map(s => s.trim()).filter(Boolean)
    : steps.map(s => String(s).trim()).filter(Boolean));

  const save = () => {
    const s = collect();
    if (!s.length) return;
    fetch(`/vanta/task/${task.id}/steps`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ steps: s }),
    }).then(r => (r.ok ? r.json() : null)).then(d => {
      if (d && d.steps) onSaved(d.steps);
      if (remember) vantaPost('/vanta/framework', { name: task.text, steps: s });
    }).catch(() => {});
  };

  const box = { background: P.tile, borderRadius: 10, padding: '5px 10px', border: 0, outline: 'none', fontFamily: 'inherit', fontSize: 13, color: P.ink, width: '100%', boxSizing: 'border-box' };
  const chip = (label, onClick, primary) => (
    <button onClick={onClick} style={{
      appearance: 'none', border: primary ? 0 : `0.5px solid ${P.ink3}`, cursor: 'pointer', fontFamily: 'inherit',
      padding: '5px 11px', borderRadius: 999, fontSize: 11.5, fontWeight: 500,
      background: primary ? P.ink : 'transparent', color: primary ? P.bg : P.ink2,
    }}>{label}</button>
  );
  const showSave = !loading && (manual || steps.length > 0);

  return (
    <div style={{ marginTop: 8, padding: '10px 12px', background: P.bg2 || P.tile, borderRadius: 12 }}>
      {loading && <div style={{ fontSize: 12.5, color: P.ink3 }}>thinking it through…</div>}
      {note && <div style={{ fontSize: 11.5, color: degraded ? P.rust : P.ink3, marginBottom: 8 }}>{note}</div>}

      {!loading && !manual && questions.length > 0 && (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 10 }}>
          <div style={{ ...S.caps, color: P.sky }}>a couple of questions</div>
          {questions.map(q => (
            <div key={q.id}>
              <div style={{ fontSize: 12.5, color: P.ink2, marginBottom: 3 }}>{q.q}</div>
              {q.kind === 'choice' && Array.isArray(q.choices) ? (
                <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
                  {q.choices.map(c => (
                    <button key={c} onClick={() => setAnswers(a => ({ ...a, [q.id]: c }))} style={{
                      appearance: 'none', border: `0.5px solid ${answers[q.id] === c ? P.sky : P.ink3}`, cursor: 'pointer',
                      padding: '4px 9px', borderRadius: 999, fontSize: 11.5, fontFamily: 'inherit',
                      background: answers[q.id] === c ? P.sky : 'transparent', color: answers[q.id] === c ? P.bg : P.ink2,
                    }}>{c}</button>
                  ))}
                </div>
              ) : (
                <input value={answers[q.id] || ''} onChange={e => setAnswers(a => ({ ...a, [q.id]: e.target.value }))}
                  placeholder="your answer (optional)" style={box} />
              )}
            </div>
          ))}
          <div>{chip('refine the steps', refine, true)}</div>
        </div>
      )}

      {!loading && !manual && steps.length > 0 && (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 5, marginBottom: 8 }}>
          {steps.map((s, i) => (
            <div key={i} style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 6, alignItems: 'center' }}>
              <input value={s} onChange={e => setSteps(st => st.map((x, j) => j === i ? e.target.value : x))} style={box} />
              <button onClick={() => setSteps(st => st.filter((_, j) => j !== i))} aria-label="remove step" style={{ appearance: 'none', border: 0, background: 'transparent', color: P.ink4, cursor: 'pointer', fontSize: 15 }}>×</button>
            </div>
          ))}
          {chip('+ step', () => setSteps(st => [...st, '']))}
        </div>
      )}

      {!loading && manual && (
        <textarea value={manualText} onChange={e => setManualText(e.target.value)} rows={4}
          placeholder="one step per line…" style={{ ...box, resize: 'vertical', lineHeight: 1.5, marginBottom: 8 }} />
      )}

      {showSave && (
        <React.Fragment>
          <label style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: 11.5, color: P.ink3, margin: '2px 0 8px' }}>
            <input type="checkbox" checked={remember} onChange={e => setRemember(e.target.checked)} />
            remember these as a framework for next time
          </label>
          <div style={{ display: 'flex', gap: 8 }}>
            {chip('save steps', save, true)}
            {chip('cancel', onClose)}
          </div>
        </React.Fragment>
      )}
    </div>
  );
}

// One task row: check circle, text, carried marker, step progress, expand for
// the breakdown. Optimistic; the server reconciles via vanta:changed refetch.
function VTaskRow({ P, task, expanded, onExpand, offer, onUseFramework, onDismissOffer, patch, remove }) {
  const S = vStyles(P);
  const [showBd, setShowBd] = React.useState(false);
  const [bdMode, setBdMode] = React.useState('ai');
  const bdRef = React.useRef(null);   // preserves breakdown edits across collapse/reopen
  const steps = task.steps || [];
  const doneSteps = steps.filter(s => s.done).length;

  const toggleTask = () => {
    const next = !task.done;
    patch(task.id, t => ({ ...t, done: next, steps: (t.steps || []).map(s => ({ ...s, done: next })) }));
    vantaPost('/vanta/task/toggle', { id: task.id, done: next });
  };
  const toggleStep = (s) => {
    // Optimistic framework/breakdown steps carry temp ids until the server
    // returns real ones; a toggle against 'tmp0' would 400. Ignore until the
    // save resolves (a beat later) and real ids arrive.
    if (String(s.id).startsWith('tmp')) return;
    const nd = !s.done;
    patch(task.id, t => {
      const ns = (t.steps || []).map(x => x.id === s.id ? { ...x, done: nd } : x);
      const allDone = ns.length > 0 && ns.every(x => x.done);
      return { ...t, steps: ns, done: allDone };
    });
    vantaPost('/vanta/task/step', { id: s.id, done: nd });
  };
  const del = () => { remove(task.id); vantaFetchDelete(`/vanta/task/${task.id}`); };

  return (
    <div style={{ background: expanded ? (P.bg2 || P.tile) : 'transparent', borderRadius: 12, padding: expanded ? '4px 8px 8px' : 0, transition: 'background .15s' }}>
      <div style={{ display: 'grid', gridTemplateColumns: '22px 1fr auto', alignItems: 'center', gap: 10, minHeight: 34 }}>
        <button onClick={toggleTask} aria-label={task.done ? 'mark undone' : 'complete'} style={{ appearance: 'none', border: 0, background: 'transparent', cursor: 'pointer', padding: 0 }}>
          <VCheck P={P} done={task.done} color={P.sky} />
        </button>
        <button onClick={onExpand} style={{ appearance: 'none', border: 0, background: 'transparent', cursor: 'pointer', textAlign: 'left', padding: 0, minWidth: 0 }}>
          <span style={{ fontSize: 14.5, color: task.done ? P.ink4 : P.ink, textDecoration: task.done ? 'line-through' : 'none', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{task.text}</span>
          <span style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 1 }}>
            {task.carried && <span style={{ ...S.caps, fontSize: 8.5, color: P.rust }}>carried over</span>}
            {steps.length > 0 && <span style={{ ...S.num, fontSize: 10.5, color: P.ink3 }}>{doneSteps}/{steps.length} steps</span>}
          </span>
        </button>
        <span style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
          <button onClick={onExpand} aria-label="expand" style={{ appearance: 'none', border: 0, background: 'transparent', color: P.ink4, cursor: 'pointer', padding: 3, transform: expanded ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }}>
            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round"><path d="M9 6l6 6-6 6" /></svg>
          </button>
          <button onClick={del} aria-label="delete task" style={{ appearance: 'none', border: 0, background: 'transparent', color: P.ink4, cursor: 'pointer', padding: 3, fontSize: 15, lineHeight: 1 }}>×</button>
        </span>
      </div>

      {expanded && (
        <div style={{ paddingLeft: 32 }}>
          {steps.length > 0 && (
            <div style={{ display: 'flex', flexDirection: 'column', gap: 3, marginTop: 4 }}>
              {steps.map(s => (
                <button key={s.id} onClick={() => toggleStep(s)} style={{ appearance: 'none', border: 0, background: 'transparent', cursor: 'pointer', display: 'grid', gridTemplateColumns: '16px 1fr', gap: 8, alignItems: 'center', textAlign: 'left', padding: '2px 0', width: '100%' }}>
                  <VCheck P={P} done={s.done} color={P.accent} size={15} />
                  <span style={{ fontSize: 13, color: s.done ? P.ink4 : P.ink2, textDecoration: s.done ? 'line-through' : 'none' }}>{s.text}</span>
                </button>
              ))}
            </div>
          )}
          {offer && (
            <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', margin: '8px 0 2px' }}>
              <button onClick={() => onUseFramework(offer)} style={{ appearance: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', padding: '5px 11px', borderRadius: 999, fontSize: 11.5, fontWeight: 500, background: P.accent, color: P.bg }}>
                use {offer.seeded ? 'starter' : 'framework'}: {offer.name} ({offer.steps.length} steps)
              </button>
              <button onClick={onDismissOffer} aria-label="dismiss" style={{ appearance: 'none', border: 0, background: 'transparent', color: P.ink4, cursor: 'pointer', fontSize: 12 }}>skip</button>
            </div>
          )}
          {!showBd && steps.length === 0 && (
            <div style={{ display: 'flex', gap: 8, marginTop: 6 }}>
              <button onClick={() => { setBdMode('ai'); setShowBd(true); }} style={{ appearance: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', padding: '5px 11px', borderRadius: 999, fontSize: 11.5, fontWeight: 500, color: P.bg, background: P.ink }}>break it down</button>
              <button onClick={() => { setBdMode('manual'); setShowBd(true); }} style={{ appearance: 'none', border: `0.5px solid ${P.ink3}`, cursor: 'pointer', fontFamily: 'inherit', padding: '5px 11px', borderRadius: 999, fontSize: 11.5, color: P.ink2, background: 'transparent' }}>add steps</button>
            </div>
          )}
          {!showBd && steps.length > 0 && (
            <button onClick={() => { setBdMode('manual'); setShowBd(true); }} style={{ appearance: 'none', border: 0, background: 'transparent', color: P.ink4, cursor: 'pointer', fontSize: 11.5, marginTop: 6, padding: 0 }}>edit steps</button>
          )}
          {showBd && (
            <VBreakdown P={P} task={task} mode={bdMode} cacheRef={bdRef}
              onClose={() => { setShowBd(false); bdRef.current = null; }}
              onSaved={fresh => { patch(task.id, t => ({ ...t, steps: fresh, done: false })); setShowBd(false); bdRef.current = null; }} />
          )}
        </div>
      )}
    </div>
  );
}

// The real daily task list (server mode): add-input + task rows. Owns local
// React state so taps re-render immediately, and reconciles to server truth on
// vanta:changed (rollover, step→parent coupling, framework steps).
function VTaskList({ P, tasks, setTasks }) {
  const [text, setText] = React.useState('');
  const [expandedId, setExpandedId] = React.useState(null);
  const [offer, setOffer] = React.useState(null);
  const inputRef = React.useRef(null);
  const list = tasks || [];

  const patch = (id, fn) => setTasks(prev => (prev || []).map(t => t.id === id ? fn(t) : t));
  const remove = (id) => setTasks(prev => (prev || []).filter(t => t.id !== id));

  const add = () => {
    const t = text.trim();
    if (!t) return;
    setText('');
    fetch('/vanta/task', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: t }) })
      .then(r => (r.ok ? r.json() : null)).then(d => {
        if (!d || !d.task) return;
        setTasks(prev => (prev || []).some(x => x.id === d.task.id) ? prev : [...(prev || []), vCloneTask(d.task)]);
        if (d.framework) { setOffer({ taskId: d.task.id, framework: d.framework }); setExpandedId(d.task.id); }
      }).catch(() => {}).finally(() => { if (inputRef.current) inputRef.current.focus(); });
  };

  const useFramework = (taskId, fw) => {
    patch(taskId, t => ({ ...t, steps: fw.steps.map((s, i) => ({ id: `tmp${i}`, text: s, done: false })), done: false }));
    setOffer(null);
    fetch(`/vanta/task/${taskId}/steps`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ steps: fw.steps, frameworkId: fw.id }) })
      .then(r => (r.ok ? r.json() : null)).then(d => { if (d && d.steps) patch(taskId, t => ({ ...t, steps: d.steps })); }).catch(() => {});
  };

  return (
    <div>
      <div style={{ display: 'flex', gap: 8, marginBottom: list.length ? 10 : 6 }}>
        <input ref={inputRef} value={text} onChange={e => setText(e.target.value)}
          onKeyDown={e => { if (e.key === 'Enter') add(); if (e.key === 'Escape') setText(''); }}
          placeholder="add a task for today…"
          style={{ flex: 1, minWidth: 0, appearance: 'none', border: 0, outline: 'none', background: P.tile, borderRadius: 10, padding: '9px 12px', fontFamily: 'inherit', fontSize: 14, color: P.ink }} />
        <button onClick={add} aria-label="add task" style={{ appearance: 'none', border: 0, cursor: 'pointer', width: 36, borderRadius: 10, background: P.ink, color: P.bg, display: 'grid', placeItems: 'center' }}>
          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 5v14M5 12h14" /></svg>
        </button>
      </div>
      {list.length === 0 && <div style={{ fontSize: 13, color: P.ink3, padding: '2px 0 6px' }}>nothing yet — add today’s first task above.</div>}
      <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 340, overflowY: 'auto' }}>
        {list.map(t => (
          <VTaskRow key={t.id} P={P} task={t}
            expanded={expandedId === t.id} onExpand={() => setExpandedId(expandedId === t.id ? null : t.id)}
            offer={offer && offer.taskId === t.id ? offer.framework : null}
            onUseFramework={fw => useFramework(t.id, fw)} onDismissOffer={() => setOffer(null)}
            patch={patch} remove={remove} />
        ))}
      </div>
    </div>
  );
}

const V_MAGENTA = '#c026d3';

// Local YYYY-MM-DD (not toISOString — that's UTC and would shift the
// calendar a day for anyone west of Greenwich).
function vLocalISO(d) {
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}

// Contiguous list of YYYY-MM-DD strings from startISO..endISO inclusive.
// Anchored at local noon so day-stepping never trips DST or a UTC shift.
function vIsoRange(startISO, endISO) {
  const out = [];
  const cur = new Date(startISO + 'T12:00:00');
  const end = new Date(endISO + 'T12:00:00');
  while (cur <= end) { out.push(vLocalISO(cur)); cur.setDate(cur.getDate() + 1); }
  return out;
}

// The rhythm card: streak, a consistency line, and the magenta calendar.
// Encoding is literal — a day's square carries opacity equal to the share of
// habits kept (a 20% day is 20% opacity), a full day is a full square, and a
// full day with objectives shipped on top gets a soft aura. A day that was
// tracked but kept nothing shows a faint dot (an honest zero); a day with no
// record at all stays an empty outline (absence, never a fabricated zero).
// Day boundaries follow the SERVER's clock (data.today) — the same one habits
// are logged under — so today's square fills the moment you check a habit.
// Habit history. With `fold` it collapses to one bar (rhythm · streak) and
// fetches only when opened; the week horizon renders it open.
function VRhythm({ P, flush, fold = false, open = true, onToggle, style }) {
  const S = vStyles(P);
  const [data, setData] = React.useState(window.VANTA_RHYTHM || null);
  const shown = !fold || open;

  React.useEffect(() => {
    if (!shown) return;
    let alive = true;
    const load = () => fetch('/vanta/rhythm?days=70')
      .then(r => (r.ok ? r.json() : null))
      .then(d => { window.VANTA_RHYTHM = d || null; if (alive) setData(d || undefined); })
      .catch(() => { if (alive) setData(undefined); });
    if (window.VANTA_RHYTHM) setData(window.VANTA_RHYTHM); else load();
    // A habit/objective/chore write invalidates the cache elsewhere and fires
    // this — so today's square and the streak update the moment you act.
    const onChanged = () => load();
    window.addEventListener('vanta:changed', onChanged);
    return () => { alive = false; window.removeEventListener('vanta:changed', onChanged); };
  }, [shown]);

  const total = data?.total || 0;
  const byDay = new Map((data?.days || []).map(r => [r.d, r]));
  const todayISO = (data && data.today) || vLocalISO(new Date());

  // 8 calendar-aligned weeks, sun→sat columns, bottom row is this week —
  // walked back from the server's today so cell keys match the row keys.
  const anchor = new Date(todayISO + 'T12:00:00');
  const start = new Date(anchor);
  start.setDate(anchor.getDate() - anchor.getDay() - 49);
  const cells = [];
  for (let i = 0; i < 56; i++) {
    const d = new Date(start);
    d.setDate(start.getDate() + i);
    const iso = vLocalISO(d);
    const rec = byDay.get(iso);
    const done = rec ? rec.done : 0;
    const pct = total > 0 ? Math.min(1, done / total) : 0;
    cells.push({
      iso, future: iso > todayISO, today: iso === todayISO,
      has: !!rec, done, extras: rec ? rec.extras : 0, pct,
    });
  }

  // Consistency line over CONTIGUOUS calendar days (first record → last
  // record), so gaps read as dips instead of collapsing out of existence.
  // Fixed [0,1] domain so a steady 60% sits at 60% height, not on the floor.
  const recDays = data?.days || [];
  let line = [];
  if (recDays.length && total > 0) {
    const range = vIsoRange(recDays[0].d, recDays[recDays.length - 1].d).slice(-42);
    line = range.map(iso => { const r = byDay.get(iso); return r ? Math.min(1, r.done / total) : 0; });
  }
  const streak = data?.streak || 0;
  const anyLogged = recDays.some(r => r.done > 0);
  const streakText = data ? (streak > 0 ? `${vantaNumWord(streak)}-day streak` : 'no streak yet')
    : null;

  const legend = 'full square = all kept · shine = went beyond · fade = partial · dot = kept none · empty = untracked';
  const body = (
    <React.Fragment>
      {data === undefined && (
        <div style={{ fontSize: 12.5, color: P.ink3, marginTop: 10 }}>
          the garden can't reach habit history right now — nothing to show, honestly.
        </div>
      )}
      {data === null && <div style={{ fontSize: 12.5, color: P.ink3, marginTop: 10 }}>reading the log…</div>}

      {data && !anyLogged && (
        <div style={{ ...S.serif, fontSize: 14, color: P.ink3, marginTop: 10 }}>
          no rhythm on record yet — keep one habit today and the first square appears.
        </div>
      )}

      {data && anyLogged && (
        <React.Fragment>
          <div style={{ display: 'flex', gap: 20, flexWrap: 'wrap', alignItems: 'flex-end', marginTop: 10 }}>
            {line.length > 1 && (
              <div style={{ flex: '1 1 230px', minWidth: 0 }}>
                <div style={{ ...S.caps, color: P.ink4, marginBottom: 4 }}>consistency · % of habits kept, by day</div>
                <Spark data={line} w={280} h={34} stroke={V_MAGENTA} fill={V_MAGENTA + '1e'} strokeWidth={1.4} domain={[0, 1]} />
              </div>
            )}
            <div style={{ flex: '0 1 240px' }} title={legend}>
              <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 4 }}>
                {['s', 'm', 't', 'w', 't', 'f', 's'].map((w, i) => (
                  <span key={i} style={{ ...S.caps, fontSize: 8.5, textAlign: 'center', color: P.ink4 }}>{w}</span>
                ))}
                {cells.map(c => {
                  if (c.future) return <span key={c.iso} style={{ aspectRatio: '1' }} />;
                  const aura = c.pct === 1 && c.extras > 0;
                  const zeroKept = c.has && c.done === 0;      // tracked, kept nothing
                  const title = c.has
                    ? `${c.iso} · ${c.done}/${total} kept${c.extras > 0 ? ` · +${c.extras} beyond` : ''}`
                    : `${c.iso} · nothing logged`;
                  return (
                    <span key={c.iso} title={title} style={{
                      aspectRatio: '1', borderRadius: 4, position: 'relative',
                      boxShadow: [
                        c.pct === 0 ? `inset 0 0 0 1px ${P.rule}` : null,
                        aura ? `0 0 9px 1.5px ${V_MAGENTA}66` : null,
                        c.today ? `inset 0 0 0 1.5px ${P.ink}` : null,
                      ].filter(Boolean).join(', ') || 'none',
                    }}>
                      {c.pct > 0 && (
                        <span style={{
                          position: 'absolute', inset: 0, borderRadius: 4,
                          background: V_MAGENTA, opacity: c.pct,
                        }} />
                      )}
                      {zeroKept && (
                        <span style={{
                          position: 'absolute', top: '50%', left: '50%', width: 3, height: 3,
                          transform: 'translate(-50%,-50%)', borderRadius: '50%', background: P.ink4,
                        }} />
                      )}
                    </span>
                  );
                })}
              </div>
            </div>
          </div>
        </React.Fragment>
      )}
    </React.Fragment>
  );

  if (fold) {
    return (
      <VFold P={P} flush={flush} style={style} label="rhythm" labelColor={V_MAGENTA} summary={streakText} open={open} onToggle={onToggle}>
        {body}
      </VFold>
    );
  }
  return (
    <section style={{ margin: flush ? 0 : '14px 20px 0', background: P.tile, borderRadius: 22, padding: '16px 18px', boxSizing: 'border-box', ...style }}>
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
        <span style={{ ...S.caps, color: V_MAGENTA }}>rhythm</span>
        <span style={{ ...S.serif, fontSize: 14.5, color: P.ink2, marginLeft: 'auto' }}>{streakText}</span>
      </div>
      {body}
    </section>
  );
}

// Frown→smile SVG. Hoisted to module scope so its identity is stable across
// re-renders — a component defined inside VMoodFace would remount the whole
// face on every open/pick. A dashed outline + dotted mouth means "not logged".
function VMoodFaceSvg({ P, s, px, dashed }) {
  const color = s == null ? P.ink4 : s <= 2 ? P.rust : s === 3 ? P.ink2 : V_MAGENTA;
  const bend = s == null ? 0 : (s - 3) * 2.7;
  const y0 = 15.5 - bend * 0.28;
  return (
    <svg width={px} height={px} viewBox="0 0 24 24" style={{ display: 'block' }}>
      <circle cx="12" cy="12" r="10.4" fill="none" stroke={color} strokeWidth="1.5"
        strokeDasharray={dashed ? '2.6 2.8' : 'none'} opacity={dashed ? 0.75 : 1} />
      <circle cx="8.6" cy="9.6" r="1.25" fill={color} />
      <circle cx="15.4" cy="9.6" r="1.25" fill={color} />
      <path d={`M 8 ${y0} Q 12 ${y0 + bend * 1.9} 16 ${y0}`} fill="none"
        stroke={color} strokeWidth="1.5" strokeLinecap="round"
        strokeDasharray={dashed ? '1.6 2.2' : 'none'} />
    </svg>
  );
}

// A small face that carries the day's mood. Reads the logged score from the
// server (via /vanta/data); tapping opens five faces to pick from, and the
// pick writes back through /vanta/mood — the same endpoint an iOS Shortcut
// can hit from a Journal routine (Apple exposes no API to read Journal
// itself, so the Shortcut is the bridge — see docs/mood-shortcut.md).
function VMoodFace({ P, size = 30 }) {
  const [score, setScore] = React.useState(() => {
    const m = window.VANTA_STATION && window.VANTA_STATION.mood;
    return typeof m === 'number' ? m : null;
  });
  const [open, setOpen] = React.useState(false);
  const wrapRef = React.useRef(null);

  // Dismiss the picker on tap-away or Escape WITHOUT committing — closing it
  // must never conjure a mood the user didn't deliberately choose.
  React.useEffect(() => {
    if (!open) return;
    const onDown = (e) => { if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false); };
    const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
    document.addEventListener('mousedown', onDown);
    document.addEventListener('keydown', onKey);
    return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); };
  }, [open]);

  const pick = (s) => {
    setScore(s); setOpen(false);
    vantaPost('/vanta/mood', { score: s });
    if (window.VANTA_STATION) window.VANTA_STATION.mood = s;
  };

  return (
    <span ref={wrapRef} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, position: 'relative' }}>
      {open ? (
        <span style={{
          display: 'inline-flex', gap: 5, alignItems: 'center',
          background: P.tile, borderRadius: 999, padding: '4px 8px',
        }}>
          {[1, 2, 3, 4, 5].map(s => (
            <button key={s} onClick={() => pick(s)} aria-label={`mood ${s} of 5`} style={{
              appearance: 'none', border: 0, background: 'transparent', cursor: 'pointer',
              padding: 2, display: 'grid', placeItems: 'center',
              opacity: score === s ? 1 : 0.72,
            }}>
              <VMoodFaceSvg P={P} s={s} px={size - 6} />
            </button>
          ))}
        </span>
      ) : (
        <button onClick={() => setOpen(o => !o)} aria-label={score == null ? 'log mood' : `mood ${score} of 5 — tap to change`}
          title={score == null ? 'log today’s mood' : `today’s mood · ${score}/5`}
          style={{ appearance: 'none', border: 0, background: 'transparent', cursor: 'pointer', padding: 2, display: 'grid', placeItems: 'center' }}>
          <VMoodFaceSvg P={P} s={score} px={size} dashed={score == null} />
        </button>
      )}
    </span>
  );
}

Object.assign(window, {
  VReveal, VChevron, VFold, vCloneTask, VCheck, VBreakdown, VTaskRow, VTaskList,
  VRhythm, VMoodFace, V_MAGENTA, vLocalISO, vIsoRange,
});
