// ── Meditate — together (live) ────────────────────────────────────────────
// Wires the EXISTING design components (timeline strip, streak grid, chat) to
// a real Supabase backend: email+password auth (persistent), live check-ins &
// chat, and editable profile name + photo. No UI reinvented — the habit screen
// is the prototype's HabitDetailScreen, fed real data.

const CFG = window.HABIT_CONFIG;
const ACCENT = (CFG.HABIT && CFG.HABIT.accent) || GREEN;
const sb = window.supabase.createClient(CFG.SUPABASE_URL, CFG.SUPABASE_ANON_KEY, {
  auth: { persistSession: true, autoRefreshToken: true, detectSessionInUrl: false },
  realtime: { params: { eventsPerSecond: 5 } },
});
// Expose the authed client so the DEV feedback toolbar can send bundles with the
// user's session (read-only handle; no effect on app behaviour).
try { window.__APP_SB = sb; } catch (_) {}

// ── Install-as-app: capture the browser's install event as EARLY as possible ─
// `beforeinstallprompt` can fire before React mounts, so we stash the event at
// module scope and let the Home banner pick it up (via a custom event). On
// Android/Chrome this enables the native install; elsewhere (iOS/Safari) the
// event never fires and the banner shows manual instructions instead.
let __deferredInstallPrompt = null;
try {
  window.addEventListener('beforeinstallprompt', (e) => {
    e.preventDefault();
    __deferredInstallPrompt = e;
    try { window.dispatchEvent(new Event('habitbuddy:installable')); } catch (_) {}
  });
  window.addEventListener('appinstalled', () => {
    __deferredInstallPrompt = null;
    try { localStorage.setItem('habitbuddy:install-dismissed', '1'); } catch (_) {}
    try { window.dispatchEvent(new Event('habitbuddy:installed')); } catch (_) {}
  });
} catch (_) {}

// True when the app is already running as an installed PWA (so we hide the banner).
function isStandaloneApp() {
  try {
    return (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) || window.navigator.standalone === true;
  } catch (_) { return false; }
}

// ── helpers copied verbatim from screen.jsx so behaviour matches the design ──
// CADENCE CONVENTION (canonical, whole app): weekdays are Mon=0,Tue=1,…,Sun=6.
// This matches dates.jsx DAY_LETTER (['M','T','W','T','F','S','S'] // Mon..Sun),
// cells.jsx buildSetDayCells (mon = (getDay()+6)%7), and buildWeeklyCells
// (targetDate = weekStart+weekday, weekStart is Monday). We STORE
// groups.cadence_days and groups.cadence_target_day in this same Mon=0 order,
// so create → store → grid render → this header label all agree. (The old
// 001_groups.sql comment "0=Sun..6=Sat" is stale; the app uses Mon=0.)
const CADENCE_WD = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
// Map a group row's DB cadence columns → the cadence object the grid/timeline expect.
function cadenceFromGroup(g) {
  switch (g && g.cadence_kind) {
    case 'set-days':   return { kind: 'set-days', weekdays: g.cadence_days || [] };
    case 'n-per-week': return { kind: 'n-per-week', n: g.cadence_n || 1 };
    case 'weekly':     return { kind: 'weekly', weekday: g.cadence_target_day || 0 };
    default:           return { kind: 'daily' };
  }
}
function cadenceLabel(c) {
  if (c.kind === 'daily') return 'Daily';
  if (c.kind === 'weekly') return 'Weekly · ' + CADENCE_WD[c.weekday || 0];
  if (c.kind === 'n-per-week') return `${c.n}× per week`;
  if (c.kind === 'set-days') return (c.weekdays || []).map((d) => CADENCE_WD[d]).join(', ');
  return '';
}
function markActionLabel(cadence, done) {
  return done ? 'Done for today' : 'Mark today as done';
}

// ── streak (canonical) ──────────────────────────────────────────────────────
// A member's CURRENT streak = consecutive scheduled occurrences (cells) they
// completed, counting back from the most recent one. Every cell buildCells
// produces IS a scheduled occurrence (daily → each day; set-days → each listed
// day; weekly / n-per-week → each week), so an occurrence with no completion is
// a MISS and BREAKS the streak — it must NOT be skipped (the old code skipped
// null statuses, which silently jumped over missed days and inflated the streak
// e.g. showing "2-day streak" when recent days weren't done). Today (or the
// current week) that isn't done yet is "pending": it neither counts nor breaks.
function computeMemberStreak(cells, member) {
  let streak = 0, started = false;
  for (let i = cells.length - 1; i >= 0; i--) {
    const c = cells[i];
    if (c.isFuture) continue;
    const status = c.getStatus(member); // 'done' | 'miss' | 'today' | 'future' | null
    if (!started && c.isToday && status !== 'done') continue; // pending today/this-week
    started = true;
    if (status === 'done') streak++;
    else break; // 'miss', null (missed scheduled day), or anything not done ends it
  }
  return streak;
}

// ── web push helpers ───────────────────────────────────────────────────────
function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - base64String.length % 4) % 4);
  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
  const raw = atob(base64);
  const arr = new Uint8Array(raw.length);
  for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
  return arr;
}
async function enablePush(uid) {
  if (!('serviceWorker' in navigator) || !('PushManager' in window)) throw new Error('Push isn’t supported on this browser.');
  const perm = await Notification.requestPermission();
  if (perm !== 'granted') throw new Error('Notifications are blocked — allow them in your browser’s site settings.');
  const reg = await navigator.serviceWorker.ready;
  let sub = await reg.pushManager.getSubscription();
  if (!sub) sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(CFG.VAPID_PUBLIC) });
  const { error } = await sb.from('push_subscriptions').upsert({ endpoint: sub.endpoint, user_id: uid, subscription: sub.toJSON() }, { onConflict: 'endpoint' });
  if (error) throw error;
  // Instant confirmation so you can see notifications actually display.
  try { await reg.showNotification('Nakama', { body: 'Notifications are on ✓', icon: 'icons/icon-192.png', badge: 'icons/icon-192.png' }); } catch (_) {}
}

// ── back-gesture handling ───────────────────────────────────────────────────
// When a full-screen overlay (profile settings, person history, a story) is
// open, push a history entry so the phone's back gesture / swipe closes THAT
// overlay instead of exiting the app. All closes go through history.back() →
// popstate → onClose.
//
// IMPORTANT (fixes the "story X returns to the wrong place" bug): overlays can
// STACK — e.g. a story opened from a person's timeline sits on top of that
// timeline. Every open overlay registers a popstate listener, and a single
// browser Back fires ALL of them at once. The old code closed every overlay on
// one Back, so closing the story also closed the timeline underneath and dumped
// you back to the habit/chat screen. We keep a shared stack and let ONLY the
// top-most overlay respond to a given Back — so each Back unwinds exactly one
// level, and closing the story returns to whatever it was opened from.
const __overlayStack = [];
function useBackClose(open, onClose) {
  React.useEffect(() => {
    if (!open) return;
    const entry = { onClose };
    __overlayStack.push(entry);
    window.history.pushState({ mtOverlay: true }, '');
    const onPop = () => {
      // Only the current top overlay closes; anything beneath stays put.
      if (__overlayStack[__overlayStack.length - 1] !== entry) return;
      __overlayStack.pop();
      onClose();
    };
    window.addEventListener('popstate', onPop);
    return () => {
      window.removeEventListener('popstate', onPop);
      const idx = __overlayStack.indexOf(entry);
      if (idx !== -1) __overlayStack.splice(idx, 1);
    };
  }, [open]);
}

// ── header (from screen.jsx, back button dropped for the single-screen POC) ──
function Header({ habitName, habitIcon, cadenceLabel, memberCount, accent, onMore, onBack }) {
  return (
    <div style={{
      background: accent || GREEN, color: '#fff',
      padding: 'calc(14px + env(safe-area-inset-top)) 16px 18px',
      display: 'flex', alignItems: 'center', gap: 10,
      borderBottomLeftRadius: 18, borderBottomRightRadius: 18,
    }}>
      {onBack ? (
        <button onClick={onBack} aria-label="Back to home" title="Home" style={{
          width: 36, height: 36, borderRadius: '50%', border: 'none',
          background: 'rgba(255,255,255,0.15)', cursor: 'pointer',
          display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', padding: 0, flexShrink: 0,
        }}>
          <svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M12 4 L6 10 L12 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>
        </button>
      ) : <div style={{ width: 8 }} />}
      {habitIcon && (
        <div aria-hidden="true" style={{
          flexShrink: 0, fontSize: 30, lineHeight: 1,
          display: 'flex', alignItems: 'center', justifyContent: 'center',
        }}>{habitIcon}</div>
      )}
      <div style={{ flex: 1 }}>
        <div style={{ fontSize: 20, fontWeight: 700, letterSpacing: -0.2, lineHeight: 1.1 }}>{habitName}</div>
        <div style={{ fontSize: 13, opacity: 0.85, marginTop: 3 }}>
          {cadenceLabel} · {memberCount} member{memberCount === 1 ? '' : 's'}
        </div>
      </div>
      <button onClick={onMore} style={{
        width: 36, height: 36, borderRadius: '50%', border: 'none',
        background: 'rgba(255,255,255,0.15)', cursor: 'pointer',
        display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', padding: 0,
      }} aria-label="Settings" title="Settings">
        <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
          <circle cx="9" cy="4" r="1.4" fill="currentColor"/>
          <circle cx="9" cy="9" r="1.4" fill="currentColor"/>
          <circle cx="9" cy="14" r="1.4" fill="currentColor"/>
        </svg>
      </button>
    </div>
  );
}

// ── the live habit screen (data → the design's grids + chat) ────────────────
function LiveHabitScreen({ uid, gid, group, roster, onOpenSettings, onReloadProfiles, onBack }) {
  const today = React.useMemo(() => new Date(), []);
  const todayStr = ymd(today);
  const habit = group;
  const accent = group.accent || ACCENT;
  const cadence = React.useMemo(() => cadenceFromGroup(group), [group]);

  const [checkins, setCheckins] = React.useState(null);
  const [msgs, setMsgs] = React.useState(null);
  const [goals, setGoals] = React.useState({}); // user_id -> goal text (C2)
  const [storyViews, setStoryViews] = React.useState([]); // my seen stories (Phase 2 ring)
  const [collapsed, setCollapsed] = React.useState(true);
  const [justMarked, setJustMarked] = React.useState(false);
  // Stories (Phase 1): signed thumbnail URLs, the check-in sheet, the viewer.
  const [mediaUrls, setMediaUrls] = React.useState({}); // media_path -> signed URL
  const [checkinSheet, setCheckinSheet] = React.useState(null); // { day, done }
  const [story, setStory] = React.useState(null); // full-screen viewer payload

  const loadCheckins = React.useCallback(async () => {
    const since = ymd(addDays(today, -160));
    const { data } = await sb.from('checkins').select('user_id,day,note,done,created_at,media_path,media_type').eq('group_id', gid).gte('day', since);
    setCheckins(data || []);
  }, [today, gid]);
  const loadMsgs = React.useCallback(async () => {
    const { data } = await sb.from('messages').select('*').eq('group_id', gid).eq('kind', 'user').order('created_at', { ascending: true }).limit(1000);
    setMsgs(data || []);
  }, [gid]);
  const loadGoals = React.useCallback(async () => {
    const { data } = await sb.from('group_members').select('user_id, goal').eq('group_id', gid);
    const map = {}; (data || []).forEach((r) => { if (r.goal) map[r.user_id] = r.goal; });
    setGoals(map);
  }, [gid]);
  // Which stories I've already seen (one query; RLS scopes rows to viewer_id=me).
  // Drives the "unseen story" pulse on the did-it ring.
  const loadStoryViews = React.useCallback(async () => {
    const { data } = await sb.from('story_views').select('subject_user, day').eq('group_id', gid);
    setStoryViews(data || []);
  }, [gid]);

  React.useEffect(() => {
    loadCheckins(); loadMsgs(); loadGoals(); loadStoryViews();
    const ch = sb.channel('room-' + gid)
      .on('postgres_changes', { event: '*', schema: 'public', table: 'checkins', filter: 'group_id=eq.' + gid }, loadCheckins)
      .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages', filter: 'group_id=eq.' + gid }, (p) => {
        const r = p.new;
        setMsgs((prev) => (prev && prev.some((m) => m.id === r.id)) ? prev : [...(prev || []), { ...r, _fresh: true }]);
      })
      .on('postgres_changes', { event: '*', schema: 'public', table: 'profiles' }, () => onReloadProfiles && onReloadProfiles())
      .subscribe();
    const onVis = () => { if (!document.hidden) { loadCheckins(); loadMsgs(); loadGoals(); loadStoryViews(); } };
    document.addEventListener('visibilitychange', onVis);
    window.addEventListener('focus', onVis);
    return () => { sb.removeChannel(ch); document.removeEventListener('visibilitychange', onVis); window.removeEventListener('focus', onVis); };
  }, [loadCheckins, loadMsgs, loadGoals, loadStoryViews, onReloadProfiles]);

  // Batch-sign the media paths so chat blocks + history rows can show thumbnails
  // without an async call per leaf. Private bucket → RLS lets a member read its
  // group's media; the signed URL is a short-lived read grant.
  React.useEffect(() => {
    const paths = [...new Set((checkins || []).filter((c) => c.media_path).map((c) => c.media_path))];
    if (!paths.length) { setMediaUrls((prev) => (Object.keys(prev).length ? {} : prev)); return; }
    let alive = true;
    sb.storage.from('checkin-media').createSignedUrls(paths, 3600).then(({ data }) => {
      if (!alive || !data) return;
      const map = {};
      data.forEach((d) => { if (d.signedUrl && !d.error) map[d.path] = d.signedUrl; });
      setMediaUrls(map);
    });
    return () => { alive = false; };
  }, [checkins]);

  // Build the design's `members` shape from real profiles + check-ins. Each gets
  // a stable per-person colour (B5) and their personal goal (C2).
  const members = React.useMemo(() => roster.map((p, i) => {
    const entries = {};
    (checkins || []).forEach((c) => { if (c.user_id === p.id) entries[c.day] = (c.done === false ? 'miss' : 'done'); });
    if (i === 0 && !entries[todayStr]) entries[todayStr] = 'today';
    return { id: i === 0 ? 'you' : p.id, name: p.name, variant: i, entries,
      color: i === 0 ? GREEN : colorForUser(p.id), goal: goals[p.id] || null };
  }), [roster, checkins, todayStr, goals]);

  const cells = React.useMemo(() => buildCells({ today, cadence, daysBack: 160 }), [today, cadence]);

  // ── "Did it for the current period" green ring — cadence-based, LOCAL time ───
  // The avatar ring is ALWAYS green (never the habit accent). It means "did it
  // for the current period" and stays green from the moment they check in until
  // the period resets at 00:00 in the USER'S LOCAL timezone (all dates here are
  // the client's own local Date). periodInfo describes that window as a half-open
  // range of local day-strings [startDay, resetDay):
  //   • daily          → green today only; resets 00:00 tomorrow.
  //   • n-per-week      → treated like daily (green the day you check in; resets
  //                       00:00 tomorrow). Weekly progress is shown on Home.
  //   • set-days        → green from the scheduled day you did it until 00:00 of
  //                       the NEXT scheduled weekday (so it stays green through
  //                       the non-scheduled days in between).
  //   • weekly (target) → like a 1-day set-days: green from the target weekday
  //                       until 00:00 of the next occurrence of that weekday.
  // A check-in on any day d with startDay <= d <= today counts as "did it this
  // period". The pulse (unseen-story ping) shares this exact lifetime.
  const periodInfo = React.useMemo(() => {
    const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate());
    const wdOf = (d) => (d.getDay() + 6) % 7; // Mon=0 … Sun=6
    if (cadence.kind === 'set-days' || cadence.kind === 'weekly') {
      const wanted = cadence.kind === 'weekly'
        ? new Set([cadence.weekday || 0])
        : new Set((cadence.weekdays && cadence.weekdays.length) ? cadence.weekdays : [0, 1, 2, 3, 4, 5, 6]);
      // most recent scheduled day on/before today = start of the current window
      let start = startOfToday;
      for (let i = 0; i <= 14; i++) { const d = addDays(startOfToday, -i); if (wanted.has(wdOf(d))) { start = d; break; } }
      // next scheduled day strictly after today = the 00:00 reset boundary
      let reset = addDays(startOfToday, 1);
      for (let i = 1; i <= 14; i++) { const d = addDays(startOfToday, i); if (wanted.has(wdOf(d))) { reset = d; break; } }
      return { active: true, startDay: ymd(start), resetDay: ymd(reset) };
    }
    // daily & n-per-week → green today only, resets at 00:00 tomorrow.
    return { active: true, startDay: todayStr, resetDay: ymd(addDays(startOfToday, 1)) };
  }, [cadence, today, todayStr]);

  // A check-in day is inside the current green window when it's on/after the
  // window start and not in the future. (resetDay > today always, so an upper
  // cap of "today" is exactly the half-open [startDay, resetDay) intersected with
  // the past — future check-ins never count.)
  const inGreenWindow = React.useCallback(
    (day) => !!day && day >= periodInfo.startDay && day <= todayStr,
    [periodInfo, todayStr]);

  // Set of "<subject_user>|<day>" I've already viewed → the pulse turns off.
  const seenSet = React.useMemo(() => {
    const s = new Set();
    (storyViews || []).forEach((v) => s.add(v.subject_user + '|' + v.day));
    return s;
  }, [storyViews]);

  const enrichedMembers = members.map((m, i) => {
    const realUid = i === 0 ? uid : m.id; // member 0 is "you"; carry the real id
    const vals = Object.values(m.entries);
    const relevant = vals.filter((v) => v === 'done' || v === 'miss' || v === 'today');
    const allDone = relevant.length > 0 && relevant.every((v) => v === 'done' || v === 'today');
    const personalStreak = computeMemberStreak(cells, m);
    // did-it this period = a done (done !== false) check-in inside the green window.
    let didThisPeriod = false, hasStory = false, storyDay = null, pulse = false;
    if (periodInfo.active) {
      const mine = (checkins || []).filter((c) => c.user_id === realUid && c.done !== false && inGreenWindow(c.day));
      didThisPeriod = mine.length > 0;
      // The story = the most recent in-window check-in that has media.
      const withMedia = mine.filter((c) => c.media_path).sort((a, b) => (a.day < b.day ? 1 : -1))[0];
      if (withMedia) {
        hasStory = true; storyDay = withMedia.day;
        // Pulse for exactly the green lifetime: green + media + not yet seen.
        pulse = !seenSet.has(realUid + '|' + storyDay);
      }
    }
    return { ...m, realUid, allDone, personalStreak, didThisPeriod, hasStory, storyDay, pulse };
  });

  const allDoneByCell = React.useMemo(() => {
    const map = new Map();
    for (const cell of cells) {
      const statuses = members.map((m) => cell.getStatus(m));
      const anyRelevant = statuses.some((s) => s != null);
      map.set(cell.key, anyRelevant && statuses.every((s) => s === 'done'));
    }
    return map;
  }, [cells, members]);

  const streak = React.useMemo(() => {
    let s = 0, endIdx = -1;
    for (let i = cells.length - 1; i >= 0; i--) {
      const c = cells[i];
      if (c.isFuture) continue;
      if (allDoneByCell.get(c.key)) { endIdx = i; break; }
      if (c.isToday) continue;
      break;
    }
    if (endIdx >= 0) for (let i = endIdx; i >= 0 && allDoneByCell.get(cells[i].key); i--) s++;
    return s;
  }, [cells, allDoneByCell]);

  const [selectedDay, setSelectedDay] = React.useState(null);
  const [historyMember, setHistoryMember] = React.useState(null);
  useBackClose(!!historyMember, () => setHistoryMember(null));
  const isTodaySelected = selectedDay === todayStr;
  const todayDone = members[0] && members[0].entries[todayStr] === 'done';
  // Tapping a day (or the streak badge) unfolds the timeline to the per-person
  // view; tapping the same day again folds it back.
  const selectDay = (d) => setSelectedDay((prev) => {
    if (prev === d) { setCollapsed(true); return null; }
    setCollapsed(false);
    return d;
  });
  const prettyShort = (ds) => { const [y, m, d] = ds.split('-').map(Number); return `${['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][m - 1]} ${d}`; };

  // Mark/unmark a specific day for the current user (optionally with a note).
  // No chat spam — the "done" state is rendered from the check-in itself, so
  // undoing simply removes the block.
  // Set / clear a day's state for the current user. state ∈ 'done' | 'miss' | null.
  const setDayState = async (day, state, note) => {
    setJustMarked(true); setTimeout(() => setJustMarked(false), 800);
    if (state === null) {
      setCheckins((prev) => (prev || []).filter((c) => !(c.user_id === uid && c.day === day)));
      await sb.from('checkins').delete().eq('group_id', gid).eq('user_id', uid).eq('day', day);
      return;
    }
    const done = state === 'done';
    const row = { group_id: gid, user_id: uid, day, done, note: note || null, created_at: new Date().toISOString() };
    setCheckins((prev) => [...(prev || []).filter((c) => !(c.user_id === uid && c.day === day)), row]);
    await sb.from('checkins').upsert({ group_id: gid, user_id: uid, day, done, note: note || null }, { onConflict: 'group_id,user_id,day' });
  };
  const markDay = (day, note) => setDayState(day, 'done', note); // used by the expanded "today" circle

  // Stories: merge a check-in row saved by the CheckinSheet into local state
  // (realtime will also refresh). Preserve any existing media if the save didn't
  // include a new upload.
  const onCheckinSaved = (row) => {
    setJustMarked(true); setTimeout(() => setJustMarked(false), 800);
    setCheckins((prev) => {
      const arr = prev || [];
      const existing = arr.find((c) => c.user_id === row.user_id && c.day === row.day);
      const merged = { ...(existing || {}), ...row };
      return [...arr.filter((c) => !(c.user_id === row.user_id && c.day === row.day)), merged];
    });
  };

  // Open the full-screen story viewer for a given person+day (from a chat
  // thumbnail or a history row). No-op if that check-in has no media.
  const openStory = (subjectUser, day) => {
    const c = (checkins || []).find((x) => x.user_id === subjectUser && x.day === day);
    if (!c || !c.media_path) return;
    const isYou = subjectUser === uid;
    const mem = members.find((m) => (isYou ? m.id === 'you' : m.id === subjectUser));
    setStory({
      name: isYou ? 'You' : (mem ? mem.name : 'Someone'),
      variant: mem ? mem.variant : 0,
      color: isYou ? GREEN : (mem ? mem.color : GREEN),
      actionWord: group.action_word,
      done: c.done !== false,
      gid, subjectUser, day,
      dayLabel: day === todayStr ? 'Today' : prettyShort(day),
      createdAt: c.created_at,
      note: c.note || '',
      media_path: c.media_path, media_type: c.media_type,
      url: mediaUrls[c.media_path] || null,
    });
  };
  useBackClose(!!story, () => setStory(null));

  // Tapping any member avatar (grid row OR chat sender): open the story viewer
  // ONLY if that avatar is currently PULSING (did-it + media + unseen + in-window).
  // Marks it seen → the ring stops pulsing. Otherwise (no story / already seen /
  // no media) fall back to their check-in history, as before.
  const handleAvatarTap = (m) => {
    if (m && m.pulse && m.hasStory && m.storyDay) openStory(m.realUid || (m.id === 'you' ? uid : m.id), m.storyDay);
    else setHistoryMember(m);
  };

  // Per-message unseen-story check (chat blocks): true when THIS check-in has
  // media the current viewer hasn't seen yet AND it falls inside the current
  // green window — the SAME cadence-based rule the row/chat-avatar ring uses,
  // keyed to a specific day. Keeps the chat-avatar pulse consistent with the
  // unfolded-row avatar pulse.
  const storyUnseen = (subjectUser, day) => {
    if (!inGreenWindow(day)) return false;
    return !seenSet.has(subjectUser + '|' + day);
  };

  // Story viewer → person timeline: tapping the name/avatar in the viewer header
  // closes the viewer and opens that person's PersonHistory.
  const openHistoryFromStory = () => {
    if (!story) return;
    const su = story.subjectUser;
    const isYou = su === uid;
    const mem = enrichedMembers.find((m) => (isYou ? m.id === 'you' : m.id === su));
    setStory(null);
    if (mem) setHistoryMember(mem);
  };

  // A story was just viewed → record it locally so the pulse stops immediately
  // (the DB upsert happens inside StoryViewer; this keeps the UI in sync now).
  const markStorySeen = (subjectUser, day) => {
    setStoryViews((prev) => (prev || []).some((v) => v.subject_user === subjectUser && v.day === day)
      ? prev : [...(prev || []), { subject_user: subjectUser, day }]);
  };

  const sendMessage = async (text) => {
    const optimistic = { id: 'tmp' + Date.now(), group_id: gid, user_id: uid, text, kind: 'user', created_at: new Date().toISOString(), _fresh: true };
    setMsgs((prev) => [...(prev || []), optimistic]);
    const { data } = await sb.from('messages').insert({ group_id: gid, user_id: uid, text }).select().single();
    if (data) setMsgs((prev) => (prev || []).map((m) => m.id === optimistic.id ? data : m));
  };

  // Current record for the selected day. selState ∈ null (unset) | 'done' | 'miss'.
  const selRec = selectedDay ? (checkins || []).find((c) => c.user_id === uid && c.day === selectedDay) : null;
  const selState = !selRec ? null : (selRec.done === false ? 'miss' : 'done');
  // Stories Phase 1: the bottom bar is ALWAYS plain chat now. Marking a day
  // done/not-done happens in the check-in sheet (opened from the strip buttons).
  const onComposerSend = async (text) => { if (text && text.trim()) await sendMessage(text.trim()); };

  // Chat thread = messages + one block per check-in (green "done" or grey
  // "missed"), merged by time. Blocks come from the check-ins, so undo removes them.
  const chatMessages = React.useMemo(() => {
    const msgItems = (msgs || []).map((r) => ({ id: r.id, from: r.user_id === uid ? 'you' : r.user_id, text: r.text, kind: 'user', ts: new Date(r.created_at).getTime(), fresh: r._fresh }));
    const doneItems = (checkins || []).map((c) => ({
      id: 'chk-' + c.user_id + '-' + c.day,
      from: c.user_id === uid ? 'you' : c.user_id,
      text: c.note || '', kind: c.done === false ? 'missed' : 'done',
      dayLabel: c.day === todayStr ? '' : prettyShort(c.day),
      ts: c.created_at ? new Date(c.created_at).getTime() : new Date(c.day + 'T12:00:00').getTime(),
      fresh: false,
      // Stories: carry media + identity so the block can show a thumbnail and open the viewer.
      media_path: c.media_path || null, media_type: c.media_type || null,
      day: c.day, subjectUser: c.user_id,
    }));
    return [...msgItems, ...doneItems].sort((a, b) => a.ts - b.ts);
  }, [msgs, checkins, uid, todayStr]);

  const loading = checkins === null || msgs === null;
  const dayWord = selectedDay === todayStr ? 'today' : (selectedDay ? prettyShort(selectedDay) : '');

  return (
    <>
    <div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#fbf7ee', color: INK, minHeight: 0 }}>
      <Header habitName={habit.name} habitIcon={habit.icon} cadenceLabel={cadenceLabel(cadence)} memberCount={members.length} accent={accent} onMore={onOpenSettings} onBack={onBack} />

      <div style={{ borderBottom: `1px solid ${BORDER}`, background: '#fff' }}>
        {loading ? (
          <div style={{ height: 96, display: 'flex', alignItems: 'center', justifyContent: 'center' }}><div className="spinner" /></div>
        ) : (
          <HabitGrid collapsed={collapsed} members={enrichedMembers} today={today} cadence={cadence} cells={cells}
            onSelectDay={selectDay} selectedDay={selectedDay} sinceDay={ymd(addDays(today, -30))} justMarked={justMarked}
            allDoneByCell={allDoneByCell} streak={streak} streakIcon="none" onMemberTap={handleAvatarTap} />
        )}

        {selectedDay && !loading && (
          <div style={{ padding: '2px 16px 12px', display: 'flex', gap: 8, alignItems: 'center', animation: 'slideDown .25s cubic-bezier(.2,.8,.2,1)' }}>
            <button onClick={() => { setSelectedDay(null); setCollapsed(true); }} aria-label="Fold" style={{
              height: 44, width: 44, borderRadius: 12, border: `1px solid ${BORDER}`, background: '#fff',
              display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0, padding: 0 }}>
              <svg width="18" height="18" viewBox="0 0 18 18" fill="none" style={{ transform: collapsed ? 'rotate(0deg)' : 'rotate(180deg)', transition: 'transform .3s' }}>
                <path d="M4 7 L9 12 L14 7" stroke={INK_SOFT} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
            </button>
            {selState === null ? (
              <>
                <button onClick={() => setCheckinSheet({ day: selectedDay, done: false })} style={{
                  flex: 1, height: 44, borderRadius: 12, border: '1px solid #d9d3c5', background: '#ece7db', color: INK,
                  fontSize: 14, fontWeight: 600, fontFamily: 'inherit', cursor: 'pointer',
                  display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
                  <svg width="13" height="13" viewBox="0 0 14 14" fill="none"><path d="M3 3 L11 11 M11 3 L3 11" stroke={INK_SOFT} strokeWidth="2" strokeLinecap="round" /></svg>
                  Not done
                </button>
                <button onClick={() => setCheckinSheet({ day: selectedDay, done: true })} style={{
                  flex: 1, height: 44, borderRadius: 12, border: `1px solid ${GREEN}`, background: GREEN, color: '#fff',
                  fontSize: 14, fontWeight: 600, fontFamily: 'inherit', cursor: 'pointer',
                  display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
                  <svg width="15" height="15" viewBox="0 0 18 18" fill="none"><path d="M3.5 9.5 L7.5 13 L14.5 5.5" stroke="#fff" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" /></svg>
                  Mark as done
                </button>
              </>
            ) : (
              <button onClick={() => setDayState(selectedDay, null)} style={{
                flex: 1, height: 44, borderRadius: 12, border: `1px solid ${BORDER}`, background: '#fff', color: INK_SOFT,
                fontSize: 15, fontWeight: 600, fontFamily: 'inherit', cursor: 'pointer',
                display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
                {selState === 'done' ? 'Undo done' : 'Undo not-done'}
              </button>
            )}
          </div>
        )}
      </div>

      {loading
        ? <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}><div className="spinner" /></div>
        : <ChatPanel messages={chatMessages} members={enrichedMembers} onSend={onComposerSend} actionWord={group.action_word}
            mediaUrls={mediaUrls} onOpenStory={openStory} onMemberTap={handleAvatarTap} storyUnseen={storyUnseen} />}
    </div>
    {historyMember && (
      <PersonHistory member={historyMember} personUid={historyMember.id === 'you' ? uid : historyMember.id}
        checkins={checkins} today={today} accent={accent} cadence={cadence} onClose={() => window.history.back()}
        mediaUrls={mediaUrls} onOpenStory={openStory} />
    )}
    {checkinSheet && (
      <CheckinSheet gid={gid} uid={uid} day={checkinSheet.day} done={checkinSheet.done}
        dayWord={checkinSheet.day === todayStr ? 'today' : prettyShort(checkinSheet.day)}
        actionWord={group.action_word} accent={accent}
        onSaved={onCheckinSaved} onClose={() => setCheckinSheet(null)} />
    )}
    {story && <StoryViewer story={story} onClose={() => window.history.back()} onSeen={markStorySeen} onOpenHistory={openHistoryFromStory} />}
    </>
  );
}

// ── per-person history: every day as a row (status + note), newest first ────
function PersonHistory({ member, personUid, checkins, today, onClose, accent = ACCENT, mediaUrls = {}, onOpenStory, cadence }) {
  const DOW = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
  const MON = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
  const cad = cadence || { kind: 'daily' };
  const isWeek = cad.kind === 'weekly' || cad.kind === 'n-per-week';

  // The person's own entries (day -> 'done'|'miss') + per-day note/media records.
  const entries = {}, recByDay = {};
  (checkins || []).forEach((c) => {
    if (c.user_id !== personUid) return;
    entries[c.day] = (c.done === false ? 'miss' : 'done');
    recByDay[c.day] = { done: c.done !== false, note: c.note, media_path: c.media_path, media_type: c.media_type };
  });
  const personObj = { entries };

  // Build the rows from the SAME cells the combined group timeline uses, so a
  // "done" row here is exactly a done cell there, non-scheduled days (weekends
  // for set-days / weekly) don't appear as misleading empty gaps, and the streak
  // (consecutive done cells) equals the count of consecutive done rows shown.
  // Fixes the "9-day streak but the rows don't show those days done" mismatch:
  // previously the streak counted cadence PERIODS (weeks) while the rows were raw
  // calendar days, so the two disagreed for weekly / n-per-week / set-days habits.
  const cells = buildCells({ today, cadence: cad, daysBack: 63 });
  const rows = [];
  for (let i = cells.length - 1; i >= 0; i--) {
    const cell = cells[i];
    if (cell.isFuture) continue;
    let status = cell.getStatus(personObj); // 'done' | 'miss' | 'today' | null
    if (cell.isToday && status !== 'done' && status !== 'miss') status = 'today';
    else if (status == null) status = 'empty';

    const kind = (cell.meta && cell.meta.kind) || 'day';
    let label, sub = null, note = null, media = null, openDay = null;
    if (kind === 'day') {
      const ds = cell.meta.dateStr;
      const d = dateFromYmd(ds);
      label = cell.isToday ? 'Today' : `${DOW[d.getDay()]} · ${MON[d.getMonth()]} ${d.getDate()}`;
      const rec = recByDay[ds];
      note = rec ? rec.note : null;
      if (rec && rec.media_path) media = { media_path: rec.media_path, media_type: rec.media_type };
      openDay = ds;
    } else {
      // week / week-n cell — aggregate the person's in-week check-ins.
      const ws = cell.meta.weekStart;
      const weekDays = cell.meta.weekDateStrs || Array.from({ length: 7 }, (_, k) => ymd(addDays(ws, k)));
      const first = dateFromYmd(weekDays[0]);
      label = cell.isToday ? 'This week' : `Week of ${MON[first.getMonth()]} ${first.getDate()}`;
      if (kind === 'week-n') {
        const doneCnt = weekDays.reduce((a, ds) => a + (entries[ds] === 'done' ? 1 : 0), 0);
        sub = `${doneCnt} of ${cell.meta.n} this week`;
      } else {
        const last = dateFromYmd(weekDays[6]);
        sub = `${MON[first.getMonth()]} ${first.getDate()}–${MON[last.getMonth()]} ${last.getDate()}`;
      }
      // Surface the most recent in-week media + first note found.
      for (let k = weekDays.length - 1; k >= 0; k--) {
        const rec = recByDay[weekDays[k]];
        if (rec && rec.media_path && !media) { media = { media_path: rec.media_path, media_type: rec.media_type }; openDay = weekDays[k]; }
        if (rec && rec.note && !note) note = rec.note;
      }
    }
    rows.push({ key: cell.key, isToday: cell.isToday, status, label, sub, note, media, openDay });
  }

  const Chip = ({ status }) => {
    if (status === 'done') return <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 12, fontWeight: 700, color: GREEN_DEEP, background: '#e6f0e8', border: '1px solid #cfe3d5', borderRadius: 100, padding: '3px 9px' }}>✓ Done</span>;
    if (status === 'miss') return <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 12, fontWeight: 700, color: INK_SOFT, background: '#ece7db', border: '1px solid #ddd6c7', borderRadius: 100, padding: '3px 9px' }}>✕ Not done</span>;
    if (status === 'today') return <span style={{ fontSize: 12, fontWeight: 700, color: GREEN, background: '#fff', border: `1px solid ${GREEN}`, borderRadius: 100, padding: '3px 9px' }}>{isWeek ? 'In progress' : 'Today'}</span>;
    return <span style={{ fontSize: 12, fontWeight: 700, color: '#a7a291', background: '#f2eee3', borderRadius: 100, padding: '3px 9px' }}>—</span>;
  };
  const doneCount = rows.filter((r) => r.status === 'done').length;
  const streak = computeMemberStreak(cells, personObj);
  const StatBox = ({ label, value, sub, gold }) => (
    <div style={{ background: '#fff', borderRadius: 14, padding: '12px 8px', textAlign: 'center', boxShadow: '0 1px 0 rgba(0,0,0,0.04)' }}>
      <div style={{ fontSize: 22, fontWeight: 800, color: gold ? GOLD : INK, lineHeight: 1, display: 'inline-flex', alignItems: 'baseline', gap: 2 }}>
        {gold && <svg width="13" height="13" viewBox="0 0 12 12" style={{ marginRight: 2 }}><path d="M6 1.5 C 7 4 9 4.5 9 7 A 3 3 0 0 1 3 7 C 3 5.5 4 5 4 4 C 5 4.5 5 3 6 1.5 Z" fill={GOLD} /></svg>}
        {value}{sub && <span style={{ fontSize: 10, color: INK_SOFT, fontWeight: 500 }}>{sub}</span>}
      </div>
      <div style={{ fontSize: 10, color: INK_SOFT, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, marginTop: 6 }}>{label}</div>
    </div>
  );
  return (
    <div style={{ position: 'absolute', inset: 0, zIndex: 70, background: '#fbf7ee', display: 'flex', flexDirection: 'column' }}>
      <div style={{ padding: 'calc(10px + env(safe-area-inset-top)) 8px 0', display: 'flex', alignItems: 'center' }}>
        <button onClick={onClose} aria-label="Back" style={{ width: 40, height: 40, border: 'none', background: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%' }}>
          <svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M12 4 L6 10 L12 16" stroke={INK} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>
        </button>
      </div>
      <div style={{ flex: 1, overflowY: 'auto', padding: '0 16px calc(16px + env(safe-area-inset-bottom))' }}>
        {/* hero — matches member-profile.jsx */}
        <div style={{ textAlign: 'center', padding: '4px 0 20px' }}>
          <div style={{ width: 110, height: 110, borderRadius: '50%', margin: '0 auto', overflow: 'hidden', border: `3px solid ${GOLD}`, padding: 3, background: '#fff', boxShadow: '0 4px 14px rgba(0,0,0,0.08)' }}>
            <Avatar variant={member.variant} size={98} />
          </div>
          <div style={{ fontSize: 24, fontWeight: 700, marginTop: 12, letterSpacing: -0.3 }}>{member.name}{member.id === 'you' ? ' (you)' : ''}</div>
          <div style={{ fontSize: 13, color: INK_SOFT, marginTop: 2 }}>Their check-in history</div>
          {member.goal && (
            <div style={{ margin: '14px auto 0', maxWidth: 340, background: '#fff', border: `1px solid ${BORDER}`, borderRadius: 14,
              padding: '12px 14px', textAlign: 'left', display: 'flex', gap: 10, alignItems: 'flex-start', boxShadow: '0 1px 0 rgba(0,0,0,0.04)' }}>
              <span style={{ fontSize: 18, lineHeight: 1.2, flexShrink: 0 }}>🎯</span>
              <div style={{ minWidth: 0 }}>
                <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: 0.6, textTransform: 'uppercase', color: INK_SOFT }}>{member.id === 'you' ? 'Your goal' : 'Their goal'}</div>
                <div style={{ fontSize: 14, color: INK, marginTop: 2, lineHeight: 1.4, wordBreak: 'break-word' }}>{member.goal}</div>
              </div>
            </div>
          )}
        </div>
        {/* stats */}
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginBottom: 20 }}>
          <StatBox label="Current streak" value={streak} sub={isWeek ? 'wks' : 'days'} gold />
          <StatBox label="Done" value={doneCount} sub={isWeek ? 'last 9 wks' : 'last 60d'} />
        </div>
        <div style={{ fontSize: 11, fontWeight: 700, letterSpacing: 0.8, color: INK_SOFT, textTransform: 'uppercase', marginBottom: 6, padding: '0 4px' }}>Recent activity</div>
        {/* Variant B media layout: a row WITH media is taller and shows a big
            (~76px) thumbnail on the right; rows without media stay compact. */}
        {rows.map((r) => {
          const hasMedia = !!r.media;
          return (
            <div key={r.key} style={{ display: 'flex', gap: 12, alignItems: 'center',
              padding: hasMedia ? '12px 6px' : '11px 6px', minHeight: hasMedia ? 76 : undefined,
              borderBottom: `1px solid ${BORDER}`, opacity: r.status === 'empty' ? 0.6 : 1 }}>
              <div style={{ width: 96, flexShrink: 0 }}>
                <div style={{ fontSize: 13, fontWeight: 600, color: r.isToday ? GREEN_DEEP : INK, lineHeight: 1.2 }}>{r.label}</div>
                {r.sub && <div style={{ fontSize: 11, color: INK_SOFT, marginTop: 2 }}>{r.sub}</div>}
              </div>
              <div style={{ flexShrink: 0 }}><Chip status={r.status} /></div>
              {r.note ? <div style={{ flex: 1, fontSize: 14, color: INK, lineHeight: 1.35, wordBreak: 'break-word' }}>{r.note}</div> : <div style={{ flex: 1 }} />}
              {hasMedia && (
                <StoryThumb url={mediaUrls[r.media.media_path]} type={r.media.media_type} size={76}
                  onClick={() => onOpenStory && onOpenStory(personUid, r.openDay)} />
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
}

// ── Stories: check-in sheet (note + optional photo/video) ───────────────────
// Opened from the day strip's "Mark as done" / "Not done" buttons. Upserts the
// check-in (done/not-done + note) and, if a media file is chosen, uploads it to
// the private checkin-media bucket at <group_id>/<user_id>/<day>.<ext> and points
// the check-in at it.
const CHK_MAX_IMAGE = 10 * 1024 * 1024;   // 10 MB photos
const CHK_MAX_VIDEO = 30 * 1024 * 1024;   // 30 MB video
const CHK_MAX_VIDEO_SECS = 20;            // guide ~15s; hard cap 20s
function CheckinSheet({ gid, uid, day, done, dayWord, actionWord = 'did it', accent = ACCENT, onSaved, onClose }) {
  useBackClose(true, onClose);
  const [note, setNote] = React.useState('');
  const [file, setFile] = React.useState(null);
  const [preview, setPreview] = React.useState(null); // { url, type: 'image'|'video' }
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);
  const fileRef = React.useRef(null);

  React.useEffect(() => () => { if (preview) URL.revokeObjectURL(preview.url); }, [preview]);

  const pickFile = (e) => {
    const f = e.target.files && e.target.files[0];
    if (e.target) e.target.value = '';
    if (!f) return;
    setErr(null);
    const isVideo = (f.type || '').startsWith('video/');
    const isImage = (f.type || '').startsWith('image/');
    if (!isVideo && !isImage) { setErr('Please choose a photo or a video.'); return; }
    const max = isVideo ? CHK_MAX_VIDEO : CHK_MAX_IMAGE;
    if (f.size > max) { setErr(`That ${isVideo ? 'video' : 'photo'} is too large (max ${Math.round(max / 1024 / 1024)} MB). Please pick a smaller one.`); return; }
    const url = URL.createObjectURL(f);
    if (isVideo) {
      // Guide toward short clips: reject anything much longer than ~15s.
      const v = document.createElement('video');
      v.preload = 'metadata';
      v.onloadedmetadata = () => {
        if (v.duration && v.duration > CHK_MAX_VIDEO_SECS) {
          setErr(`Keep videos short — about 15s max (this one is ${Math.round(v.duration)}s).`);
          URL.revokeObjectURL(url);
        } else {
          setPreview((p) => { if (p) URL.revokeObjectURL(p.url); return { url, type: 'video' }; });
          setFile(f);
        }
      };
      v.onerror = () => { // can't read metadata → allow (size already capped)
        setPreview((p) => { if (p) URL.revokeObjectURL(p.url); return { url, type: 'video' }; });
        setFile(f);
      };
      v.src = url;
    } else {
      setPreview((p) => { if (p) URL.revokeObjectURL(p.url); return { url, type: 'image' }; });
      setFile(f);
    }
  };

  const removeMedia = () => {
    setPreview((p) => { if (p) URL.revokeObjectURL(p.url); return null; });
    setFile(null); setErr(null);
  };

  const save = async () => {
    setBusy(true); setErr(null);
    try {
      let media_path = null, media_type = null;
      if (file) {
        const isVideo = (file.type || '').startsWith('video/');
        const ext = ((file.name || '').split('.').pop() || (isVideo ? 'mp4' : 'jpg')).toLowerCase().replace(/[^a-z0-9]/g, '') || (isVideo ? 'mp4' : 'jpg');
        media_path = `${gid}/${uid}/${day}.${ext}`;
        media_type = isVideo ? 'video' : 'image';
        const up = await sb.storage.from('checkin-media').upload(media_path, file, { upsert: true, contentType: file.type });
        if (up.error) throw up.error;
      }
      const payload = { group_id: gid, user_id: uid, day, done, note: note.trim() || null };
      if (media_path) { payload.media_path = media_path; payload.media_type = media_type; }
      const { error } = await sb.from('checkins').upsert(payload, { onConflict: 'group_id,user_id,day' });
      if (error) throw error;
      onSaved && onSaved({ user_id: uid, day, done, note: note.trim() || null,
        created_at: new Date().toISOString(),
        ...(media_path ? { media_path, media_type } : {}) });
      onClose();
    } catch (e) { setErr(e.message || String(e)); setBusy(false); }
  };

  const titleColor = done ? GREEN : INK_SOFT;
  return (
    <div onClick={onClose} style={{ position: 'absolute', inset: 0, zIndex: 68, background: 'rgba(31,42,36,0.4)', display: 'flex', alignItems: 'flex-end', animation: 'fadeIn 0.2s ease' }}>
      <div onClick={(e) => e.stopPropagation()} style={{ background: '#fff', width: '100%', borderRadius: '22px 22px 0 0', padding: '16px 18px calc(20px + env(safe-area-inset-bottom))', animation: 'slideUp 0.28s cubic-bezier(.2,.8,.2,1)', maxHeight: '90%', overflowY: 'auto' }}>
        <div style={{ width: 38, height: 4, borderRadius: 2, background: '#e0dac9', margin: '0 auto 14px' }} />
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
          {done ? (
            <svg width="20" height="20" viewBox="0 0 18 18" fill="none"><path d="M3.5 9.5 L7.5 13 L14.5 5.5" stroke={GREEN} strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" /></svg>
          ) : (
            <svg width="18" height="18" viewBox="0 0 14 14" fill="none"><path d="M3 3 L11 11 M11 3 L3 11" stroke={INK_SOFT} strokeWidth="2.2" strokeLinecap="round" /></svg>
          )}
          <div style={{ fontSize: 18, fontWeight: 800, color: titleColor }}>
            {done ? `You ${actionWord}` : 'Not done'} <span style={{ color: INK_SOFT, fontWeight: 600 }}>· {dayWord}</span>
          </div>
        </div>
        <div style={{ fontSize: 13, color: INK_SOFT, marginBottom: 14 }}>{done ? 'How did it go? Add a note or a photo/video.' : 'What got in the way? (optional)'}</div>

        <textarea value={note} onChange={(e) => setNote(e.target.value)} placeholder="How did it go?" rows={3} maxLength={2000} style={{
          width: '100%', border: `1px solid ${BORDER}`, borderRadius: 12, padding: '11px 13px', fontSize: 16,
          outline: 'none', fontFamily: 'inherit', resize: 'none', color: INK, background: '#fbf7ee', lineHeight: 1.4 }} />

        {preview ? (
          <div style={{ marginTop: 12, position: 'relative', borderRadius: 14, overflow: 'hidden', background: '#000', maxHeight: 320 }}>
            {preview.type === 'video'
              ? <video src={preview.url} controls playsInline style={{ width: '100%', maxHeight: 320, display: 'block', objectFit: 'contain', background: '#000' }} />
              : <img src={preview.url} alt="Selected media preview" style={{ width: '100%', maxHeight: 320, display: 'block', objectFit: 'contain', background: '#000' }} />}
            <button onClick={removeMedia} aria-label="Remove media" style={{
              position: 'absolute', top: 8, right: 8, width: 30, height: 30, borderRadius: '50%', border: 'none',
              background: 'rgba(0,0,0,0.6)', color: '#fff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}>
              <svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M3 3 L11 11 M11 3 L3 11" stroke="#fff" strokeWidth="2" strokeLinecap="round" /></svg>
            </button>
          </div>
        ) : (
          <button onClick={() => fileRef.current && fileRef.current.click()} style={{
            marginTop: 12, width: '100%', height: 46, borderRadius: 12, border: `1.5px dashed ${BORDER}`, background: '#fbf7ee',
            color: INK, fontSize: 14, fontWeight: 600, fontFamily: 'inherit', cursor: 'pointer',
            display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
            <svg width="18" height="18" viewBox="0 0 20 20" fill="none">
              <rect x="2.5" y="4.5" width="15" height="12" rx="2.5" stroke={INK_SOFT} strokeWidth="1.6" />
              <circle cx="7" cy="9" r="1.6" fill={INK_SOFT} />
              <path d="M4 15 L8.5 10.5 L12 13.5 L14 11.5 L17 14.5" stroke={INK_SOFT} strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
            </svg>
            Add photo / video
          </button>
        )}
        <input ref={fileRef} type="file" accept="image/*,video/*" capture onChange={pickFile} style={{ display: 'none' }} />
        <div style={{ fontSize: 11.5, color: INK_SOFT, marginTop: 6 }}>Photos up to 10 MB · videos ~15s, up to 30 MB.</div>

        {err && <div style={{ color: '#8a3b22', fontSize: 13, marginTop: 10, lineHeight: 1.4 }}>{err}</div>}

        <button onClick={save} disabled={busy} style={{
          width: '100%', height: 50, marginTop: 16, border: 'none', borderRadius: 14,
          background: busy ? '#9bb3a6' : (done ? GREEN : accent), color: '#fff',
          fontSize: 16, fontWeight: 700, cursor: busy ? 'default' : 'pointer', fontFamily: 'inherit' }}>
          {busy ? 'Saving…' : 'Save'}
        </button>
        <button onClick={onClose} disabled={busy} style={{
          width: '100%', height: 44, marginTop: 8, borderRadius: 12, border: 'none', background: 'transparent',
          color: INK_SOFT, fontSize: 15, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>Cancel</button>
      </div>
    </div>
  );
}

// ── Stories: full-screen story viewer ───────────────────────────────────────
// Media fills the screen; top progress bar; header (name · day label · time);
// bottom gradient with the check-in note (2-line preview + "more"). Records a
// story_views row on open. Opens from a chat thumbnail or a history row.
function StoryViewer({ story, onClose, onSeen, onOpenHistory }) {
  const [url, setUrl] = React.useState(story.url || null);
  const [err, setErr] = React.useState(null);
  const [expanded, setExpanded] = React.useState(false);
  const [playing, setPlaying] = React.useState(false);
  const videoRef = React.useRef(null);

  // Sign the media URL if we weren't handed one, and mark the story as seen.
  React.useEffect(() => {
    let alive = true;
    if (!story.url && story.media_path) {
      sb.storage.from('checkin-media').createSignedUrl(story.media_path, 3600).then(({ data, error }) => {
        if (!alive) return;
        if (error) setErr(error.message); else if (data) setUrl(data.signedUrl);
      });
    }
    // Mark seen (Phase 2 uses story_views for the ring). Ignore duplicates.
    try {
      sb.from('story_views').upsert(
        { group_id: story.gid, subject_user: story.subjectUser, day: story.day },
        { onConflict: 'viewer_id,group_id,subject_user,day', ignoreDuplicates: true }
      ).then(() => {}, () => {});
    } catch (_) {}
    // Reflect the view immediately in the parent so the did-it ring stops pulsing.
    try { onSeen && onSeen(story.subjectUser, story.day); } catch (_) {}
    return () => { alive = false; };
  }, [story.media_path, story.gid, story.subjectUser, story.day]);

  const toggleVideo = () => {
    const v = videoRef.current; if (!v) return;
    if (v.paused) { v.play(); setPlaying(true); } else { v.pause(); setPlaying(false); }
  };

  const timeLabel = story.createdAt ? (() => {
    const d = new Date(story.createdAt);
    return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
  })() : '';

  return (
    <div style={{ position: 'absolute', inset: 0, zIndex: 82, background: '#000', display: 'flex', flexDirection: 'column', animation: 'fadeIn 0.2s ease' }}>
      {/* media */}
      <div style={{ position: 'absolute', inset: 0 }} onClick={story.media_type === 'video' ? toggleVideo : undefined}>
        {url ? (
          story.media_type === 'video'
            ? <video ref={videoRef} src={url} playsInline onEnded={() => setPlaying(false)}
                style={{ width: '100%', height: '100%', objectFit: 'contain', background: '#000' }} />
            : <img src={url} alt="" style={{ width: '100%', height: '100%', objectFit: 'contain', background: '#000' }} />
        ) : (
          <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
            {err ? <div style={{ color: '#fff', fontSize: 13, padding: 24, textAlign: 'center' }}>Couldn’t load this story.<br />{err}</div> : <div className="spinner" />}
          </div>
        )}
        {story.media_type === 'video' && !playing && url && (
          <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' }}>
            <div style={{ width: 66, height: 66, borderRadius: '50%', background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
              <svg width="30" height="30" viewBox="0 0 24 24" fill="#fff"><path d="M6 3 L21 12 L6 21 Z" /></svg>
            </div>
          </div>
        )}
      </div>

      {/* top progress bar (single story in Phase 1) */}
      <div style={{ position: 'absolute', top: 'calc(10px + env(safe-area-inset-top))', left: 12, right: 12, display: 'flex', gap: 4, zIndex: 3 }}>
        <div style={{ flex: 1, height: 3, borderRadius: 2, background: '#fff' }} />
      </div>

      {/* header: avatar · name · day label · time */}
      <div style={{ position: 'absolute', top: 'calc(22px + env(safe-area-inset-top))', left: 12, right: 12, display: 'flex', alignItems: 'center', gap: 10, zIndex: 3 }}>
        {/* Tapping the avatar/name closes the viewer and opens this person's timeline. */}
        <button onClick={() => onOpenHistory && onOpenHistory()} aria-label={`${story.name} timeline`}
          style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0, flex: 1, border: 'none', background: 'transparent', padding: 0, cursor: 'pointer', textAlign: 'left' }}>
          <div style={{ border: '2px solid #fff', borderRadius: '50%', flexShrink: 0 }}><Avatar variant={story.variant} size={34} /></div>
          <div style={{ minWidth: 0, flex: 1 }}>
            <div style={{ color: '#fff', fontWeight: 700, fontSize: 14, textShadow: '0 1px 4px rgba(0,0,0,0.6)' }}>
              {story.name}
            </div>
            <div style={{ color: 'rgba(255,255,255,0.9)', fontSize: 11.5, textShadow: '0 1px 4px rgba(0,0,0,0.6)' }}>
              {story.dayLabel}{timeLabel ? ` · ${timeLabel}` : ''}
            </div>
          </div>
        </button>
        <button onClick={onClose} aria-label="Close" style={{
          width: 36, height: 36, borderRadius: '50%', border: 'none', background: 'rgba(0,0,0,0.35)', color: '#fff',
          cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0, flexShrink: 0 }}>
          <svg width="16" height="16" viewBox="0 0 14 14" fill="none"><path d="M3 3 L11 11 M11 3 L3 11" stroke="#fff" strokeWidth="2" strokeLinecap="round" /></svg>
        </button>
      </div>

      {/* bottom gradient: verb + note (preview + expand) */}
      <div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: '48px 16px calc(20px + env(safe-area-inset-bottom))', zIndex: 3,
        background: 'linear-gradient(to top, rgba(0,0,0,0.82), rgba(0,0,0,0.35) 62%, transparent)' }}>
        <div style={{ color: '#fff', fontWeight: 700, fontSize: 13, marginBottom: 4, display: 'flex', alignItems: 'center', gap: 6, textShadow: '0 1px 4px rgba(0,0,0,0.6)' }}>
          {story.done ? (
            <><svg width="14" height="14" viewBox="0 0 18 18" fill="none"><path d="M3.5 9.5 L7.5 13 L14.5 5.5" stroke="#fff" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" /></svg>{story.actionWord}</>
          ) : (
            <><svg width="13" height="13" viewBox="0 0 14 14" fill="none"><path d="M3 3 L11 11 M11 3 L3 11" stroke="#fff" strokeWidth="2" strokeLinecap="round" /></svg>missed it</>
          )}
        </div>
        {story.note ? (
          <div onClick={() => setExpanded((v) => !v)} style={{ cursor: 'pointer' }}>
            <div style={{ color: '#fff', fontSize: 14, lineHeight: 1.4, textShadow: '0 1px 4px rgba(0,0,0,0.6)',
              ...(expanded ? {} : { display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }) }}>
              {story.note}
            </div>
            {!expanded && story.note.length > 80 && (
              <div style={{ color: 'rgba(255,255,255,0.85)', fontSize: 12, marginTop: 4, textDecoration: 'underline' }}>Read more ▾</div>
            )}
          </div>
        ) : null}
      </div>
    </div>
  );
}

// ── settings sheet: name + photo + sign out ────────────────────────────────
function SettingsSheet({ uid, profile, onClose, onSaved, onSignOut }) {
  const [name, setName] = React.useState(profile.name || '');
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);
  const [pushBusy, setPushBusy] = React.useState(false);
  const [pushMsg, setPushMsg] = React.useState(null);
  const notifPerm = (typeof Notification !== 'undefined') ? Notification.permission : 'unsupported';
  const fileRef = React.useRef(null);

  const saveName = async () => {
    setBusy(true); setErr(null);
    const { error } = await sb.from('profiles').update({ name: name.trim() || 'Someone' }).eq('id', uid);
    setBusy(false);
    if (error) return setErr(error.message);
    onSaved();
  };
  const pickPhoto = async (e) => {
    const file = e.target.files && e.target.files[0];
    if (!file) return;
    setBusy(true); setErr(null);
    const ext = (file.name.split('.').pop() || 'jpg').toLowerCase();
    const path = `${uid}/avatar-${Date.now()}.${ext}`;
    const up = await sb.storage.from('avatars').upload(path, file, { upsert: true, contentType: file.type });
    if (up.error) { setBusy(false); return setErr(up.error.message); }
    const { data } = sb.storage.from('avatars').getPublicUrl(path);
    const { error } = await sb.from('profiles').update({ avatar_url: data.publicUrl }).eq('id', uid);
    setBusy(false);
    if (error) return setErr(error.message);
    onSaved();
  };

  return (
    <div onClick={onClose} style={{ position: 'absolute', inset: 0, zIndex: 60, background: 'rgba(31,42,36,0.35)', display: 'flex', alignItems: 'flex-end' }}>
      <div onClick={(e) => e.stopPropagation()} style={{ background: '#fff', width: '100%', borderRadius: '22px 22px 0 0', padding: '18px 18px calc(22px + env(safe-area-inset-bottom))' }}>
        <div style={{ width: 38, height: 4, borderRadius: 2, background: '#e0dac9', margin: '0 auto 16px' }} />
        <div style={{ fontSize: 18, fontWeight: 700, marginBottom: 16 }}>Your profile</div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 16 }}>
          <Avatar variant={0} size={64} />
          <button onClick={() => fileRef.current && fileRef.current.click()} disabled={busy} style={{
            border: `1px solid ${BORDER}`, background: '#fff', borderRadius: 10, padding: '10px 14px',
            fontSize: 14, fontWeight: 600, color: INK, cursor: 'pointer', fontFamily: 'inherit' }}>
            {busy ? 'Working…' : 'Change photo'}
          </button>
          <input ref={fileRef} type="file" accept="image/*" onChange={pickPhoto} style={{ display: 'none' }} />
        </div>
        <div style={{ fontSize: 11, fontWeight: 800, letterSpacing: 0.6, textTransform: 'uppercase', color: INK_SOFT, margin: '0 2px 6px' }}>Display name</div>
        <div style={{ display: 'flex', gap: 8, marginBottom: 18 }}>
          <input value={name} onChange={(e) => setName(e.target.value)} placeholder="Your name" style={{
            flex: 1, height: 44, border: `1px solid ${BORDER}`, borderRadius: 12, padding: '0 13px', fontSize: 16, outline: 'none', fontFamily: 'inherit' }} />
          <button onClick={saveName} disabled={busy} style={{
            height: 44, padding: '0 16px', border: 'none', borderRadius: 12, background: GREEN, color: '#fff',
            fontSize: 15, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit' }}>Save</button>
        </div>
        <div style={{ fontSize: 11, fontWeight: 800, letterSpacing: 0.6, textTransform: 'uppercase', color: INK_SOFT, margin: '0 2px 6px' }}>Notifications</div>
        <button onClick={async () => {
          setPushBusy(true); setErr(null); setPushMsg(null);
          try { await enablePush(uid); setPushMsg('On — you’ll be pinged when the other person checks in or messages.'); }
          catch (e) { setErr(e.message || String(e)); }
          setPushBusy(false);
        }} disabled={pushBusy} style={{
          width: '100%', height: 46, border: `1px solid ${notifPerm === 'granted' ? GREEN : BORDER}`,
          background: notifPerm === 'granted' ? '#eaf3ec' : '#fff', color: notifPerm === 'granted' ? GREEN : INK,
          borderRadius: 12, fontSize: 15, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', marginBottom: 8 }}>
          {pushBusy ? 'Enabling…' : notifPerm === 'granted' ? '🔔 Notifications on — tap to re-enable' : 'Turn on notifications'}
        </button>
        {pushMsg && <div style={{ color: GREEN, fontSize: 13, marginBottom: 12 }}>{pushMsg}</div>}
        {err && <div style={{ color: '#8a3b22', fontSize: 13, marginBottom: 12 }}>{err}</div>}
        <button onClick={onSignOut} style={{ width: '100%', height: 46, border: `1px solid ${BORDER}`, background: '#fff',
          borderRadius: 12, fontSize: 15, fontWeight: 600, color: INK_SOFT, cursor: 'pointer', fontFamily: 'inherit' }}>Sign out</button>
      </div>
    </div>
  );
}

// ── auth screen (email + password; sticky session) ─────────────────────────
function AuthScreen() {
  const [mode, setMode] = React.useState('signin'); // 'signin' | 'signup' | 'forgot'
  const [email, setEmail] = React.useState('');
  const [pw, setPw] = React.useState('');
  const [name, setName] = React.useState('');
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);
  const [sentTo, setSentTo] = React.useState(null); // email a reset link was requested for

  const submit = async () => {
    setBusy(true); setErr(null);
    try {
      if (mode === 'signup') {
        const { data, error } = await sb.auth.signUp({ email: email.trim(), password: pw, options: { data: { name: name.trim() } } });
        if (error) throw error;
        // Supabase hides "already registered" for privacy: it returns a user with
        // no identities and no session. Detect that and guide them to sign in.
        if (data && data.user && (!data.session) && Array.isArray(data.user.identities) && data.user.identities.length === 0) {
          setMode('signin');
          throw new Error('That email already has an account — please sign in below.');
        }
      } else {
        const { error } = await sb.auth.signInWithPassword({ email: email.trim(), password: pw });
        if (error) throw error;
      }
    } catch (e) { setErr(e.message || String(e)); }
    setBusy(false);
  };

  // Forgot-password: email the user a recovery link. We always show the same
  // neutral confirmation (don't reveal whether an account exists).
  const sendReset = async () => {
    setBusy(true); setErr(null);
    try {
      const redirectTo = window.location.origin + window.location.pathname;
      const { error } = await sb.auth.resetPasswordForEmail(email.trim(), { redirectTo });
      if (error) throw error;
      setSentTo(email.trim());
    } catch (e) { setErr(e.message || String(e)); }
    setBusy(false);
  };

  const inputStyle = { width: '100%', height: 48, border: `1px solid ${BORDER}`, borderRadius: 12, padding: '0 14px', fontSize: 16, outline: 'none', fontFamily: 'inherit', marginBottom: 10, background: '#fff' };
  const linkBtn = { background: 'none', border: 'none', color: GREEN, fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' };

  // ── Forgot-password view ──────────────────────────────────────────────────
  if (mode === 'forgot') {
    return (
      <div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '28px 24px', background: '#fbf7ee' }}>
        <div style={{ textAlign: 'center', marginBottom: 22 }}>
          <div style={{ fontSize: 40 }}>{CFG.HABIT.icon}</div>
          <div style={{ fontFamily: 'Caveat, cursive', fontSize: 32, color: GREEN_DEEP, lineHeight: 1.05 }}>Reset your password</div>
          <div style={{ fontSize: 13, color: INK_SOFT, marginTop: 6, lineHeight: 1.35 }}>We’ll email you a link to set a new one.</div>
        </div>
        {sentTo ? (
          <div>
            <div style={{ background: '#e6f0e8', border: '1px solid #cfe3d5', color: GREEN_DEEP, borderRadius: 12, padding: '14px 16px', fontSize: 14, lineHeight: 1.45 }}>
              If that email has an account, a reset link is on its way — check your inbox.
            </div>
            <button onClick={() => { setMode('signin'); setSentTo(null); setErr(null); }} style={{ ...linkBtn, marginTop: 18, width: '100%' }}>
              ← Back to sign in
            </button>
          </div>
        ) : (
          <>
            <input value={email} onChange={(e) => setEmail(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') sendReset(); }} placeholder="Email" type="email" autoCapitalize="none" autoCorrect="off" style={inputStyle} />
            {err && <div style={{ color: '#8a3b22', fontSize: 13, margin: '2px 2px 10px' }}>{err}</div>}
            <button onClick={sendReset} disabled={busy || !email} style={{
              width: '100%', height: 50, border: 'none', borderRadius: 14, background: (busy || !email) ? '#9bb3a6' : GREEN, color: '#fff',
              fontSize: 16, fontWeight: 700, cursor: (busy || !email) ? 'default' : 'pointer', fontFamily: 'inherit', marginTop: 4 }}>
              {busy ? 'Please wait…' : 'Send reset link'}
            </button>
            <button onClick={() => { setMode('signin'); setErr(null); }} style={{ ...linkBtn, marginTop: 14 }}>
              ← Back to sign in
            </button>
          </>
        )}
      </div>
    );
  }

  return (
    <div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '28px 24px', background: '#fbf7ee' }}>
      <div style={{ textAlign: 'center', marginBottom: 22 }}>
        <div style={{ fontSize: 40 }}>{CFG.HABIT.icon}</div>
        <div style={{ fontSize: 26, fontWeight: 800, color: GREEN_DEEP, lineHeight: 1.15, marginTop: 6 }}>Build habits, together.</div>
        <div style={{ fontSize: 12.5, color: INK_SOFT, marginTop: 8, lineHeight: 1.45, maxWidth: 300, marginLeft: 'auto', marginRight: 'auto' }}>
          <span style={{ fontWeight: 700, color: GREEN_DEEP }}>Nakama</span>: “Friends who become like close family through shared purpose and experience.”
        </div>
        <div style={{ fontSize: 13, color: INK_SOFT, marginTop: 12 }}>{mode === 'signup' ? 'Create your account' : 'Welcome back — sign in'}</div>
      </div>
      {mode === 'signup' && <input value={name} onChange={(e) => setName(e.target.value)} placeholder="Your name" style={inputStyle} />}
      <input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" type="email" autoCapitalize="none" autoCorrect="off" style={inputStyle} />
      <input value={pw} onChange={(e) => setPw(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') submit(); }} placeholder="Password" type="password" style={inputStyle} />
      {mode === 'signin' && (
        <div style={{ textAlign: 'right', margin: '-4px 2px 10px' }}>
          <button onClick={() => { setMode('forgot'); setErr(null); setSentTo(null); }} style={{ ...linkBtn, fontSize: 13 }}>
            Forgot password?
          </button>
        </div>
      )}
      {err && <div style={{ color: '#8a3b22', fontSize: 13, margin: '2px 2px 10px' }}>{err}</div>}
      <button onClick={submit} disabled={busy || !email || !pw} style={{
        width: '100%', height: 50, border: 'none', borderRadius: 14, background: busy ? '#9bb3a6' : GREEN, color: '#fff',
        fontSize: 16, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit', marginTop: 4 }}>
        {busy ? 'Please wait…' : (mode === 'signup' ? 'Sign up' : 'Sign in')}
      </button>
      <button onClick={() => { setMode(mode === 'signup' ? 'signin' : 'signup'); setErr(null); }} style={{
        marginTop: 14, ...linkBtn }}>
        {mode === 'signup' ? 'I already have an account' : "New here? Create an account"}
      </button>
    </div>
  );
}

// ── Recovery: "Set a new password" screen ───────────────────────────────────
// Shown when the user arrives via a password-reset email link (a recovery
// session is active). On success they're signed in and proceed into the app.
function SetNewPassword({ onDone }) {
  const [pw, setPw] = React.useState('');
  const [pw2, setPw2] = React.useState('');
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);

  const submit = async () => {
    if (pw.length < 6) { setErr('Use at least 6 characters.'); return; }
    if (pw !== pw2) { setErr('Those two passwords don’t match.'); return; }
    setBusy(true); setErr(null);
    try {
      const { error } = await sb.auth.updateUser({ password: pw });
      if (error) throw error;
      onDone(); // recovery session is now a normal session — enter the app
    } catch (e) { setErr(e.message || String(e)); setBusy(false); }
  };

  const inputStyle = { width: '100%', height: 48, border: `1px solid ${BORDER}`, borderRadius: 12, padding: '0 14px', fontSize: 16, outline: 'none', fontFamily: 'inherit', marginBottom: 10, background: '#fff' };
  return (
    <div data-testid="set-new-password" style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '28px 24px', background: '#fbf7ee' }}>
      <div style={{ textAlign: 'center', marginBottom: 22 }}>
        <div style={{ fontSize: 40 }}>{CFG.HABIT.icon}</div>
        <div style={{ fontFamily: 'Caveat, cursive', fontSize: 32, color: GREEN_DEEP, lineHeight: 1.05 }}>Set a new password</div>
        <div style={{ fontSize: 13, color: INK_SOFT, marginTop: 6, lineHeight: 1.35 }}>Almost there — choose a new password for your account.</div>
      </div>
      <input value={pw} onChange={(e) => setPw(e.target.value)} placeholder="New password" type="password" style={inputStyle} />
      <input value={pw2} onChange={(e) => setPw2(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') submit(); }} placeholder="Confirm new password" type="password" style={inputStyle} />
      {err && <div style={{ color: '#8a3b22', fontSize: 13, margin: '2px 2px 10px' }}>{err}</div>}
      <button onClick={submit} disabled={busy || !pw || !pw2} style={{
        width: '100%', height: 50, border: 'none', borderRadius: 14, background: (busy || !pw || !pw2) ? '#9bb3a6' : GREEN, color: '#fff',
        fontSize: 16, fontWeight: 700, cursor: (busy || !pw || !pw2) ? 'default' : 'pointer', fontFamily: 'inherit', marginTop: 4 }}>
        {busy ? 'Saving…' : 'Save new password'}
      </button>
    </div>
  );
}

// ════════════════════════════════════════════════════════════════════════════
// M3 — Home list, Create-habit flow, Join-by-invite, invite links.
// All screens live here (same scope as `sb`, colors, buildCells) and reuse the
// wireframe's look (screens/home-today.jsx, screens/hifi-create.jsx).
// ════════════════════════════════════════════════════════════════════════════

const GOLD = '#d4a536';
const CREATE_ACCENTS = ['#2f6b52', '#2e6477', '#6b3c5e', '#a55a3a', '#3a3f3d', '#b88a2a'];
const CREATE_ICONS = ['🏃','🧘','📖','💧','🥗','📵','💤','🎯','🚿','🚲','🏋️','🎨','🎸','✍️','📚','🧗','😴','🎧','🌅','🐶'];

// ── B5: stable per-person colour ────────────────────────────────────────────
// Every member gets a pleasant, distinct colour derived deterministically from
// their user_id (so it's the same on every device / reload). "You" always render
// in the app green for your own bubbles; everyone else hashes into this palette.
const MEMBER_PALETTE = ['#2d6cb5','#b5652d','#a5445e','#5e5bbf','#2f8f6b','#b5852d','#7a4fb0','#2f8f8f','#b04a8a','#557a2d','#c0632f','#3563a8'];
function colorForUser(id) {
  const s = String(id || '');
  let h = 0;
  for (let i = 0; i < s.length; i++) h = (Math.imul(h, 31) + s.charCodeAt(i)) >>> 0;
  return MEMBER_PALETTE[h % MEMBER_PALETTE.length];
}

// ── B3: a large curated emoji set for the tap-to-open icon picker ────────────
const PICKER_EMOJIS = [
  '🎯','✅','🔥','⭐','💪','🏃','🚶','🧘','🚴','🏊','🏋️','🤸','⚽','🏀','🎾','🥊','⛰️','🧗',
  '📖','📚','✍️','📝','🎓','💻','🧠','💡','🎨','🎸','🎹','🎧','🎤','🎬','📷','🎮','♟️','🧩',
  '💧','🥗','🍎','🥦','🥕','🍳','🥤','☕','🍵','🚭','📵','💊','🩺','🦷','🧴','🛁','🚿','🧼',
  '💤','🌅','🌙','⏰','🌱','🌳','🌸','🌻','🍀','🐶','🐱','🐢','🦋','🐝','🌍','☀️','⛅','❄️',
  '💰','📈','💵','🧾','🗂️','📅','🧹','🧺','🛏️','🍽️','🛒','🏠','🚗','✈️','🎒','⛺','🎣','🏕️',
  '❤️','🙏','😊','😌','🥰','🎉','🏆','🥇','👏','🤝','🫶','🌟','⚡','🎵','🕯️','📿','🧿','🔔',
];
const CAD_OPTS = [
  { id: 'daily',  label: 'Every day',          sub: 'Mon through Sun, no exceptions' },
  { id: 'set',    label: 'Specific days',      sub: 'Pick which weekdays' },
  { id: 'n-week', label: 'A few times a week',  sub: 'Your choice when' },
  { id: 'weekly', label: 'Once a week',        sub: 'Pick a target day' },
];
const DAY_LETTERS = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; // index = Mon=0..Sun=6

// UI cadence id → DB cadence_kind
function kindToDb(cad) {
  return cad === 'set' ? 'set-days' : cad === 'n-week' ? 'n-per-week' : cad === 'weekly' ? 'weekly' : 'daily';
}

// The current user's personal streak in a group, from their own check-in entries
// (day → 'done'|'miss'). Mirrors the per-member streak logic in LiveHabitScreen.
function personalStreakFor(cadence, entries, today) {
  const cells = buildCells({ today, cadence, daysBack: 120 });
  return computeMemberStreak(cells, { entries });
}

async function copyText(t) {
  try { await navigator.clipboard.writeText(t); return true; }
  catch (e) {
    try {
      const ta = document.createElement('textarea');
      ta.value = t; ta.style.position = 'fixed'; ta.style.opacity = '0';
      document.body.appendChild(ta); ta.focus(); ta.select();
      const ok = document.execCommand('copy'); document.body.removeChild(ta); return ok;
    } catch (_) { return false; }
  }
}

// Pull a token out of a pasted invite link OR accept a raw token.
function extractInviteToken(s) {
  s = (s || '').trim();
  if (!s) return '';
  try { const u = new URL(s); const t = u.searchParams.get('invite'); if (t) return t; } catch (_) {}
  const m = s.match(/[?&]invite=([^&\s]+)/);
  if (m) return decodeURIComponent(m[1]);
  return s;
}

// Map the redeem_invite RPC's raised errors to friendly copy.
function friendlyInviteError(msg) {
  const m = (msg || '').toLowerCase();
  if (m.includes('invalid invite')) return "That invite link isn’t valid. Ask for a fresh one.";
  if (m.includes('revoked'))        return "This invite was turned off. Ask for a fresh link.";
  if (m.includes('expired'))        return "This invite has expired. Ask for a fresh link.";
  if (m.includes('not authenticated')) return "Please sign in first, then open the link again.";
  return msg || "Couldn’t join with that link.";
}

// A small avatar (real photo or a colored initial) — used in Home rows so we
// don't disturb the design's global AVATAR_URLS (that's habit-page-scoped).
function MiniAvatar({ url, name, size = 22, accent = GREEN }) {
  const initial = (name || '?').trim().charAt(0).toUpperCase() || '?';
  return url
    ? <img src={url} alt="" style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', display: 'block', background: '#fff' }} />
    : <div style={{ width: size, height: size, borderRadius: '50%', background: accent, color: '#fff',
        display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: size * 0.44, fontWeight: 700 }}>{initial}</div>;
}

// ── "Install as app" banner (Home only) ─────────────────────────────────────
// Shows a small, dismissible card unless the app is already installed
// (standalone) or the user has dismissed it before (localStorage flag). On
// Android/Chrome the button triggers the native install; on iOS/others it opens
// step-by-step instructions with an Android / iPhone toggle.
const INSTALL_DISMISS_KEY = 'habitbuddy:install-dismissed';
function InstallPrompt() {
  const [dismissed, setDismissed] = React.useState(() => {
    try { return localStorage.getItem(INSTALL_DISMISS_KEY) === '1'; } catch (_) { return false; }
  });
  const [standalone] = React.useState(() => isStandaloneApp());
  const [deferred, setDeferred] = React.useState(() => __deferredInstallPrompt);
  const [gone, setGone] = React.useState(false);
  const [showHelp, setShowHelp] = React.useState(false);
  const [plat, setPlat] = React.useState(() => /iphone|ipad|ipod/i.test(navigator.userAgent || '') ? 'ios' : 'android');

  React.useEffect(() => {
    const onInstallable = () => setDeferred(__deferredInstallPrompt);
    const onInstalled = () => setGone(true);
    window.addEventListener('habitbuddy:installable', onInstallable);
    window.addEventListener('habitbuddy:installed', onInstalled);
    return () => {
      window.removeEventListener('habitbuddy:installable', onInstallable);
      window.removeEventListener('habitbuddy:installed', onInstalled);
    };
  }, []);

  // If installed or already dismissed, render nothing.
  if (standalone || dismissed || gone) return null;

  const dismiss = () => {
    try { localStorage.setItem(INSTALL_DISMISS_KEY, '1'); } catch (_) {}
    setDismissed(true);
  };
  const onPrimary = async () => {
    if (deferred) {
      try { deferred.prompt(); await deferred.userChoice; } catch (_) {}
      __deferredInstallPrompt = null; setDeferred(null); setGone(true);
    } else {
      setShowHelp(true);
    }
  };

  const toggleBtn = (id, label) => (
    <button data-testid={'install-tab-' + id} onClick={() => setPlat(id)} style={{
      flex: 1, height: 34, border: 'none', borderRadius: 9, cursor: 'pointer', fontFamily: 'inherit',
      fontSize: 13, fontWeight: 700, background: plat === id ? '#fff' : 'transparent',
      color: plat === id ? GREEN_DEEP : INK_SOFT, boxShadow: plat === id ? '0 1px 3px rgba(0,0,0,0.12)' : 'none' }}>
      {label}
    </button>
  );

  return (
    <div data-testid="install-banner" style={{ position: 'relative', background: '#fff', borderRadius: 14, padding: 14,
      margin: '12px 4px 4px', boxShadow: '0 1px 0 rgba(0,0,0,0.04)', border: `1px solid ${BORDER}` }}>
      <button data-testid="install-dismiss" onClick={dismiss} aria-label="Dismiss" style={{
        position: 'absolute', top: 8, right: 8, width: 26, height: 26, borderRadius: '50%', border: 'none',
        background: 'transparent', color: INK_SOFT, fontSize: 18, lineHeight: '20px', cursor: 'pointer', fontFamily: 'inherit' }}>×</button>
      <div style={{ display: 'flex', alignItems: 'center', gap: 12, paddingRight: 22 }}>
        <div style={{ width: 40, height: 40, borderRadius: 11, background: GREEN + '22', display: 'flex',
          alignItems: 'center', justifyContent: 'center', fontSize: 20, flexShrink: 0 }}>📲</div>
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ fontSize: 14, fontWeight: 700, color: INK }}>Install {CFG.HABIT.name}</div>
          <div style={{ fontSize: 12, color: INK_SOFT, marginTop: 1, lineHeight: 1.35 }}>Add it to your home screen for one-tap access.</div>
        </div>
        <button data-testid="install-primary" onClick={onPrimary} style={{
          flexShrink: 0, height: 36, padding: '0 14px', border: 'none', borderRadius: 10, background: GREEN, color: '#fff',
          fontSize: 13, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit' }}>
          {deferred ? 'Install' : 'How?'}
        </button>
      </div>

      {showHelp && !deferred && (
        <div data-testid="install-help" style={{ marginTop: 12, borderTop: `1px solid ${BORDER}`, paddingTop: 12 }}>
          <div style={{ display: 'flex', gap: 4, background: '#f1ede2', borderRadius: 11, padding: 4, marginBottom: 10 }}>
            {toggleBtn('android', 'Android')}
            {toggleBtn('ios', 'iPhone')}
          </div>
          {plat === 'android' ? (
            <div data-testid="install-help-android" style={{ fontSize: 13, color: INK, lineHeight: 1.5 }}>
              Tap the <b>⋮ menu</b> (top-right of your browser) → <b>Install app</b> / <b>Add to Home screen</b>.
            </div>
          ) : (
            <div data-testid="install-help-ios" style={{ fontSize: 13, color: INK, lineHeight: 1.5 }}>
              Tap the <b>Share</b> icon <span style={{ fontSize: 15 }}>⬆️</span> in Safari → <b>Add to Home Screen</b>.
            </div>
          )}
        </div>
      )}
    </div>
  );
}

// ── Home: the list of the user's groups ─────────────────────────────────────
function HomeScreen({ uid, groups, me, onOpenGroup, onCreate, onJoin, onOpenAccount, onSignOut }) {
  const today = React.useMemo(() => new Date(), []);
  const todayStr = ymd(today);
  const gids = React.useMemo(() => groups.map((g) => g.id), [groups]);
  const [summary, setSummary] = React.useState(null); // { [gid]: { cadence, members, streak, youDone } }

  React.useEffect(() => {
    let cancelled = false;
    (async () => {
      if (!gids.length) { setSummary({}); return; }
      const since = ymd(addDays(today, -160));
      const [{ data: gm }, { data: checks }] = await Promise.all([
        sb.from('group_members').select('group_id,user_id').in('group_id', gids),
        sb.from('checkins').select('group_id,user_id,day,done').in('group_id', gids).gte('day', since),
      ]);
      const ids = [...new Set((gm || []).map((r) => r.user_id))];
      const { data: profs } = ids.length ? await sb.from('profiles').select('id,name,avatar_url').in('id', ids) : { data: [] };
      const profById = {}; (profs || []).forEach((p) => { profById[p.id] = p; });
      const byGroup = {};
      for (const g of groups) {
        const cadence = cadenceFromGroup(g);
        const memberRows = (gm || []).filter((r) => r.group_id === g.id);
        const myEntries = {};
        (checks || []).forEach((c) => { if (c.group_id === g.id && c.user_id === uid) myEntries[c.day] = (c.done === false ? 'miss' : 'done'); });
        const doneToday = {};
        (checks || []).forEach((c) => { if (c.group_id === g.id && c.day === todayStr && c.done !== false) doneToday[c.user_id] = true; });
        const members = memberRows.map((r) => {
          const p = profById[r.user_id] || { name: 'Someone' };
          return { id: r.user_id, name: p.name, avatar_url: p.avatar_url,
            done: r.user_id === uid ? (myEntries[todayStr] === 'done') : !!doneToday[r.user_id] };
        }).sort((a, b) => (a.id === uid ? -1 : b.id === uid ? 1 : 0));
        // How many times I've completed this in the CURRENT week (Mon..Sun) —
        // for weekly / x-per-week progress ("X of N this week").
        const weekStart = startOfWeekMon(today);
        let youWeekDone = 0;
        for (let i = 0; i < 7; i++) { if (myEntries[ymd(addDays(weekStart, i))] === 'done') youWeekDone++; }
        byGroup[g.id] = { cadence, members, streak: personalStreakFor(cadence, myEntries, today), youDone: myEntries[todayStr] === 'done', youWeekDone };
      }
      if (!cancelled) setSummary(byGroup);
    })();
    return () => { cancelled = true; };
  }, [gids.join(','), uid]);

  const DOW = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
  const dateLine = `${DOW[today.getDay()]} · ${MONTH_SHORT[today.getMonth()]} ${today.getDate()}`;
  const firstName = ((me && me.name) || 'there').split(' ')[0];

  return (
    <div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#fbf7ee', color: INK }}>
      {/* header */}
      <div style={{ padding: 'calc(18px + env(safe-area-inset-top)) 20px 12px', display: 'flex', alignItems: 'center', gap: 12 }}>
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ fontSize: 13, color: INK_SOFT, fontWeight: 500 }}>{dateLine}</div>
          <div style={{ fontSize: 26, fontWeight: 700, letterSpacing: -0.5, marginTop: 2 }}>Hi, {firstName}</div>
        </div>
        <button onClick={onOpenAccount} aria-label="Your account" style={{ border: 'none', background: 'transparent', padding: 0, cursor: 'pointer', borderRadius: '50%' }}>
          <MiniAvatar url={me && me.avatar_url} name={me && me.name} size={40} />
        </button>
      </div>

      <div style={{ flex: 1, overflow: 'auto', padding: '0 16px 20px' }}>
        <InstallPrompt />
        {summary === null ? (
          <div style={{ height: 160, display: 'flex', alignItems: 'center', justifyContent: 'center' }}><div className="spinner" /></div>
        ) : groups.length === 0 ? (
          <div style={{ textAlign: 'center', padding: '40px 20px 24px', color: INK }}>
            <div style={{ fontSize: 46 }}>🌱</div>
            <div style={{ fontSize: 20, fontWeight: 700, marginTop: 8 }}>Start your first habit</div>
            <div style={{ fontSize: 14, color: INK_SOFT, maxWidth: 280, margin: '8px auto 0', lineHeight: 1.45 }}>
              Create a habit and invite friends, or join one someone shared with you.
            </div>
          </div>
        ) : (
          <>
            <div style={{ fontSize: 11, fontWeight: 700, letterSpacing: 0.8, color: INK_SOFT, textTransform: 'uppercase', margin: '10px 4px 8px' }}>Your habits</div>
            {groups.map((g) => {
              const s = summary[g.id] || { members: [], streak: 0, youDone: false, youWeekDone: 0, cadence: cadenceFromGroup(g) };
              const doneCount = s.members.filter((m) => m.done).length;
              const cad = s.cadence;
              const todayMon = (today.getDay() + 6) % 7; // Mon=0..Sun=6
              // Per-cadence progress line + whether to show it at all today.
              let showProgress = true;
              let progressText;
              if (cad.kind === 'weekly' || cad.kind === 'n-per-week') {
                const N = cad.kind === 'weekly' ? 1 : (cad.n || 1);
                const X = Math.min(s.youWeekDone || 0, N);
                progressText = X >= N ? `${N} of ${N} this week ✓` : `${X} of ${N} this week`;
              } else if (cad.kind === 'set-days') {
                // Only surface progress on scheduled weekdays; otherwise there's
                // nothing to do today, so the line is hidden entirely.
                showProgress = (cad.weekdays || []).includes(todayMon);
                progressText = s.youDone ? 'Done today ✓' : `${doneCount}/${s.members.length} today`;
              } else { // daily
                progressText = s.youDone ? 'Done today ✓' : `${doneCount}/${s.members.length} today`;
              }
              return (
                <div key={g.id} role="button" tabIndex={0} data-testid={'group-row-' + g.id}
                  onClick={() => onOpenGroup(g.id)}
                  style={{ background: '#fff', borderRadius: 14, padding: 14, marginBottom: 10,
                    display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer', boxShadow: '0 1px 0 rgba(0,0,0,0.04)' }}>
                  <div style={{ width: 44, height: 44, borderRadius: 12, background: (g.accent || GREEN) + '22',
                    display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 22, flexShrink: 0 }}>{g.icon}</div>
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
                      <div style={{ fontSize: 15, fontWeight: 600, lineHeight: 1.2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{g.name}</div>
                      {s.streak > 0 && (
                        <span style={{ fontSize: 11, fontWeight: 700, color: GOLD, display: 'inline-flex', alignItems: 'center', gap: 2, flexShrink: 0 }}>
                          <svg width="10" height="10" viewBox="0 0 12 12"><path d="M6 1.5 C 7 4 9 4.5 9 7 A 3 3 0 0 1 3 7 C 3 5.5 4 5 4 4 C 5 4.5 5 3 6 1.5 Z" fill={GOLD} /></svg>
                          {s.streak}
                        </span>
                      )}
                    </div>
                    <div style={{ fontSize: 12, color: INK_SOFT, marginTop: 2 }}>{cadenceLabel(s.cadence)}</div>
                    {showProgress && (
                      <div style={{ marginTop: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
                        <div style={{ display: 'flex' }}>
                          {s.members.slice(0, 4).map((m, i) => (
                            <div key={m.id} style={{ marginLeft: i === 0 ? 0 : -6, border: '2px solid #fff', borderRadius: '50%',
                              opacity: m.done ? 1 : 0.5, filter: m.done ? 'none' : 'saturate(0.3)' }}>
                              <MiniAvatar url={m.avatar_url} name={m.name} size={22} accent={g.accent || GREEN} />
                            </div>
                          ))}
                          {s.members.length > 4 && (
                            <div style={{ width: 22, height: 22, borderRadius: '50%', marginLeft: -6, border: '2px solid #fff', background: '#ece5d4',
                              fontSize: 10, fontWeight: 700, color: INK_SOFT, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>+{s.members.length - 4}</div>
                          )}
                        </div>
                        <span style={{ fontSize: 11, color: INK_SOFT, marginLeft: 4 }}>{progressText}</span>
                      </div>
                    )}
                  </div>
                  <svg width="9" height="16" viewBox="0 0 9 16" style={{ flexShrink: 0 }}><path d="M2 2 L7 8 L2 14" stroke={INK_SOFT} strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>
                </div>
              );
            })}
          </>
        )}

        {/* actions */}
        <button onClick={onCreate} style={{ width: '100%', marginTop: 6, height: 52, borderRadius: 14, border: 'none',
          background: GREEN, color: '#fff', fontSize: 16, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit',
          display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, boxShadow: `0 6px 16px ${GREEN}45` }}>
          <svg width="15" height="15" viewBox="0 0 14 14"><path d="M7 2 V12 M2 7 H12" stroke="#fff" strokeWidth="2" strokeLinecap="round" /></svg>
          New habit
        </button>
        <button onClick={onJoin} style={{ width: '100%', marginTop: 10, height: 48, borderRadius: 12,
          border: `1.5px solid ${BORDER}`, background: '#fff', color: INK, fontSize: 15, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
          display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
          <svg width="16" height="16" viewBox="0 0 18 18" fill="none"><path d="M7 11 L11 7 M6 12 A 3 3 0 0 1 6 6 H 8 M12 6 A 3 3 0 0 1 12 12 H 10" stroke={INK} strokeWidth="1.5" strokeLinecap="round" /></svg>
          Join with a link
        </button>
        <button onClick={onSignOut} style={{ width: '100%', marginTop: 18, height: 40, border: 'none', background: 'transparent',
          color: INK_SOFT, fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>Sign out</button>
      </div>
    </div>
  );
}

// ── B3: icon + colour picker (bottom sheet) ─────────────────────────────────
// Tap the habit icon → this opens. Choose an emoji from a large grid (with a
// live text filter) and a colour (preset swatches PLUS a native custom picker).
// Reused by the Create flow and by owner edits on the Habit Settings screen.
function IconColorPicker({ icon, accent, onPick, onClose }) {
  useBackClose(true, onClose);
  const [em, setEm] = React.useState(icon || '🎯');
  const [color, setColor] = React.useState(accent || '#2f6b52');
  const [q, setQ] = React.useState('');
  const colorRef = React.useRef(null);
  const isCustom = !CREATE_ACCENTS.includes(color);
  // Keyword map for a friendlier filter (matches on a few common words).
  const KW = {
    '🏃':'run','🚶':'walk','🧘':'meditate calm yoga','🚴':'bike cycle','🏊':'swim','🏋️':'gym lift weight',
    '📖':'read book','📚':'read study books','✍️':'write journal','💧':'water drink hydrate','🥗':'salad eat healthy',
    '🍎':'apple fruit eat','😴':'sleep rest','💤':'sleep','🌅':'morning sunrise','🚭':'no smoking quit','📵':'no phone screen',
    '💪':'strong workout','🎸':'guitar music practice','🎨':'art paint draw','💰':'money save budget','🧹':'clean tidy chores',
  };
  const list = q.trim()
    ? PICKER_EMOJIS.filter((e) => e.includes(q.trim()) || (KW[e] || '').includes(q.trim().toLowerCase()))
    : PICKER_EMOJIS;
  const apply = () => { onPick({ icon: em, accent: color }); onClose(); };

  return (
    <div onClick={onClose} style={{ position: 'absolute', inset: 0, zIndex: 75, background: 'rgba(31,42,36,0.4)', display: 'flex', alignItems: 'flex-end', animation: 'fadeIn 0.2s ease' }}>
      <div onClick={(e) => e.stopPropagation()} style={{ width: '100%', background: '#fbf7ee', color: INK, borderRadius: '20px 20px 0 0',
        padding: '12px 18px calc(18px + env(safe-area-inset-bottom))', maxHeight: '82%', display: 'flex', flexDirection: 'column', animation: 'slideUp 0.25s cubic-bezier(.2,.8,.2,1)' }}>
        <div style={{ width: 40, height: 4, borderRadius: 2, background: '#d8d2c2', margin: '0 auto 12px', flexShrink: 0 }} />
        {/* preview + title */}
        <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12, flexShrink: 0 }}>
          <div style={{ width: 52, height: 52, borderRadius: 14, background: color, color: '#fff', fontSize: 28, flexShrink: 0,
            display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: `0 6px 14px ${color}44` }}>{em}</div>
          <div style={{ flex: 1 }}>
            <div style={{ fontSize: 17, fontWeight: 800, letterSpacing: -0.2 }}>Choose icon &amp; colour</div>
            <div style={{ fontSize: 12, color: INK_SOFT, marginTop: 1 }}>Pick an emoji and a colour for this habit.</div>
          </div>
        </div>
        {/* colour row */}
        <div style={{ display: 'flex', gap: 10, alignItems: 'center', marginBottom: 12, flexShrink: 0 }}>
          {CREATE_ACCENTS.map((a) => (
            <button key={a} onClick={() => setColor(a)} aria-label={'colour ' + a} style={{ width: 34, height: 34, borderRadius: '50%', background: a,
              border: 'none', cursor: 'pointer', padding: 0, outline: color === a ? `2px solid ${a}` : 'none', outlineOffset: 3,
              boxShadow: 'inset 0 0 0 2px rgba(255,255,255,0.18)' }} />
          ))}
          <button onClick={() => colorRef.current && colorRef.current.click()} aria-label="Custom colour" title="Custom colour"
            style={{ width: 34, height: 34, borderRadius: '50%', cursor: 'pointer', padding: 0, position: 'relative', flexShrink: 0,
              background: isCustom ? color : 'conic-gradient(#f00,#ff0,#0f0,#0ff,#00f,#f0f,#f00)',
              border: '2px solid #fff', outline: isCustom ? `2px solid ${color}` : `1px solid ${BORDER}`, outlineOffset: 2,
              display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
            {isCustom
              ? <svg width="15" height="15" viewBox="0 0 18 18" fill="none"><path d="M3.5 9.5 L7.5 13 L14.5 5.5" stroke="#fff" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" /></svg>
              : <span style={{ fontSize: 15, fontWeight: 800, color: '#fff', textShadow: '0 1px 2px rgba(0,0,0,0.4)' }}>+</span>}
            <input ref={colorRef} type="color" value={color} onChange={(e) => setColor(e.target.value)}
              style={{ position: 'absolute', inset: 0, opacity: 0, width: '100%', height: '100%', cursor: 'pointer', border: 'none', padding: 0 }} />
          </button>
        </div>
        {/* search */}
        <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search emoji (e.g. run, read, water)…"
          style={{ width: '100%', height: 40, border: `1px solid ${BORDER}`, borderRadius: 12, padding: '0 13px', fontSize: 15, outline: 'none', fontFamily: 'inherit', background: '#fff', marginBottom: 10, flexShrink: 0 }} />
        {/* emoji grid */}
        <div style={{ flex: 1, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: 6, paddingBottom: 4 }}>
          {list.map((e, i) => (
            <button key={e + i} onClick={() => setEm(e)} style={{ aspectRatio: '1', borderRadius: 10, fontSize: 22, cursor: 'pointer', padding: 0,
              background: em === e ? color + '22' : '#fff', border: em === e ? `1.5px solid ${color}` : '1.5px solid transparent',
              boxShadow: em === e ? 'none' : 'inset 0 0 0 1px ' + BORDER }}>{e}</button>
          ))}
          {list.length === 0 && <div style={{ gridColumn: '1 / -1', textAlign: 'center', color: INK_SOFT, fontSize: 13, padding: '16px 0' }}>No matches</div>}
        </div>
        <button onClick={apply} style={{ width: '100%', height: 50, marginTop: 12, borderRadius: 14, border: 'none', background: color, color: '#fff',
          fontSize: 16, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0 }}>Done</button>
      </div>
    </div>
  );
}

// ── Create-habit flow (2 steps) → rpc/create_group ──────────────────────────
function CreateHabitFlow({ myName, onClose, onCreated }) {
  const [step, setStep] = React.useState(0);
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);
  const [pickerOpen, setPickerOpen] = React.useState(false);
  const [d, setD] = React.useState({
    name: '', icon: '🎯', accent: '#2f6b52', actionWord: 'did it', goal: '',
    cad: 'daily', days: [], n: 3, weekday: 0,
  });
  const set = (patch) => setD((prev) => ({ ...prev, ...patch }));
  const accent = d.accent;
  const sampleName = (myName || 'Sam').split(' ')[0];

  const submit = async () => {
    setErr(null);
    if (!d.name.trim()) { setErr('Give your habit a name.'); return; }
    const days = d.cad === 'set' ? [...d.days].sort((a, b) => a - b) : null;
    if (d.cad === 'set' && (!days || days.length === 0)) { setErr('Pick at least one day.'); return; }
    setBusy(true);
    const { data, error } = await sb.rpc('create_group', {
      p_name: d.name.trim(),
      p_icon: d.icon || '✅',
      p_accent: d.accent,
      p_cadence_kind: kindToDb(d.cad),
      p_cadence_days: days,                                 // Mon=0..Sun=6
      p_cadence_n: d.cad === 'n-week' ? d.n : null,
      p_cadence_target_day: d.cad === 'weekly' ? d.weekday : null, // Mon=0..Sun=6
      p_action_word: (d.actionWord || 'did it').trim() || 'did it',
    });
    // create_group returns the groups row (single composite).
    const group = Array.isArray(data) ? data[0] : data;
    // C2: set the creator's own personal goal (optional) via the safe RPC.
    if (!error && group && d.goal && d.goal.trim()) {
      try { await sb.rpc('set_my_goal', { p_group: group.id, p_goal: d.goal.trim() }); } catch (_) {}
    }
    setBusy(false);
    if (error) { setErr(error.message); return; }
    onCreated(group);
  };

  const footerLabel = step === 0 ? 'Continue' : (busy ? 'Creating…' : 'Create habit');
  const onFooter = () => { if (step === 0) { if (!d.name.trim()) { setErr('Give your habit a name.'); return; } setErr(null); setStep(1); } else submit(); };
  const onHeaderBack = step === 0 ? onClose : () => { setErr(null); setStep(0); };

  return (
    <div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#fbf7ee', color: INK }}>
      {/* header: back/close + progress */}
      <div style={{ padding: 'calc(12px + env(safe-area-inset-top)) 18px 4px', display: 'flex', alignItems: 'center', gap: 12 }}>
        <button onClick={onHeaderBack} aria-label={step === 0 ? 'Close' : 'Back'} style={{ width: 36, height: 36, borderRadius: '50%', background: '#fff',
          border: `1px solid ${BORDER}`, cursor: 'pointer', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
          {step === 0
            ? <svg width="14" height="14" viewBox="0 0 14 14"><path d="M3 3 L11 11 M11 3 L3 11" stroke={INK} strokeWidth="1.8" strokeLinecap="round" /></svg>
            : <svg width="18" height="18" viewBox="0 0 18 18"><path d="M11 4 L6 9 L11 14" stroke={INK} strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>}
        </button>
        <div style={{ flex: 1, display: 'flex', gap: 4, padding: '0 8px' }}>
          {[0, 1].map((i) => <div key={i} style={{ flex: 1, height: 4, borderRadius: 2, background: i <= step ? accent : '#e6e0d0' }} />)}
        </div>
        <div style={{ width: 36 }} />
      </div>

      <div style={{ flex: 1, minHeight: 0, overflow: 'auto', paddingBottom: 12 }}>
        {step === 0 ? (
          <>
            <div style={{ padding: '4px 22px 0' }}>
              <h2 style={{ fontSize: 26, fontWeight: 800, letterSpacing: -0.4, margin: 0 }}>What’s the habit?</h2>
              <p style={{ fontSize: 14, color: INK_SOFT, marginTop: 6 }}>Give it a name, a look, and a word for check-ins.</p>
            </div>
            {/* name + icon card — tap the icon to open the emoji/colour picker */}
            <div style={{ padding: '16px 22px 0' }}>
              <div style={{ background: '#fff', borderRadius: 18, padding: 16, display: 'flex', gap: 14, alignItems: 'center', boxShadow: '0 1px 0 rgba(0,0,0,0.04)' }}>
                <button onClick={() => setPickerOpen(true)} aria-label="Choose icon and colour" data-testid="create-icon-btn"
                  style={{ width: 58, height: 58, borderRadius: 16, background: accent, color: '#fff', fontSize: 30, flexShrink: 0, cursor: 'pointer',
                  border: 'none', padding: 0, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: `0 6px 14px ${accent}33` }}>
                  {d.icon}
                  <span style={{ position: 'absolute', bottom: -3, right: -3, width: 22, height: 22, borderRadius: '50%', background: '#fff', color: INK,
                    border: `1px solid ${BORDER}`, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
                    <svg width="11" height="11" viewBox="0 0 14 14" fill="none"><path d="M2 11 L2 12 L3 12 L11 4 L10 3 Z M9 4 L10 5" stroke={INK} strokeWidth="1.6" strokeLinejoin="round" strokeLinecap="round" fill="none" /></svg>
                  </span>
                </button>
                <input value={d.name} onChange={(e) => set({ name: e.target.value })} placeholder="Habit name" autoFocus
                  style={{ flex: 1, border: 'none', background: 'transparent', fontFamily: 'inherit', fontSize: 22, fontWeight: 700, color: INK, outline: 'none', minWidth: 0 }} />
              </div>
              <div style={{ fontSize: 12, color: INK_SOFT, margin: '8px 2px 0' }}>Tap the icon to choose an emoji &amp; colour.</div>
            </div>
            {/* your personal goal (C2, optional) */}
            <div style={{ padding: '18px 22px 0' }}>
              <CreateLabel>Your goal (optional)</CreateLabel>
              <p style={{ fontSize: 12.5, color: INK_SOFT, margin: '6px 0 8px', lineHeight: 1.45 }}>Only you set this — a personal reason or target for this habit.</p>
              <textarea value={d.goal} onChange={(e) => set({ goal: e.target.value })} placeholder="Your goal for this habit…" rows={3} maxLength={500}
                style={{ width: '100%', border: `1px solid ${BORDER}`, borderRadius: 12, padding: '10px 13px', fontSize: 15, outline: 'none',
                  fontFamily: 'inherit', background: '#fff', resize: 'none', lineHeight: 1.4, color: INK }} />
            </div>
            {/* action word */}
            <div style={{ padding: '20px 22px 0' }}>
              <CreateLabel>Check-in word</CreateLabel>
              <p style={{ fontSize: 12.5, color: INK_SOFT, margin: '6px 0 8px', lineHeight: 1.45 }}>
                When someone checks in, we’ll say “<strong style={{ color: INK }}>{sampleName} {(d.actionWord || 'did it').trim() || 'did it'}</strong>”. E.g. meditated, ran, practiced.
              </p>
              <input value={d.actionWord} onChange={(e) => set({ actionWord: e.target.value })} placeholder="did it"
                style={{ width: '100%', height: 46, border: `1px solid ${BORDER}`, borderRadius: 12, padding: '0 13px', fontSize: 16, outline: 'none', fontFamily: 'inherit', background: '#fff' }} />
            </div>
          </>
        ) : (
          <>
            <div style={{ padding: '4px 22px 0' }}>
              <h2 style={{ fontSize: 26, fontWeight: 800, letterSpacing: -0.4, margin: 0 }}>How often?</h2>
              <p style={{ fontSize: 14, color: INK_SOFT, marginTop: 6 }}>Pick a cadence that fits real life.</p>
            </div>
            <div style={{ padding: '16px 22px 0', display: 'flex', flexDirection: 'column', gap: 10 }}>
              {CAD_OPTS.map((o) => {
                const active = d.cad === o.id;
                return (
                  <button key={o.id} onClick={() => set({ cad: o.id })} style={{ background: '#fff', border: 'none', borderRadius: 14, padding: '14px 16px',
                    display: 'flex', alignItems: 'center', gap: 14, cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit',
                    boxShadow: active ? `0 0 0 2px ${accent}, 0 1px 0 rgba(0,0,0,0.04)` : '0 1px 0 rgba(0,0,0,0.04), inset 0 0 0 1px ' + BORDER }}>
                    <div style={{ width: 22, height: 22, borderRadius: '50%', background: active ? accent : 'transparent', border: active ? 'none' : '1.5px solid ' + BORDER,
                      display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
                      {active && <div style={{ width: 8, height: 8, borderRadius: '50%', background: '#fff' }} />}
                    </div>
                    <div style={{ flex: 1 }}>
                      <div style={{ fontSize: 15, fontWeight: 600, color: INK }}>{o.label}</div>
                      <div style={{ fontSize: 12, color: INK_SOFT, marginTop: 2 }}>{o.sub}</div>
                    </div>
                  </button>
                );
              })}
            </div>

            {d.cad === 'set' && (
              <div style={{ padding: '16px 22px 0' }}>
                <CreateLabel>Which days</CreateLabel>
                <div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
                  {DAY_LETTERS.map((letter, i) => {
                    const on = d.days.includes(i);
                    return (
                      <button key={i} data-day={i} onClick={() => set({ days: on ? d.days.filter((x) => x !== i) : [...d.days, i] })}
                        style={{ flex: 1, height: 44, borderRadius: 12, background: on ? accent : '#fff', color: on ? '#fff' : INK,
                          border: on ? 'none' : '1.5px solid ' + BORDER, cursor: 'pointer', fontFamily: 'inherit', fontWeight: 700, fontSize: 14 }}>{letter}</button>
                    );
                  })}
                </div>
              </div>
            )}

            {d.cad === 'n-week' && (
              <div style={{ padding: '16px 22px 0' }}>
                <CreateLabel>How many times a week</CreateLabel>
                <div style={{ display: 'flex', alignItems: 'center', gap: 14, marginTop: 8, background: '#fff', borderRadius: 14, padding: '12px 16px', boxShadow: '0 1px 0 rgba(0,0,0,0.04)' }}>
                  <button onClick={() => set({ n: Math.max(1, d.n - 1) })} aria-label="Fewer" style={{ width: 40, height: 40, borderRadius: 10, border: `1px solid ${BORDER}`, background: '#fff', fontSize: 22, cursor: 'pointer' }}>−</button>
                  <div style={{ flex: 1, textAlign: 'center', fontSize: 18, fontWeight: 700 }}>{d.n}× per week</div>
                  <button onClick={() => set({ n: Math.min(7, d.n + 1) })} aria-label="More" style={{ width: 40, height: 40, borderRadius: 10, border: `1px solid ${BORDER}`, background: '#fff', fontSize: 22, cursor: 'pointer' }}>+</button>
                </div>
              </div>
            )}

            {d.cad === 'weekly' && (
              <div style={{ padding: '16px 22px 0' }}>
                <CreateLabel>Target day</CreateLabel>
                <div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
                  {DAY_LETTERS.map((letter, i) => {
                    const on = d.weekday === i;
                    return (
                      <button key={i} data-day={i} onClick={() => set({ weekday: i })}
                        style={{ flex: 1, height: 44, borderRadius: 12, background: on ? accent : '#fff', color: on ? '#fff' : INK,
                          border: on ? 'none' : '1.5px solid ' + BORDER, cursor: 'pointer', fontFamily: 'inherit', fontWeight: 700, fontSize: 14 }}>{letter}</button>
                    );
                  })}
                </div>
              </div>
            )}
          </>
        )}
        {err && <div style={{ color: '#8a3b22', fontSize: 13, padding: '14px 22px 0' }}>{err}</div>}
      </div>

      <div style={{ padding: '14px 22px calc(22px + env(safe-area-inset-bottom))' }}>
        <button onClick={onFooter} disabled={busy} style={{ width: '100%', height: 52, borderRadius: 14, border: 'none',
          background: busy ? '#9bb3a6' : accent, color: '#fff', fontFamily: 'inherit', fontSize: 16, fontWeight: 700, cursor: 'pointer',
          boxShadow: `0 6px 16px ${accent}45`, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
          {footerLabel}
          {!busy && <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4 8 H12 M9 5 L12 8 L9 11" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>}
        </button>
      </div>
      {pickerOpen && <IconColorPicker icon={d.icon} accent={d.accent} onPick={(p) => set(p)} onClose={() => setPickerOpen(false)} />}
    </div>
  );
}

function CreateLabel({ children }) {
  return <div style={{ fontSize: 11, color: INK_SOFT, fontWeight: 700, letterSpacing: 0.6, textTransform: 'uppercase' }}>{children}</div>;
}

// ── Join-by-invite: paste a link/token → rpc/redeem_invite ──────────────────
function JoinScreen({ onClose, onJoined }) {
  const [val, setVal] = React.useState('');
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);

  const submit = async () => {
    const token = extractInviteToken(val);
    if (!token) { setErr('Paste an invite link or code.'); return; }
    setBusy(true); setErr(null);
    const { data, error } = await sb.rpc('redeem_invite', { p_token: token });
    setBusy(false);
    if (error) { setErr(friendlyInviteError(error.message)); return; }
    onJoined(data);
  };

  return (
    <div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#fbf7ee', color: INK }}>
      <div style={{ padding: 'calc(12px + env(safe-area-inset-top)) 18px 4px', display: 'flex', alignItems: 'center', gap: 12 }}>
        <button onClick={onClose} aria-label="Close" style={{ width: 36, height: 36, borderRadius: '50%', background: '#fff', border: `1px solid ${BORDER}`,
          cursor: 'pointer', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
          <svg width="14" height="14" viewBox="0 0 14 14"><path d="M3 3 L11 11 M11 3 L3 11" stroke={INK} strokeWidth="1.8" strokeLinecap="round" /></svg>
        </button>
        <div style={{ fontSize: 16, fontWeight: 700 }}>Join a habit</div>
      </div>
      <div style={{ flex: 1, overflow: 'auto', padding: '18px 22px' }}>
        <h2 style={{ fontSize: 24, fontWeight: 800, letterSpacing: -0.4, margin: 0 }}>Have an invite?</h2>
        <p style={{ fontSize: 14, color: INK_SOFT, marginTop: 6, lineHeight: 1.5 }}>Paste the invite link (or just the code) someone shared with you.</p>
        <input value={val} onChange={(e) => setVal(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
          placeholder="https://…/?invite=… or code" autoFocus
          style={{ width: '100%', height: 48, marginTop: 16, border: `1px solid ${BORDER}`, borderRadius: 12, padding: '0 14px', fontSize: 15, outline: 'none', fontFamily: 'inherit', background: '#fff' }} />
        {err && <div style={{ color: '#8a3b22', fontSize: 13, marginTop: 10 }}>{err}</div>}
      </div>
      <div style={{ padding: '14px 22px calc(22px + env(safe-area-inset-bottom))' }}>
        <button onClick={submit} disabled={busy} style={{ width: '100%', height: 52, borderRadius: 14, border: 'none',
          background: busy ? '#9bb3a6' : GREEN, color: '#fff', fontFamily: 'inherit', fontSize: 16, fontWeight: 700, cursor: 'pointer' }}>
          {busy ? 'Joining…' : 'Join'}
        </button>
      </div>
    </div>
  );
}

// ── Invite sheet: create an invite row (owner-only) + shareable link ────────
function InviteSheet({ gid, uid, onClose }) {
  const [url, setUrl] = React.useState(null);
  const [err, setErr] = React.useState(null);
  const [copied, setCopied] = React.useState(false);
  useBackClose(true, onClose);

  React.useEffect(() => {
    (async () => {
      const { data, error } = await sb.from('invites').insert({ group_id: gid, created_by: uid }).select('token').single();
      if (error) setErr(error.message);
      else setUrl(`${location.origin}${location.pathname}?invite=${data.token}`);
    })();
  }, [gid, uid]);

  const doCopy = async () => { if (!url) return; const ok = await copyText(url); setCopied(ok); if (ok) setTimeout(() => setCopied(false), 1800); };

  return (
    <div onClick={() => window.history.back()} style={{ position: 'absolute', inset: 0, zIndex: 65, background: 'rgba(31,42,36,0.35)', display: 'flex', alignItems: 'flex-end' }}>
      <div onClick={(e) => e.stopPropagation()} style={{ background: '#fff', width: '100%', borderRadius: '22px 22px 0 0', padding: '18px 20px calc(24px + env(safe-area-inset-bottom))' }}>
        <div style={{ width: 38, height: 4, borderRadius: 2, background: '#e0dac9', margin: '0 auto 16px' }} />
        <div style={{ fontSize: 20, fontWeight: 800, letterSpacing: -0.3 }}>Invite people 🎉</div>
        <div style={{ fontSize: 13.5, color: INK_SOFT, marginTop: 6, lineHeight: 1.5 }}>
          Share this link. Anyone who opens it can join and start checking in with you.
        </div>
        <div style={{ marginTop: 16, background: '#f4efe3', borderRadius: 12, padding: '12px 14px', display: 'flex', alignItems: 'center', gap: 10, minHeight: 48 }}>
          {err ? <span style={{ fontSize: 13, color: '#8a3b22' }}>{err}</span>
            : url ? <span style={{ flex: 1, fontSize: 13, color: INK, wordBreak: 'break-all', lineHeight: 1.35 }}>{url}</span>
            : <div className="spinner" />}
        </div>
        <button onClick={doCopy} disabled={!url} style={{ width: '100%', height: 50, marginTop: 12, borderRadius: 14, border: 'none',
          background: copied ? GREEN_DEEP : GREEN, color: '#fff', fontSize: 16, fontWeight: 700, cursor: url ? 'pointer' : 'default', fontFamily: 'inherit' }}>
          {copied ? 'Copied ✓' : 'Copy invite link'}
        </button>
        <button onClick={() => window.history.back()} style={{ width: '100%', height: 46, marginTop: 8, borderRadius: 12,
          border: `1px solid ${BORDER}`, background: '#fff', color: INK_SOFT, fontSize: 15, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>Done</button>
      </div>
    </div>
  );
}

// ── C2: personal-goal prompt (bottom sheet) — set only YOUR goal via RPC ─────
function GoalPromptSheet({ gid, initial = '', title = 'Set your goal', subtitle, onDone }) {
  useBackClose(true, onDone);
  const [goal, setGoal] = React.useState(initial || '');
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);
  const save = async () => {
    setBusy(true); setErr(null);
    const { error } = await sb.rpc('set_my_goal', { p_group: gid, p_goal: goal.trim() });
    setBusy(false);
    if (error) { setErr(error.message); return; }
    onDone(goal.trim());
  };
  return (
    <div onClick={onDone} style={{ position: 'absolute', inset: 0, zIndex: 66, background: 'rgba(31,42,36,0.4)', display: 'flex', alignItems: 'flex-end', animation: 'fadeIn 0.2s ease' }}>
      <div onClick={(e) => e.stopPropagation()} style={{ width: '100%', background: '#fff', color: INK, borderRadius: '22px 22px 0 0',
        padding: '16px 20px calc(22px + env(safe-area-inset-bottom))', animation: 'slideUp 0.25s cubic-bezier(.2,.8,.2,1)' }}>
        <div style={{ width: 40, height: 4, borderRadius: 2, background: '#e0dac9', margin: '0 auto 14px' }} />
        <div style={{ fontSize: 19, fontWeight: 800, letterSpacing: -0.3 }}>{title} 🎯</div>
        <div style={{ fontSize: 13, color: INK_SOFT, marginTop: 4, lineHeight: 1.5 }}>{subtitle || 'A personal reason or target — only you see and set this. You can skip it.'}</div>
        <textarea value={goal} onChange={(e) => setGoal(e.target.value)} placeholder="Your goal for this habit…" rows={3} maxLength={500} autoFocus
          style={{ width: '100%', marginTop: 14, border: `1px solid ${BORDER}`, borderRadius: 12, padding: '10px 13px', fontSize: 15, outline: 'none',
            fontFamily: 'inherit', background: '#fff', resize: 'none', lineHeight: 1.4, color: INK }} />
        {err && <div style={{ color: '#8a3b22', fontSize: 13, marginTop: 8 }}>{err}</div>}
        <button onClick={save} disabled={busy} style={{ width: '100%', height: 50, marginTop: 12, borderRadius: 14, border: 'none',
          background: busy ? '#9bb3a6' : GREEN, color: '#fff', fontSize: 16, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit' }}>
          {busy ? 'Saving…' : 'Save goal'}
        </button>
        <button onClick={() => onDone(null)} style={{ width: '100%', height: 44, marginTop: 8, borderRadius: 12, border: 'none', background: 'transparent',
          color: INK_SOFT, fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>Skip for now</button>
      </div>
    </div>
  );
}

// ── B4: Habit Settings — group details + member list; owner can edit name/emoji
// and invite; non-owners see it read-only and can leave. (Profile lives on the
// Home avatar → Account; the habit-page ⋯ now opens THIS.) ────────────────────
function HabitSettings({ gid, uid, group, roster, onClose, onSaved, onLeft }) {
  const isOwner = group.created_by === uid;
  const cadence = React.useMemo(() => cadenceFromGroup(group), [group]);
  const [name, setName] = React.useState(group.name || '');
  const [icon, setIcon] = React.useState(group.icon || '✅');
  const [accent, setAccent] = React.useState(group.accent || GREEN);
  const [pickerOpen, setPickerOpen] = React.useState(false);
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);
  const [okMsg, setOkMsg] = React.useState(null);
  const [meta, setMeta] = React.useState(null);   // user_id -> { role, goal }
  const [showInvite, setShowInvite] = React.useState(false);
  const [goalPrompt, setGoalPrompt] = React.useState(false);
  const [confirmLeave, setConfirmLeave] = React.useState(false);
  const flash = (m) => { setOkMsg(m); setTimeout(() => setOkMsg(null), 1600); };

  const loadMeta = React.useCallback(async () => {
    const { data } = await sb.from('group_members').select('user_id, role, goal').eq('group_id', gid);
    const map = {}; (data || []).forEach((r) => { map[r.user_id] = { role: r.role, goal: r.goal }; });
    setMeta(map);
  }, [gid]);
  React.useEffect(() => { loadMeta(); }, [loadMeta]);

  const dirty = isOwner && (name.trim() !== (group.name || '').trim() || icon !== group.icon || accent !== group.accent);
  const saveGroup = async () => {
    if (!name.trim()) { setErr('Give the habit a name.'); return; }
    setBusy(true); setErr(null);
    const { error } = await sb.from('groups').update({ name: name.trim(), icon, accent }).eq('id', gid);
    setBusy(false);
    if (error) { setErr(error.message); return; }
    flash('Saved ✓'); onSaved && onSaved();
  };
  const doLeave = async () => {
    setBusy(true); setErr(null);
    const { error } = await sb.from('group_members').delete().eq('group_id', gid).eq('user_id', uid);
    setBusy(false);
    if (error) { setErr(error.message); return; }
    onLeft && onLeft();
  };

  // member rows in roster order (me first), enriched with role/goal + colour.
  const rows = (roster || []).map((p, i) => ({
    id: p.id, name: p.name, variant: i,
    isYou: p.id === uid,
    role: (meta && meta[p.id] && meta[p.id].role) || 'member',
    goal: meta && meta[p.id] ? meta[p.id].goal : null,
    color: p.id === uid ? GREEN : colorForUser(p.id),
  }));

  return (
    <div style={{ position: 'absolute', inset: 0, zIndex: 62, background: '#fbf7ee', color: INK, display: 'flex', flexDirection: 'column' }}>
      <div style={{ padding: 'calc(14px + env(safe-area-inset-top)) 14px 10px', display: 'flex', alignItems: 'center', gap: 8 }}>
        <button onClick={onClose} aria-label="Back" style={{ width: 36, height: 36, borderRadius: '50%', border: `1px solid ${BORDER}`, background: '#fff',
          cursor: 'pointer', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
          <svg width="18" height="18" viewBox="0 0 18 18"><path d="M11 4 L6 9 L11 14" stroke={INK} strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>
        </button>
        <div style={{ flex: 1, fontSize: 16, fontWeight: 700, letterSpacing: -0.2 }}>Habit settings</div>
      </div>

      <div style={{ flex: 1, overflow: 'auto', padding: '6px 18px 32px' }}>
        {/* identity card */}
        <div style={{ background: '#fff', borderRadius: 18, padding: '20px 18px', textAlign: 'center', boxShadow: '0 1px 0 rgba(0,0,0,0.04)' }}>
          <button onClick={() => isOwner && setPickerOpen(true)} disabled={!isOwner} aria-label={isOwner ? 'Change icon and colour' : undefined}
            data-testid="habitset-icon" style={{ border: 'none', background: 'transparent', padding: 0, cursor: isOwner ? 'pointer' : 'default', position: 'relative', display: 'inline-block' }}>
            <div style={{ width: 76, height: 76, borderRadius: 20, background: accent, color: '#fff', fontSize: 40,
              display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: `0 6px 16px ${accent}44` }}>{icon}</div>
            {isOwner && (
              <span style={{ position: 'absolute', bottom: -4, right: -4, width: 28, height: 28, borderRadius: '50%', background: '#fff', color: INK,
                border: `1px solid ${BORDER}`, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
                <svg width="13" height="13" viewBox="0 0 14 14" fill="none"><path d="M2 11 L2 12 L3 12 L11 4 L10 3 Z M9 4 L10 5" stroke={INK} strokeWidth="1.6" strokeLinejoin="round" strokeLinecap="round" fill="none" /></svg>
              </span>
            )}
          </button>
          {isOwner ? (
            <div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
              <input value={name} onChange={(e) => setName(e.target.value)} placeholder="Habit name" data-testid="habitset-name-input"
                style={{ flex: 1, height: 44, border: `1px solid ${BORDER}`, borderRadius: 12, padding: '0 13px', fontSize: 16, outline: 'none', fontFamily: 'inherit', textAlign: 'center', fontWeight: 700 }} />
            </div>
          ) : (
            <div style={{ fontSize: 22, fontWeight: 800, marginTop: 14, letterSpacing: -0.3 }}>{group.name}</div>
          )}
          <div style={{ fontSize: 13, color: INK_SOFT, marginTop: 8 }}>{cadenceLabel(cadence)} · {(roster || []).length} member{(roster || []).length === 1 ? '' : 's'}</div>
          {isOwner && (
            <button onClick={saveGroup} disabled={busy || !dirty} data-testid="habitset-save" style={{ marginTop: 14, height: 44, width: '100%', border: 'none', borderRadius: 12,
              background: dirty ? GREEN : '#c7d3cb', color: '#fff', fontSize: 15, fontWeight: 700, cursor: dirty ? 'pointer' : 'default', fontFamily: 'inherit' }}>
              {busy ? 'Saving…' : 'Save changes'}
            </button>
          )}
          {okMsg && <div style={{ color: GREEN, fontSize: 13, marginTop: 8 }}>{okMsg}</div>}
          {err && <div style={{ color: '#8a3b22', fontSize: 13, marginTop: 8 }}>{err}</div>}
        </div>

        {/* owner: invite */}
        {isOwner && (
          <AccountGroup title="Invite">
            <button onClick={() => setShowInvite(true)} data-testid="habitset-invite" style={{ width: '100%', padding: '15px 16px', display: 'flex', alignItems: 'center', gap: 12,
              background: 'transparent', border: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit' }}>
              <div style={{ width: 36, height: 36, borderRadius: 10, background: GREEN + '18', color: GREEN, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
                <svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M7 11 L11 7 M6 12 A 3 3 0 0 1 6 6 H 8 M12 6 A 3 3 0 0 1 12 12 H 10" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /></svg>
              </div>
              <div style={{ flex: 1 }}>
                <div style={{ fontSize: 14, fontWeight: 600 }}>Invite people</div>
                <div style={{ fontSize: 12, color: INK_SOFT, marginTop: 2 }}>Share a link so friends can join</div>
              </div>
              <svg width="8" height="14" viewBox="0 0 9 16" style={{ flexShrink: 0 }}><path d="M2 2 L7 8 L2 14" stroke={INK_SOFT} strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>
            </button>
          </AccountGroup>
        )}

        {/* members + their goals */}
        <AccountGroup title={`Members${roster ? ' · ' + roster.length : ''}`}>
          {meta === null ? (
            <div style={{ padding: 20, display: 'flex', justifyContent: 'center' }}><div className="spinner" /></div>
          ) : rows.map((m, i) => (
            <div key={m.id} data-testid="habitset-member" style={{ padding: '12px 16px', display: 'flex', gap: 12, alignItems: 'flex-start', borderTop: i === 0 ? 'none' : `1px solid ${BORDER}` }}>
              <div style={{ flexShrink: 0 }}><Avatar variant={m.variant} size={42} /></div>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
                  <span style={{ fontSize: 15, fontWeight: 700, color: m.color }}>{m.name}{m.isYou ? ' (you)' : ''}</span>
                  {m.role === 'owner' && <span style={{ fontSize: 10, fontWeight: 700, color: GOLD, background: GOLD + '22', borderRadius: 100, padding: '1px 7px', textTransform: 'uppercase', letterSpacing: 0.4 }}>Owner</span>}
                </div>
                {m.goal
                  ? <div style={{ fontSize: 13, color: INK_SOFT, marginTop: 3, lineHeight: 1.4, wordBreak: 'break-word' }}>🎯 {m.goal}</div>
                  : m.isYou ? <div style={{ fontSize: 13, color: '#a7a291', marginTop: 3, fontStyle: 'italic' }}>No personal goal yet</div> : null}
                {m.isYou && (
                  <button onClick={() => setGoalPrompt(true)} data-testid="habitset-editgoal" style={{ marginTop: 4, background: 'none', border: 'none', padding: 0, color: GREEN, fontSize: 12.5, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit' }}>
                    {m.goal ? 'Edit your goal' : 'Set your goal'}
                  </button>
                )}
              </div>
            </div>
          ))}
        </AccountGroup>

        {/* non-owner: leave */}
        {!isOwner && (
          <AccountGroup title="">
            {confirmLeave ? (
              <div style={{ padding: '14px 16px' }}>
                <div style={{ fontSize: 14, color: INK, marginBottom: 10 }}>Leave this habit? Your check-ins here will be removed for you.</div>
                <div style={{ display: 'flex', gap: 8 }}>
                  <button onClick={() => setConfirmLeave(false)} style={{ flex: 1, height: 42, border: `1px solid ${BORDER}`, borderRadius: 12, background: '#fff', color: INK, fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>Cancel</button>
                  <button onClick={doLeave} disabled={busy} data-testid="habitset-leave-confirm" style={{ flex: 1, height: 42, border: 'none', borderRadius: 12, background: '#b3503a', color: '#fff', fontSize: 14, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit' }}>{busy ? 'Leaving…' : 'Leave habit'}</button>
                </div>
              </div>
            ) : (
              <button onClick={() => setConfirmLeave(true)} data-testid="habitset-leave" style={{ width: '100%', padding: '15px 16px', background: 'transparent', border: 'none',
                cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit', fontSize: 14, fontWeight: 600, color: '#b3503a' }}>Leave habit</button>
            )}
          </AccountGroup>
        )}
      </div>

      {pickerOpen && <IconColorPicker icon={icon} accent={accent} onPick={({ icon: ic, accent: ac }) => { setIcon(ic); setAccent(ac); }} onClose={() => setPickerOpen(false)} />}
      {showInvite && <InviteSheet gid={gid} uid={uid} onClose={() => setShowInvite(false)} />}
      {goalPrompt && <GoalPromptSheet gid={gid} initial={(meta && meta[uid] && meta[uid].goal) || ''} title="Your goal"
        onDone={() => { setGoalPrompt(false); loadMeta(); }} />}
    </div>
  );
}

// ── small top-of-screen notice (invite errors / info) ───────────────────────
function NoticeToast({ text, onClose }) {
  React.useEffect(() => { const t = setTimeout(onClose, 5000); return () => clearTimeout(t); }, [text]);
  return (
    <div onClick={onClose} style={{ position: 'absolute', top: 'calc(10px + env(safe-area-inset-top))', left: 16, right: 16, zIndex: 80,
      background: '#1f2a24', color: '#fff', borderRadius: 12, padding: '12px 14px', fontSize: 13.5, lineHeight: 1.4,
      boxShadow: '0 8px 24px rgba(0,0,0,0.25)', cursor: 'pointer', animation: 'slideUp .25s ease' }}>{text}</div>
  );
}

// ════════════════════════════════════════════════════════════════════════════
// M4 — first-run onboarding + Account screen (profile self-edit).
// Look copied from the wireframes (screens/hifi-onboard.jsx, hifi-account.jsx,
// member-profile.jsx); wired to the real profiles table + avatars bucket. The
// onboarding is gated on profiles.onboarded (added in db/migrations/001_groups.sql).
// ════════════════════════════════════════════════════════════════════════════

// Upload a chosen image to the public `avatars` bucket → returns its public URL.
async function uploadAvatarFile(uid, file) {
  const ext = (file.name.split('.').pop() || 'jpg').toLowerCase();
  const path = `${uid}/avatar-${Date.now()}.${ext}`;
  const up = await sb.storage.from('avatars').upload(path, file, { upsert: true, contentType: file.type });
  if (up.error) throw up.error;
  const { data } = sb.storage.from('avatars').getPublicUrl(path);
  return data.publicUrl;
}

// ── onboarding: shared shell (progress + sticky footer) ─────────────────────
function OnbShell({ children, step, total = 3, cta, secondary, busy, onNext, onSecondary }) {
  return (
    <div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#fbf7ee', color: INK }}>
      <div style={{ padding: 'calc(14px + env(safe-area-inset-top)) 18px 6px', display: 'flex', alignItems: 'center', gap: 10 }}>
        <div style={{ flex: 1, display: 'flex', gap: 4 }}>
          {Array.from({ length: total }).map((_, i) => (
            <div key={i} style={{ flex: 1, height: 4, borderRadius: 2, background: i <= step ? GREEN : '#e6e0d0', transition: 'background .3s' }} />
          ))}
        </div>
      </div>
      <div style={{ flex: 1, minHeight: 0, overflow: 'auto', paddingBottom: 12 }}>{children}</div>
      <div style={{ padding: '12px 22px calc(22px + env(safe-area-inset-bottom))', display: 'flex', flexDirection: 'column', gap: 8 }}>
        <button onClick={onNext} disabled={busy} style={{ width: '100%', height: 52, borderRadius: 14, border: 'none',
          background: busy ? '#9bb3a6' : GREEN, color: '#fff', fontFamily: 'inherit', fontSize: 16, fontWeight: 700, cursor: 'pointer',
          boxShadow: `0 6px 16px ${GREEN}45`, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
          {cta}
          {!busy && <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4 8 H12 M9 5 L12 8 L9 11" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>}
        </button>
        {secondary && (
          <button onClick={onSecondary} disabled={busy} style={{ width: '100%', height: 44, borderRadius: 12, border: 'none',
            background: 'transparent', color: INK_SOFT, fontFamily: 'inherit', fontSize: 14, fontWeight: 600, cursor: 'pointer' }}>{secondary}</button>
        )}
      </div>
    </div>
  );
}

// ── onboarding step 1: welcome (full-bleed dark hero) ───────────────────────
function OnbIntro({ onNext }) {
  return (
    <div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#1f2a24', color: '#fff', position: 'relative', overflow: 'hidden' }}>
      <div style={{ position: 'absolute', top: -80, right: -80, width: 280, height: 280, borderRadius: '50%', background: GREEN, opacity: 0.4, filter: 'blur(40px)' }} />
      <div style={{ position: 'absolute', bottom: 100, left: -120, width: 320, height: 320, borderRadius: '50%', background: GOLD, opacity: 0.18, filter: 'blur(50px)' }} />
      <div style={{ padding: 'calc(14px + env(safe-area-inset-top)) 18px 6px', position: 'relative', display: 'flex', gap: 4 }}>
        {[0, 1, 2].map((i) => <div key={i} style={{ flex: 1, height: 3, borderRadius: 2, background: i === 0 ? '#fff' : 'rgba(255,255,255,0.18)' }} />)}
      </div>
      <div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: 30, position: 'relative' }}>
        <div style={{ position: 'relative', width: '100%', height: 220, display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 28 }}>
          {[0, 1, 2].map((i) => (
            <div key={i} style={{ position: 'absolute', width: 120, height: 120, borderRadius: '50%',
              transform: `translate(${(i - 1) * 50}px, ${i === 1 ? -10 : 8}px)`,
              background: ['#a55a3a', '#2f6b52', '#6b3c5e'][i], boxShadow: '0 12px 40px rgba(0,0,0,0.4)',
              display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 56 }}>{['🏃', '🧘', '📖'][i]}</div>
          ))}
          <div style={{ position: 'absolute', right: 30, bottom: 0, width: 56, height: 56, borderRadius: '50%', background: GOLD,
            display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 8px 20px rgba(212,165,54,0.55)' }}>
            <svg width="26" height="26" viewBox="0 0 16 16"><path d="M8 2 C 9 5 12 6 12 9 A 4 4 0 0 1 4 9 C 4 7 5 6 5 5 C 7 6 7 3 8 2 Z" fill="#fff" /></svg>
          </div>
        </div>
        <h1 style={{ fontSize: 38, fontWeight: 800, letterSpacing: -0.8, lineHeight: 1.05, margin: 0 }}>
          Habits are easier <span style={{ color: GOLD }}>with friends.</span>
        </h1>
        <p style={{ fontSize: 15, color: 'rgba(255,255,255,0.75)', marginTop: 16, marginBottom: 0, lineHeight: 1.5, maxWidth: 320 }}>
          Pick something. Invite a few people. Hold the streak together.
        </p>
      </div>
      <div style={{ padding: '14px 22px calc(22px + env(safe-area-inset-bottom))', position: 'relative' }}>
        <button onClick={onNext} style={{ width: '100%', height: 54, borderRadius: 14, border: 'none', background: '#fff', color: INK,
          fontFamily: 'inherit', fontSize: 16, fontWeight: 700, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
          boxShadow: '0 12px 30px rgba(0,0,0,0.3)' }}>
          Get started
          <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4 8 H12 M9 5 L12 8 L9 11" stroke={INK} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>
        </button>
      </div>
    </div>
  );
}

// ── onboarding step 2: notifications (wire to enablePush; "Maybe later" skips) ─
function OnbNotifications({ uid, onNext }) {
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);
  const allow = async () => {
    setBusy(true); setErr(null);
    try { await enablePush(uid); } catch (e) { setErr(e.message || String(e)); setBusy(false); return; }
    setBusy(false); onNext();
  };
  return (
    <OnbShell step={1} cta={busy ? 'Enabling…' : 'Allow notifications'} secondary="Maybe later" busy={busy} onNext={allow} onSecondary={onNext}>
      <div style={{ padding: '4px 22px 0' }}>
        <div style={{ fontSize: 11, color: INK_SOFT, fontWeight: 700, letterSpacing: 0.5 }}>STEP 2 OF 3</div>
        <h2 style={ONB_H2}>One nudge a day.<br />That's the deal.</h2>
        <p style={ONB_SUB}>We only ping when someone checks in — or when it's your turn.</p>
      </div>
      <div style={{ padding: '20px 22px 0' }}>
        <div style={{ background: 'linear-gradient(180deg, #f4f1e7 0%, #ebe5d3 100%)', borderRadius: 18, padding: '20px 16px 24px' }}>
          <div style={{ textAlign: 'center', fontSize: 11, color: INK_SOFT, fontWeight: 600, letterSpacing: 0.5, marginBottom: 12 }}>FRIDAY · 8:00 AM</div>
          <div style={{ background: 'rgba(255,255,255,0.9)', borderRadius: 14, padding: '12px 14px', display: 'flex', gap: 10, alignItems: 'flex-start',
            boxShadow: '0 4px 14px rgba(0,0,0,0.08)', animation: 'slideUp 0.5s cubic-bezier(.2,.8,.2,1)' }}>
            <div style={{ width: 32, height: 32, borderRadius: 8, background: GREEN, color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16, flexShrink: 0 }}>🔥</div>
            <div style={{ flex: 1, minWidth: 0 }}>
              <div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
                <span style={{ fontSize: 12, fontWeight: 700, color: INK }}>{CFG.HABIT.name}</span>
                <span style={{ fontSize: 10, color: INK_SOFT }}>now</span>
              </div>
              <div style={{ fontSize: 13, fontWeight: 600, color: INK, marginTop: 2 }}>Lena just checked in.</div>
              <div style={{ fontSize: 12, color: INK_SOFT, marginTop: 1 }}>Day 24 — your turn 👀</div>
            </div>
          </div>
        </div>
        {err && <div style={{ color: '#8a3b22', fontSize: 13, marginTop: 12 }}>{err} You can turn these on later from your account.</div>}
      </div>
    </OnbShell>
  );
}

// ── onboarding step 3: set up profile (name + optional photo → profiles) ─────
function OnbProfile({ uid, me, onFinish }) {
  const [name, setName] = React.useState((me && me.name && me.name !== 'Someone') ? me.name : '');
  const [preview, setPreview] = React.useState((me && me.avatar_url) || null);
  const [file, setFile] = React.useState(null);
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);
  const fileRef = React.useRef(null);
  const initial = (name || '?').trim().charAt(0).toUpperCase() || '?';

  const pick = (e) => {
    const f = e.target.files && e.target.files[0];
    if (!f) return;
    setFile(f); setPreview(URL.createObjectURL(f));
  };
  const finish = async () => {
    setBusy(true); setErr(null);
    try {
      const patch = { name: name.trim() || 'Someone' };
      if (file) patch.avatar_url = await uploadAvatarFile(uid, file);
      const { error } = await sb.from('profiles').update(patch).eq('id', uid);
      if (error) throw error;
    } catch (e) { setErr(e.message || String(e)); setBusy(false); return; }
    setBusy(false);
    onFinish();
  };

  return (
    <OnbShell step={2} cta={busy ? 'Saving…' : 'Finish'} busy={busy} onNext={finish}>
      <div style={{ padding: '4px 22px 0' }}>
        <div style={{ fontSize: 11, color: INK_SOFT, fontWeight: 700, letterSpacing: 0.5 }}>STEP 3 OF 3</div>
        <h2 style={ONB_H2}>One last thing.</h2>
        <p style={ONB_SUB}>Add a name and photo so your group knows it's you.</p>
      </div>
      <div style={{ padding: '24px 22px 0', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10 }}>
        <button onClick={() => fileRef.current && fileRef.current.click()} style={{ position: 'relative', padding: 0, border: 'none', background: 'transparent', cursor: 'pointer' }}>
          {preview
            ? <img src={preview} alt="" style={{ width: 110, height: 110, borderRadius: '50%', objectFit: 'cover', boxShadow: '0 12px 30px rgba(0,0,0,0.18)' }} />
            : <div style={{ width: 110, height: 110, borderRadius: '50%', background: `linear-gradient(135deg, ${GREEN}, #2e6477)`, color: '#fff',
                display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 42, fontWeight: 800, boxShadow: '0 12px 30px rgba(0,0,0,0.18)' }}>{initial}</div>}
          <div style={{ position: 'absolute', bottom: -2, right: -2, width: 36, height: 36, borderRadius: '50%', background: '#fff', color: INK,
            display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 4px 12px rgba(0,0,0,0.15)' }}>
            <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 5 H4.5 L6 3 H10 L11.5 5 H13 V12 H3 Z" stroke={INK} strokeWidth="1.5" fill="none" strokeLinejoin="round" /><circle cx="8" cy="8.5" r="2.5" stroke={INK} strokeWidth="1.5" fill="none" /></svg>
          </div>
        </button>
        <input ref={fileRef} type="file" accept="image/*" onChange={pick} style={{ display: 'none' }} />
        <div style={{ fontSize: 13, color: GREEN, fontWeight: 600 }}>{preview ? 'Change photo' : 'Add photo'}</div>
      </div>
      <div style={{ padding: '24px 22px 0' }}>
        <div style={{ fontSize: 11, color: INK_SOFT, fontWeight: 700, letterSpacing: 0.6, marginBottom: 6, textTransform: 'uppercase' }}>Your name</div>
        <input value={name} onChange={(e) => setName(e.target.value)} placeholder="Your name" autoFocus data-testid="onb-name"
          style={{ width: '100%', height: 50, borderRadius: 12, border: 'none', background: '#fff', padding: '0 16px', fontFamily: 'inherit',
            fontSize: 16, color: INK, fontWeight: 600, outline: 'none', boxShadow: '0 1px 0 rgba(0,0,0,0.04), inset 0 0 0 1px ' + BORDER }} />
        {err && <div style={{ color: '#8a3b22', fontSize: 13, marginTop: 10 }}>{err}</div>}
      </div>
    </OnbShell>
  );
}

const ONB_H2 = { fontSize: 28, fontWeight: 800, color: INK, letterSpacing: -0.5, lineHeight: 1.1, margin: '8px 0 0' };
const ONB_SUB = { fontSize: 14, color: INK_SOFT, marginTop: 8, marginBottom: 0, lineHeight: 1.5 };

function OnboardingFlow({ uid, me, onDone }) {
  const [step, setStep] = React.useState(0);
  if (step === 0) return <OnbIntro onNext={() => setStep(1)} />;
  if (step === 1) return <OnbNotifications uid={uid} onNext={() => setStep(2)} />;
  return <OnbProfile uid={uid} me={me} onFinish={onDone} />;
}

// ── Account: view/edit your own profile (name + photo) + notifications + sign out
function AccountScreen({ uid, me, onBack, onSaved, onSignOut }) {
  useBackClose(true, onBack);
  const [name, setName] = React.useState(me.name || '');
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);
  const [okMsg, setOkMsg] = React.useState(null);
  const [sheet, setSheet] = React.useState(false);
  const [pushBusy, setPushBusy] = React.useState(false);
  const fileRef = React.useRef(null);
  const notifPerm = (typeof Notification !== 'undefined') ? Notification.permission : 'unsupported';
  const dirty = name.trim() !== (me.name || '').trim();
  const flash = (m) => { setOkMsg(m); setTimeout(() => setOkMsg(null), 1600); };

  const saveName = async () => {
    setBusy(true); setErr(null);
    const { error } = await sb.from('profiles').update({ name: name.trim() || 'Someone' }).eq('id', uid);
    setBusy(false);
    if (error) return setErr(error.message);
    flash('Saved ✓'); onSaved();
  };
  const doUpload = (e) => {
    const f = e.target.files && e.target.files[0];
    if (!f) return;
    setSheet(false); setBusy(true); setErr(null);
    (async () => {
      try {
        const url = await uploadAvatarFile(uid, f);
        const { error } = await sb.from('profiles').update({ avatar_url: url }).eq('id', uid);
        if (error) throw error;
        flash('Photo updated ✓'); onSaved();
      } catch (e2) { setErr(e2.message || String(e2)); }
      setBusy(false);
    })();
  };
  const removePhoto = async () => {
    setSheet(false); setBusy(true); setErr(null);
    const { error } = await sb.from('profiles').update({ avatar_url: null }).eq('id', uid);
    setBusy(false);
    if (error) return setErr(error.message);
    flash('Photo removed'); onSaved();
  };
  const enableNotifs = async () => {
    setPushBusy(true); setErr(null);
    try { await enablePush(uid); flash('Notifications on ✓'); }
    catch (e) { setErr(e.message || String(e)); }
    setPushBusy(false);
  };

  return (
    <div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#fbf7ee', color: INK, position: 'relative' }}>
      <div style={{ padding: 'calc(14px + env(safe-area-inset-top)) 14px 10px', display: 'flex', alignItems: 'center', gap: 8 }}>
        <button onClick={onBack} aria-label="Back" style={{ width: 36, height: 36, borderRadius: '50%', border: `1px solid ${BORDER}`, background: '#fff',
          cursor: 'pointer', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
          <svg width="18" height="18" viewBox="0 0 18 18"><path d="M11 4 L6 9 L11 14" stroke={INK} strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>
        </button>
        <div style={{ flex: 1, fontSize: 16, fontWeight: 700, letterSpacing: -0.2 }}>You</div>
      </div>

      <div style={{ flex: 1, overflow: 'auto', padding: '6px 18px 32px' }}>
        {/* profile hero */}
        <div style={{ background: '#fff', borderRadius: 18, padding: '22px 18px', textAlign: 'center', boxShadow: '0 1px 0 rgba(0,0,0,0.04)' }}>
          <button onClick={() => setSheet(true)} aria-label="Change profile photo" data-testid="account-avatar"
            style={{ border: 'none', background: 'transparent', cursor: 'pointer', padding: 0, position: 'relative', display: 'inline-block' }}>
            <div style={{ width: 96, height: 96, borderRadius: '50%', overflow: 'hidden', border: '3px solid #fff', boxShadow: '0 4px 14px rgba(0,0,0,0.08)' }}>
              <MiniAvatar url={me.avatar_url} name={me.name} size={90} />
            </div>
            <div style={{ position: 'absolute', bottom: 0, right: 0, width: 32, height: 32, borderRadius: '50%', background: GREEN, color: '#fff',
              border: '3px solid #fff', display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 2px 6px rgba(0,0,0,0.18)' }}>
              <svg width="13" height="13" viewBox="0 0 14 14" fill="none"><path d="M2 11 L2 12 L3 12 L11 4 L10 3 Z M9 4 L10 5" stroke="#fff" strokeWidth="1.6" strokeLinejoin="round" strokeLinecap="round" fill="none" /></svg>
            </div>
          </button>
          <div data-testid="account-name" style={{ fontSize: 22, fontWeight: 800, marginTop: 14, letterSpacing: -0.3 }}>{me.name || 'You'}</div>
          <div style={{ fontSize: 13, color: INK_SOFT, marginTop: 2 }}>{busy ? 'Working…' : 'Tap the photo to change it'}</div>
        </div>

        {/* editable display name */}
        <AccountGroup title="Account">
          <div style={{ padding: '14px 16px' }}>
            <div style={{ fontSize: 12, color: INK_SOFT, fontWeight: 600, marginBottom: 8 }}>Display name</div>
            <div style={{ display: 'flex', gap: 8 }}>
              <input value={name} onChange={(e) => setName(e.target.value)} placeholder="Your name" data-testid="account-name-input"
                style={{ flex: 1, height: 44, border: `1px solid ${BORDER}`, borderRadius: 12, padding: '0 13px', fontSize: 16, outline: 'none', fontFamily: 'inherit' }} />
              <button onClick={saveName} disabled={busy || !dirty} data-testid="account-save" style={{ height: 44, padding: '0 16px', border: 'none', borderRadius: 12,
                background: dirty ? GREEN : '#c7d3cb', color: '#fff', fontSize: 15, fontWeight: 700, cursor: dirty ? 'pointer' : 'default', fontFamily: 'inherit' }}>Save</button>
            </div>
            {okMsg && <div style={{ color: GREEN, fontSize: 13, marginTop: 8 }}>{okMsg}</div>}
            {err && <div style={{ color: '#8a3b22', fontSize: 13, marginTop: 8 }}>{err}</div>}
          </div>
        </AccountGroup>

        {/* notifications */}
        <AccountGroup title="Notifications">
          <button onClick={enableNotifs} disabled={pushBusy} style={{ width: '100%', padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 12,
            background: 'transparent', border: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit' }}>
            <div style={{ flex: 1 }}>
              <div style={{ fontSize: 14, fontWeight: 500 }}>Push notifications</div>
              <div style={{ fontSize: 12, color: INK_SOFT, marginTop: 2 }}>
                {notifPerm === 'granted' ? 'On — tap to re-enable on this device' : notifPerm === 'denied' ? 'Blocked in your browser settings' : 'Get pinged on check-ins and messages'}
              </div>
            </div>
            <div style={{ fontSize: 13, fontWeight: 700, color: notifPerm === 'granted' ? GREEN : INK_SOFT }}>{pushBusy ? '…' : notifPerm === 'granted' ? '🔔 On' : 'Turn on'}</div>
          </button>
        </AccountGroup>

        {/* sign out */}
        <AccountGroup title="">
          <button onClick={onSignOut} data-testid="account-signout" style={{ width: '100%', padding: '15px 16px', background: 'transparent', border: 'none',
            cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit', fontSize: 14, fontWeight: 600, color: '#b3503a' }}>Sign out</button>
        </AccountGroup>

        <div style={{ textAlign: 'center', fontSize: 11, color: INK_SOFT, marginTop: 22 }}>{CFG.HABIT.name} · {CFG.ENV}</div>
      </div>

      {sheet && <PhotoPickerSheet hasPhoto={!!me.avatar_url} onClose={() => setSheet(false)} onUpload={() => fileRef.current && fileRef.current.click()} onRemove={removePhoto} />}
      <input ref={fileRef} type="file" accept="image/*" onChange={doUpload} style={{ display: 'none' }} />
    </div>
  );
}

function AccountGroup({ title, children }) {
  return (
    <div style={{ marginTop: 24 }}>
      {title && <div style={{ fontSize: 11, color: INK_SOFT, fontWeight: 700, letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 6px 8px' }}>{title}</div>}
      <div style={{ background: '#fff', borderRadius: 14, overflow: 'hidden', boxShadow: '0 1px 0 rgba(0,0,0,0.04)' }}>{children}</div>
    </div>
  );
}

// Photo picker bottom sheet (real actions only: upload / remove) — hifi-account look.
function PhotoPickerSheet({ hasPhoto, onClose, onUpload, onRemove }) {
  const Row = ({ icon, label, sub, accent, danger, divider, onClick }) => (
    <button onClick={onClick} style={{ width: '100%', padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14, background: 'transparent',
      border: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit', borderTop: divider ? `1px solid ${BORDER}` : 'none' }}>
      <div style={{ width: 40, height: 40, borderRadius: 10, background: (danger ? '#b3503a' : accent) + '18', color: danger ? '#b3503a' : accent,
        display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{icon}</div>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ fontSize: 14, fontWeight: 600, color: danger ? '#b3503a' : INK }}>{label}</div>
        <div style={{ fontSize: 12, color: INK_SOFT, marginTop: 2 }}>{sub}</div>
      </div>
    </button>
  );
  return (
    <div onClick={onClose} style={{ position: 'absolute', inset: 0, zIndex: 60, background: 'rgba(31,42,36,0.45)', display: 'flex', alignItems: 'flex-end', animation: 'fadeIn 0.2s ease' }}>
      <div onClick={(e) => e.stopPropagation()} style={{ width: '100%', background: '#fbf7ee', color: INK, borderRadius: '20px 20px 0 0', padding: '12px 20px calc(24px + env(safe-area-inset-bottom))', animation: 'slideUp 0.25s cubic-bezier(.2,.8,.2,1)' }}>
        <div style={{ width: 40, height: 4, borderRadius: 2, background: '#d8d2c2', margin: '0 auto 14px' }} />
        <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}>
          <h3 style={{ fontSize: 18, fontWeight: 800, margin: 0, letterSpacing: -0.3 }}>Profile photo</h3>
          <button onClick={onClose} style={{ border: 'none', background: 'transparent', color: INK_SOFT, fontSize: 13, fontWeight: 600, cursor: 'pointer', padding: 4 }}>Cancel</button>
        </div>
        <p style={{ fontSize: 12, color: INK_SOFT, margin: '4px 0 14px' }}>Use a clear photo so friends recognize you.</p>
        <div style={{ background: '#fff', borderRadius: 14, overflow: 'hidden', boxShadow: '0 1px 0 rgba(0,0,0,0.04)' }}>
          <Row accent={GREEN} onClick={onUpload} label="Choose from library" sub="JPG, PNG · max 5 MB"
            icon={<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><rect x="3" y="3" width="14" height="14" rx="2" stroke="currentColor" strokeWidth="1.5" /><circle cx="7" cy="7.5" r="1.4" stroke="currentColor" strokeWidth="1.4" /><path d="M3 14 L7.5 10 L11 13 L13.5 10.5 L17 14" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" fill="none" /></svg>} />
          {hasPhoto && <Row danger divider onClick={onRemove} label="Remove photo" sub="Use your initial instead"
            icon={<svg width="18" height="18" viewBox="0 0 20 20" fill="none"><path d="M4 6 H16 M8 4 H12 M6 6 V16 H14 V6 M9 9 V13 M11 9 V13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>} />}
        </div>
      </div>
    </div>
  );
}

// ── authed container: bootstrap profile, load groups, load the selected group's
// ── roster, render the (group-scoped) habit screen ──────────────────────────
function Authed({ session }) {
  const uid = session.user.id;
  const [groups, setGroups] = React.useState(null);  // groups I belong to (RLS-scoped)
  const [gid, setGid] = React.useState(null);        // the group whose habit page is open
  const [roster, setRoster] = React.useState(null);  // profiles of the open group's members
  const [ready, setReady] = React.useState(false);
  const [me, setMe] = React.useState({ id: uid, name: 'You' });
  const [showSettings, setShowSettings] = React.useState(false);
  // Navigation: 'home' (group list) ↔ 'habit' (a group's page). Overlays sit on top.
  const [view, setView] = React.useState('home');
  const [overlay, setOverlay] = React.useState(null); // null | 'create' | 'join'
  const [inviteForGid, setInviteForGid] = React.useState(null); // show InviteSheet after create
  const [goalPromptGid, setGoalPromptGid] = React.useState(null); // C2: prompt for goal after joining
  const [notice, setNotice] = React.useState(null);
  const [pendingInvite, setPendingInvite] = React.useState(() => new URLSearchParams(location.search).get('invite'));
  useBackClose(showSettings, () => setShowSettings(false));

  const loadGroups = React.useCallback(async () => {
    const { data: gm } = await sb.from('group_members').select('role, groups(*)').eq('user_id', uid);
    const gs = (gm || []).map((r) => r.groups).filter(Boolean);
    setGroups(gs);
    return gs;
  }, [uid]);

  // Re-read my own profile (name/avatar/onboarded) after an edit in Account/onboarding.
  const reloadMe = React.useCallback(async () => {
    const { data } = await sb.from('profiles').select('*').eq('id', uid).maybeSingle();
    if (data) setMe(data);
    return data;
  }, [uid]);

  // Load the open group's members (group_members → profiles), keeping "me" first
  // so the design's roster order (variant 0 = you) is preserved.
  const loadRoster = React.useCallback(async () => {
    if (!gid) { setRoster([]); return; }
    const { data: gm } = await sb.from('group_members').select('user_id').eq('group_id', gid);
    const ids = (gm || []).map((r) => r.user_id);
    const { data: profs } = ids.length ? await sb.from('profiles').select('*').in('id', ids) : { data: [] };
    const list = profs || [];
    const mine = list.find((p) => p.id === uid) || me;
    setMe(mine);
    setRoster([mine, ...list.filter((p) => p.id !== uid)]);
  }, [gid, uid]);

  React.useEffect(() => {
    (async () => {
      const { data: mineProfile } = await sb.from('profiles').select('*').eq('id', uid).maybeSingle();
      if (!mineProfile) {
        const nm = (session.user.user_metadata && session.user.user_metadata.name) || (session.user.email || 'Someone').split('@')[0];
        const { data: created } = await sb.from('profiles').insert({ id: uid, name: nm }).select('*').maybeSingle();
        if (created) setMe(created);
      } else {
        setMe(mineProfile);
      }
      await loadGroups();
      setReady(true);
    })();
  }, [uid]);

  // Redeem a ?invite=<token> once we're authed + loaded. Works both for a user
  // who was already signed in and one who just signed up via the invite URL.
  React.useEffect(() => {
    if (!ready || !pendingInvite) return;
    const tok = pendingInvite;
    setPendingInvite(null);
    (async () => {
      const { data, error } = await sb.rpc('redeem_invite', { p_token: tok });
      try { const u = new URL(location.href); u.searchParams.delete('invite'); window.history.replaceState({}, '', u.pathname + u.search + u.hash); } catch (_) {}
      if (error) { setNotice(friendlyInviteError(error.message)); return; }
      await loadGroups();
      setGid(data); setView('habit'); setNotice(null); setGoalPromptGid(data);
    })();
  }, [ready, pendingInvite]);

  React.useEffect(() => { if (ready) loadRoster(); }, [ready, loadRoster]);

  const openGroup = (id) => { setGid(id); setView('habit'); };
  const backHome = () => { setView('home'); loadGroups(); };

  // Expose the current logical route so the dev feedback toolbar can tag bundles
  // (read-only global; no effect on app behaviour).
  React.useEffect(() => {
    try {
      window.__APP_ROUTE = me && me.onboarded === false ? 'onboarding'
        : overlay ? overlay
        : (view === 'habit' && gid) ? ('habit:' + gid)
        : view;
    } catch (_) {}
  }, [view, overlay, gid, me]);
  const signOut = async () => { await sb.auth.signOut(); };

  if (!ready || groups === null) return <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}><div className="spinner" /></div>;

  // First-run welcome flow: shows once, until profiles.onboarded flips true.
  if (me && me.onboarded === false) {
    return <OnboardingFlow uid={uid} me={me}
      onDone={async () => { await sb.from('profiles').update({ onboarded: true }).eq('id', uid); await reloadMe(); loadRoster(); }} />;
  }

  // Full-screen flows take over.
  if (overlay === 'account') {
    return <AccountScreen uid={uid} me={me} onBack={() => setOverlay(null)}
      onSaved={() => { reloadMe(); loadRoster(); }} onSignOut={signOut} />;
  }
  if (overlay === 'create') {
    return <CreateHabitFlow myName={me.name} onClose={() => setOverlay(null)}
      onCreated={async (g) => { setOverlay(null); await loadGroups(); setGid(g.id); setView('habit'); setInviteForGid(g.id); }} />;
  }
  if (overlay === 'join') {
    return <JoinScreen onClose={() => setOverlay(null)}
      onJoined={async (id) => { setOverlay(null); await loadGroups(); setGid(id); setView('habit'); setGoalPromptGid(id); }} />;
  }

  const group = groups.find((g) => g.id === gid) || null;

  // Habit page for the open group.
  if (view === 'habit' && group) {
    if (!roster) return <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}><div className="spinner" /></div>;
    setAvatarUrls(roster.map((p) => p.avatar_url)); // feed real photos into the design's avatars
    return (
      <>
        <LiveHabitScreen key={gid} uid={uid} gid={gid} group={group} roster={roster}
          onReloadProfiles={loadRoster} onOpenSettings={() => setShowSettings(true)} onBack={backHome} />
        {showSettings && (
          <HabitSettings gid={gid} uid={uid} group={group} roster={roster} onClose={() => window.history.back()}
            onSaved={() => { loadGroups(); loadRoster(); }}
            onLeft={() => { window.history.back(); setGid(null); setView('home'); loadGroups(); }} />
        )}
        {inviteForGid && <InviteSheet gid={inviteForGid} uid={uid} onClose={() => setInviteForGid(null)} />}
        {goalPromptGid && (
          <GoalPromptSheet gid={goalPromptGid} title="Set your goal"
            subtitle="You just joined — add a personal goal for this habit (only you set this). You can skip it."
            onDone={() => setGoalPromptGid(null)} />
        )}
        {notice && <NoticeToast text={notice} onClose={() => setNotice(null)} />}
      </>
    );
  }

  // Home (default): the list of the user's groups + create/join entry points.
  return (
    <>
      <HomeScreen uid={uid} groups={groups} me={me} onOpenGroup={openGroup}
        onCreate={() => setOverlay('create')} onJoin={() => setOverlay('join')}
        onOpenAccount={() => setOverlay('account')} onSignOut={signOut} />
      {showSettings && (
        <SettingsSheet uid={uid} profile={me} onClose={() => window.history.back()}
          onSaved={() => { loadGroups(); window.history.back(); }} onSignOut={signOut} />
      )}
      {notice && <NoticeToast text={notice} onClose={() => setNotice(null)} />}
    </>
  );
}

// Parse a Supabase recovery link's URL hash (implicit flow). The client is
// created with detectSessionInUrl:false (so it won't touch invite query params),
// which means it also won't auto-exchange the recovery hash — we do it here.
function parseRecoveryHash() {
  const h = (window.location.hash || '').replace(/^#/, '');
  if (!h) return null;
  const p = new URLSearchParams(h);
  if (p.get('type') === 'recovery' && p.get('access_token')) {
    return { access_token: p.get('access_token'), refresh_token: p.get('refresh_token') || '' };
  }
  return null;
}

function App() {
  const [session, setSession] = React.useState(undefined);
  // Recovery mode: user arrived via a password-reset email link. This takes
  // priority over the normal login/app screens until they set a new password.
  const [recovery, setRecovery] = React.useState(() => !!parseRecoveryHash());

  React.useEffect(() => {
    const rec = parseRecoveryHash();
    if (rec) {
      // Establish the recovery session from the hash tokens, then scrub the URL
      // so the tokens don't linger (and a refresh doesn't re-trigger).
      sb.auth.setSession({ access_token: rec.access_token, refresh_token: rec.refresh_token })
        .then(({ data }) => setSession((data && data.session) || null))
        .catch(() => {});
      try { window.history.replaceState(null, '', window.location.pathname + window.location.search); } catch (_) {}
    }
    sb.auth.getSession().then(({ data }) => setSession(data.session || null));
    // Belt-and-suspenders: if the client ever emits PASSWORD_RECOVERY (e.g. if
    // detectSessionInUrl is later turned on), honour it too.
    const { data } = sb.auth.onAuthStateChange((e, s) => {
      if (e === 'PASSWORD_RECOVERY') setRecovery(true);
      setSession(s || null);
    });
    return () => data.subscription.unsubscribe();
  }, []);

  let body;
  if (recovery) body = <SetNewPassword onDone={() => setRecovery(false)} />;
  else if (session === undefined) body = <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}><div className="spinner" /></div>;
  else if (!session) body = <AuthScreen />;
  else body = <Authed session={session} key={session.user.id} />;

  return <div className="shell">{body}</div>;
}

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