// Attraction Detail + Checklist + Profile screens
(function(){
const { COLORS, waitColor, Stamp, StampPill, Card, Caption, Display, Body,
  PerforatedDivider, StampedOverlay, TicketStub, Icon, Sparkline, BarProgress, StampIcon } = window.PH365_UI;
// NOTE: don't destructure data lookups (ATTRACTIONS, LANDS, PARKS, …) at
// module-load time — live-data.jsx and queue-times-fetch.jsx replace
// window.PH365 with NEW objects after Supabase + queue-times resolve, so any
// captured reference stays frozen on the data.js mock (wait=0 everywhere).
// Read from window.PH365 inside each component instead.

// ─────────────────────────────────────────────────────────────
// ATTRACTION DETAIL
// ─────────────────────────────────────────────────────────────
// Per-attraction "vibe" accent color — used on the hero accent bar so each
// ride feels distinct without needing real photography. Lookup is by
// case-insensitive name keywords, with a fallback by attraction.type.
function vibeAccent(a) {
  const n = (a.name || '').toLowerCase();
  if (n.includes('matterhorn'))                         return ['#6CC2D8', '#1A4A6B'];
  if (n.includes('big thunder'))                        return ['#C77848', '#5A2818'];
  if (n.includes('space mountain'))                     return ['#1A2566', '#0A0F2C'];
  if (n.includes('pirates'))                            return ['#1F3A47', '#0E2228'];
  if (n.includes('haunted mansion'))                    return ['#4A4A2D', '#1F1F12'];
  if (n.includes('rise of') || n.includes('millennium')) return ['#FF8E53', '#4A1A8C'];
  if (n.includes('peter pan'))                          return ['#2E5F7A', '#1A3045'];
  if (n.includes('dumbo') || n.includes('carrousel') || n.includes('carousel')) return ['#F4B650', '#B07930'];
  if (n.includes('jungle cruise') || n.includes('tiki')) return ['#2F5C2A', '#143018'];
  if (n.includes('indiana'))                            return ['#8E5524', '#3F2410'];
  if (n.includes('cars') || n.includes('radiator'))     return ['#C44231', '#5C1A12'];
  if (n.includes('soarin'))                             return ['#6BBADD', '#1F587A'];
  if (n.includes('grizzly'))                            return ['#427C5A', '#1B3A28'];
  if (n.includes('incredicoaster') || n.includes('pixar pier')) return ['#E8312A', '#6B0F0C'];
  if (n.includes('avengers') || n.includes('web slinger') || n.includes('web-slinger')) return ['#B22A2A', '#3A0F0F'];
  if (n.includes('breakout') || n.includes('guardians')) return ['#6B2BA8', '#1F0F33'];
  if (n.includes('toy story'))                          return ['#3CA2D8', '#16475F'];
  if (n.includes('tiana'))                              return ['#3F8F5C', '#16402A'];
  if (n.includes('mickey') || n.includes('runaway railway')) return ['#D8504A', '#6B1E1B'];
  switch (a.type) {
    case 'Coaster':              return ['#4A62D8', '#1A2566'];
    case 'Dark Ride':            return ['#4A4070', '#1F1A30'];
    case 'Boat Ride': case 'Water': return ['#5BA8C7', '#1F4A5A'];
    case 'Show':                 return ['#FBBE4F', '#8A6020'];
    case 'Spinner':              return ['#76D0C0', '#2F635A'];
    case 'Drop':                 return ['#746BAB', '#2F2A4A'];
    default:                     return ['#4A62D8', '#2A3A78'];
  }
}

function DetailScreen({ id, onBack }) {
  // Read live: window.PH365.ATTRACTIONS is a fresh array after every
  // queue-times refresh (see queue-times-fetch.jsx), so finding `a` on each
  // render gives us the latest wait/isOpen instead of the data.js stub
  // captured at module load.
  const a = (window.PH365.ATTRACTIONS || []).find(x => x.id === id);
  const [tab, setTab] = React.useState('today');
  const [pinned, setPinned] = React.useState(false);
  const LANDS = window.PH365.LANDS;
  const PARKS = window.PH365.PARKS;
  const land = LANDS[a.land];
  const park = PARKS[land.park];
  const [accent, accentDeep] = vibeAccent(a);

  return (
    <div style={{ paddingBottom: 100 }}>
      {/* Hero — colored accent bar + soft tint signals ride identity */}
      <div style={{
        height: 6,
        background: `linear-gradient(90deg, ${accent} 0%, ${accentDeep} 100%)`,
      }} />
      <div style={{
        padding: '8px 16px 18px',
        borderBottom: `1px dashed ${COLORS.paperEdge}`,
        background: `linear-gradient(180deg, ${accent}14 0%, transparent 60%)`,
      }}>
        <button onClick={onBack} style={{
          background: 'transparent', border: 'none', padding: 0, cursor: 'pointer',
          display: 'flex', alignItems: 'center', gap: 4, marginBottom: 14,
          fontFamily: 'Manrope, sans-serif', fontSize: 13, color: COLORS.inkSoft,
        }}>
          <Icon name="chevron" size={14} color={COLORS.inkSoft} />
          <span style={{ transform: 'scaleX(-1)' }}> </span>
          Back to waits
        </button>
        <Caption style={{ marginBottom: 6 }}>{park.short} · {land.name}</Caption>
        <div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
          <Display size={28} style={{ flex: 1 }}>{a.name}</Display>
          <div style={{ flexShrink: 0 }}>
            <StampPill attraction={a} size={32} />
          </div>
        </div>
        {/* Wait stamp + status — prominent, just under the title so the
            number that drove the user here is the second thing they see. */}
        {a.type !== 'Show' && (
          <div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginTop: 12 }}>
            {a.isOpen === false ? (
              <>
                <Stamp value="—" unit="" size={36} color={COLORS.inkSoft} />
                <span style={{
                  fontFamily: 'Manrope', fontSize: 11, fontWeight: 700,
                  color: COLORS.clay, textTransform: 'uppercase', letterSpacing: '0.1em',
                }}>● Closed</span>
              </>
            ) : a.lastUpdated ? (
              <>
                <Stamp value={a.wait} unit="min" size={36} color={waitColor(a.wait)} />
                <span style={{
                  fontFamily: 'Manrope', fontSize: 11, fontWeight: 700,
                  color: COLORS.moss, textTransform: 'uppercase', letterSpacing: '0.1em',
                }}>● Live</span>
              </>
            ) : (
              <>
                <Stamp value="—" unit="" size={36} color={COLORS.inkSoft} />
                <span style={{
                  fontFamily: 'Manrope', fontSize: 11, fontWeight: 600,
                  color: COLORS.inkSoft, textTransform: 'uppercase', letterSpacing: '0.1em',
                }}>No live data</span>
              </>
            )}
          </div>
        )}
        <Body size={13.5} color={COLORS.inkSoft} style={{ marginTop: 10 }}>{a.blurb}</Body>
        <div style={{ display: 'flex', gap: 14, marginTop: 12 }}>
          <Meta label="Type" value={a.type} />
          <div style={{ width: 1, background: COLORS.paperEdge }} />
          <Meta label="Height" value={a.height} />
          <div style={{ width: 1, background: COLORS.paperEdge }} />
          <Meta label="Your visits" value={a.rides ?? 0} />
        </div>
      </div>

      {/* Pin status (when pinned) */}
      {pinned && (
        <div style={{
          padding: '14px 16px',
          background: COLORS.teal,
          color: COLORS.paper,
          display: 'flex',
          alignItems: 'center',
          gap: 12,
        }}>
          <Icon name="pin" size={18} color={COLORS.paper} />
          <div style={{ flex: 1 }}>
            <div style={{ fontFamily: 'Manrope', fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.12em', opacity: 0.7 }}>
              Pin dropped — est. time to ride
            </div>
            <Stamp value={a.wait + 4} unit="min" size={28} color={COLORS.paper} />
          </div>
          <button onClick={() => setPinned(false)} style={{
            background: 'transparent', border: `1px solid ${COLORS.paper}40`, color: COLORS.paper,
            borderRadius: 6, padding: '6px 10px', cursor: 'pointer', fontFamily: 'Manrope', fontSize: 11, fontWeight: 600,
          }}>Clear</button>
        </div>
      )}

      {/* Tabs */}
      <div style={{
        display: 'flex',
        padding: '0 16px',
        gap: 18,
        borderBottom: `1px solid ${COLORS.paperEdge}`,
        marginTop: 4,
        overflowX: 'auto',
        scrollbarWidth: 'none',
      }}>
        {['live', 'today', 'history', 'reviews', 'visits'].map(t => (
          <button key={t} onClick={() => setTab(t)} style={{
            background: 'transparent', border: 'none', padding: '12px 0',
            cursor: 'pointer',
            fontFamily: 'Manrope', fontSize: 13, fontWeight: tab === t ? 700 : 500,
            color: tab === t ? COLORS.ink : COLORS.inkSoft,
            borderBottom: tab === t ? `2px solid ${COLORS.teal}` : '2px solid transparent',
            textTransform: 'capitalize',
            whiteSpace: 'nowrap',
            flexShrink: 0,
          }}>{t === 'visits' ? 'Your Visits' : t}</button>
        ))}
      </div>

      <div style={{ padding: '14px 16px' }}>
        {tab === 'live' && <LiveTab attraction={a} onPin={() => setPinned(true)} pinned={pinned} />}
        {tab === 'today' && <TodayTab attraction={a} />}
        {tab === 'history' && <HistoryTab attraction={a} />}
        {tab === 'reviews' && <ReviewsTab attraction={a} />}
        {tab === 'visits' && <VisitsTab attraction={a} />}
      </div>
    </div>
  );
}

function Meta({ label, value }) {
  return (
    <div>
      <Caption style={{ fontSize: 10 }}>{label}</Caption>
      <Body size={13} weight={600} style={{ marginTop: 2 }}>{value}</Body>
    </div>
  );
}

function LiveTab({ attraction, onPin, pinned }) {
  const [busy, setBusy] = React.useState(false);
  // Locally tracked ride count — initialised from the supabase-backed count
  // so the button label stays accurate across remounts and additional taps.
  const [rideCount, setRideCount] = React.useState(attraction.rides ?? 0);
  const [myStars, setMyStars] = React.useState(0);
  // Crowd-sourced wait override: if a passholder reported their position
  // in the last 10 min, use that. Falls back to attraction.wait (queue-times).
  const [effectiveWait, setEffectiveWait] = React.useState(null);

  // Keep local count in sync if the upstream attraction object refreshes
  // (e.g. live-data reload after sign-in or background sync).
  React.useEffect(() => {
    setRideCount(attraction.rides ?? 0);
  }, [attraction.id, attraction.rides]);

  React.useEffect(() => {
    let cancelled = false;
    const fetchEff = async () => {
      try {
        const w = await window.PH365_ACTIONS?.getEffectiveWait?.(attraction.id);
        if (!cancelled) setEffectiveWait(w ?? null);
      } catch {}
    };
    fetchEff();
    const id = setInterval(fetchEff, 30_000);
    return () => { cancelled = true; clearInterval(id); };
  }, [attraction.id]);

  const actions = window.PH365_ACTIONS || {};

  async function dropPin() {
    if (busy) return;
    setBusy(true);
    try {
      if (actions.dropQueuePin) {
        await actions.dropQueuePin({ attractionId: attraction.id, postedWait: attraction.wait });
      }
      onPin();
    } catch (e) {
      console.warn(e);
      onPin();  // optimistic — still flip UI
    } finally { setBusy(false); }
  }

  async function logRide() {
    if (busy) return;
    setBusy(true);
    const wasFirst = rideCount === 0;
    try {
      if (actions.checkIn) {
        await actions.checkIn({ attractionId: attraction.id, waitMinutes: attraction.wait });
      }
      const next = rideCount + 1;
      setRideCount(next);
      // Mutate the attraction object in place so the "Your visits" stat and
      // checklist row update without waiting for a full live-data reload.
      attraction.rides = next;
      // Pop the rating modal only on the first completion — subsequent rides
      // shouldn't nag the user for a re-rating.
      if (wasFirst) {
        actions.openRatingModal?.({
          kind: 'attraction',
          attractionId: attraction.id,
          name: attraction.name,
          defaultStars: attraction.userStars ?? 7,
        });
      }
    } catch (e) {
      alert(e.message || 'Could not log this ride. Are you signed in?');
    } finally { setBusy(false); }
  }

  function rate(stars) {
    // Redirect to the rating modal (0-10 with optional review). Pre-seed the
    // slider with the tapped stub × 2 so a 5-star tap reads as a strong 10.
    actions.openRatingModal?.({
      kind: 'attraction',
      attractionId: attraction.id,
      name: attraction.name,
      defaultStars: Math.min(10, stars * 2),
    });
  }

  const isShow = attraction.type === 'Show';

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
      <Card style={{ padding: 14 }}>
        {isShow ? (
          <>
            <Caption style={{ marginBottom: 8 }}>Entertainment</Caption>
            <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 4 }}>
              <span style={{
                fontFamily: 'Fraunces, serif', fontSize: 36, fontWeight: 600,
                color: COLORS.mustard, lineHeight: 1,
              }}>★ Show</span>
            </div>
            <div style={{ marginTop: 10 }}>
              <Caption>Check the official Disneyland app for showtimes</Caption>
            </div>
          </>
        ) : (
          <>
            {(() => {
              const userMin = effectiveWait?.source === 'user' ? effectiveWait.minutes : null;
              const showMin = userMin != null ? userMin : attraction.wait;
              const showColor = userMin != null
                ? (userMin <= 15 ? COLORS.moss : userMin <= 35 ? COLORS.mustard : COLORS.clay)
                : waitColor(attraction.wait);
              const ageMin = effectiveWait?.age_seconds != null
                ? Math.max(1, Math.round(effectiveWait.age_seconds / 60))
                : null;
              return (
                <>
                  <Caption style={{ marginBottom: 8 }}>
                    {userMin != null ? 'Reported by passholders' : 'Posted wait'}
                  </Caption>
                  <div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 4 }}>
                    <Stamp value={showMin} unit="min" size={56} color={showColor} />
                    {attraction.isOpen === false ? (
                      <span style={{
                        fontFamily: 'Manrope', fontSize: 11, fontWeight: 700,
                        color: COLORS.clay, textTransform: 'uppercase', letterSpacing: '0.1em',
                      }}>● Closed</span>
                    ) : userMin != null ? (
                      <span style={{
                        fontFamily: 'Manrope', fontSize: 11, fontWeight: 700,
                        color: COLORS.moss, textTransform: 'uppercase', letterSpacing: '0.1em',
                      }}>● {effectiveWait.sample_count} report{effectiveWait.sample_count === 1 ? '' : 's'}</span>
                    ) : (
                      <span style={{
                        fontFamily: 'Manrope', fontSize: 11, fontWeight: 600,
                        color: COLORS.moss, textTransform: 'uppercase', letterSpacing: '0.1em',
                      }}>● Live</span>
                    )}
                  </div>
                  <div style={{ marginTop: 8 }}>
                    <Caption>
                      {userMin != null
                        ? `From a passholder ${ageMin}m ago — falls back to queue-times after 10m`
                        : 'Updated from queue-times.com · refresh every 60s'}
                    </Caption>
                  </div>
                </>
              );
            })()}
          </>
        )}
      </Card>
      <div style={{ display: 'flex', gap: 8 }}>
        <button onClick={dropPin} disabled={pinned || busy} style={{
          flex: 1, background: pinned ? COLORS.paperEdge : COLORS.teal,
          color: COLORS.paper, border: 'none', borderRadius: 12,
          padding: '14px 18px', cursor: pinned ? 'default' : 'pointer',
          fontFamily: 'Manrope', fontSize: 14, fontWeight: 600,
          display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
          boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.18)',
        }}>
          <Icon name="pin" size={16} color={COLORS.paper} />
          {pinned ? 'Pin dropped' : 'Drop pin'}
        </button>
        <button onClick={logRide} disabled={busy} style={{
          flex: 1, background: rideCount > 0 ? COLORS.moss : COLORS.mustard,
          color: rideCount > 0 ? COLORS.paper : COLORS.ink, border: 'none', borderRadius: 12,
          padding: '14px 18px', cursor: busy ? 'default' : 'pointer',
          fontFamily: 'Manrope', fontSize: 14, fontWeight: 700,
          display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
          boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.25)',
          opacity: busy ? 0.7 : 1,
        }}>
          <Icon name="check" size={16} color={rideCount > 0 ? COLORS.paper : COLORS.ink} strokeWidth={2.4} />
          {rideCount === 0 ? 'I just rode this' : `Rode Again (${rideCount})`}
        </button>
      </div>
      <Card style={{ padding: 14 }}>
        <Caption style={{ marginBottom: 6 }}>Your rating</Caption>
        <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
          {[1,2,3,4,5].map(i => (
            <button key={i} onClick={() => rate(i)} style={{
              background: 'transparent', border: 'none', padding: 2, cursor: 'pointer',
            }}>
              <TicketStub filled={i <= (myStars || Math.round(attraction.rating ?? 0))} />
            </button>
          ))}
          <span style={{ fontFamily: '"JetBrains Mono"', fontSize: 12, fontWeight: 700, marginLeft: 8, color: COLORS.inkSoft }}>
            {myStars ? `${myStars}.0 — yours`
              : attraction.rating != null ? `${attraction.rating.toFixed(1)} avg`
              : 'No ratings yet'}
          </span>
        </div>
      </Card>

      {!isShow && <CalibrationCard attraction={attraction} />}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// CALIBRATION CARD — admin manual pin-drop flow
// ─────────────────────────────────────────────────────────────
// Admin-only. Tap "Calibrate queue path" → starts a draft set + jumps to
// the Map tab where the admin drops one pin per minute as they advance
// through the line. The legacy GPS-based tracker is kept in user-actions
// for backward compat but no longer surfaced in the UI.
function CalibrationCard({ attraction }) {
  const user = window.PH365.USER || {};
  const isAdmin = !!user.isAdmin;
  const actions = window.PH365_ACTIONS || {};

  const [busy, setBusy] = React.useState(false);
  const [err, setErr]   = React.useState(null);
  const [calib, setCalib] = React.useState(() =>
    actions.calibrationSnapshot?.() ?? { active: false });

  React.useEffect(() => {
    if (!actions.subscribeCalibration) return;
    return actions.subscribeCalibration(setCalib);
  }, []);

  if (!isAdmin) return null;

  const calibratingThis = calib.active && calib.attractionId === attraction.id;
  const calibratingOther = calib.active && calib.attractionId !== attraction.id;

  async function startNew() {
    setBusy(true); setErr(null);
    try {
      await actions.startCalibrationSet(attraction.id);
      // Tell App to switch to the Map tab so the admin can immediately
      // start dropping pins on the right map.
      window.dispatchEvent(new CustomEvent('ph365-go-to-map'));
    } catch (e) {
      setErr(e.message || 'Could not start calibration');
    } finally { setBusy(false); }
  }

  async function cancelExisting() {
    setBusy(true); setErr(null);
    try { await actions.cancelCalibrationSet(); }
    catch (e) { setErr(e.message); }
    finally { setBusy(false); }
  }

  return (
    <Card style={{ padding: 14, border: `1px dashed ${COLORS.paperEdge}` }}>
      <Caption color={COLORS.teal} style={{ marginBottom: 8 }}>ADMIN · QUEUE CALIBRATION</Caption>

      {calibratingThis ? (
        <>
          <Body size={13} weight={600} style={{ marginBottom: 4 }}>
            Calibration in progress · {calib.pinCount} pin{calib.pinCount === 1 ? '' : 's'} dropped.
          </Body>
          <Body size={11.5} color={COLORS.inkSoft} style={{ marginBottom: 10, lineHeight: 1.5 }}>
            Switch to the Map tab to keep dropping pins, finish, or cancel.
          </Body>
          <div style={{ display: 'flex', gap: 8 }}>
            <button
              onClick={cancelExisting}
              disabled={busy}
              style={{
                flex: 1, padding: '10px 12px', borderRadius: 10,
                background: 'transparent', color: COLORS.inkSoft,
                border: `1px solid ${COLORS.paperEdge}`,
                fontFamily: 'Manrope', fontSize: 12, fontWeight: 600, cursor: 'pointer',
              }}
            >Cancel calibration</button>
            <button
              onClick={() => window.dispatchEvent(new CustomEvent('ph365-go-to-map'))}
              style={{
                flex: 2, padding: '10px 12px', borderRadius: 10,
                background: COLORS.teal, color: COLORS.paper, border: 'none',
                fontFamily: 'Manrope', fontSize: 13, fontWeight: 700, cursor: 'pointer',
                boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.18)',
              }}
            >Open map to drop pins</button>
          </div>
        </>
      ) : calibratingOther ? (
        <>
          <Body size={13} weight={600} style={{ marginBottom: 4 }}>
            Calibration is active for another ride.
          </Body>
          <Body size={11.5} color={COLORS.inkSoft} style={{ marginBottom: 10, lineHeight: 1.5 }}>
            Finish or cancel that one before starting a new calibration here.
          </Body>
          <button
            onClick={() => window.dispatchEvent(new CustomEvent('ph365-go-to-map'))}
            style={{
              width: '100%', padding: '10px 12px', borderRadius: 10,
              background: COLORS.paperDeep, color: COLORS.ink,
              border: `1px solid ${COLORS.paperEdge}`,
              fontFamily: 'Manrope', fontSize: 13, fontWeight: 600, cursor: 'pointer',
            }}
          >Open map</button>
        </>
      ) : (
        <>
          <Body size={11.5} color={COLORS.inkSoft} style={{ marginBottom: 10, lineHeight: 1.5 }}>
            Walk into the line, then tap below. The map will open zoomed to this ride —
            tap the map at your spot in line, ideally once per minute, and the system
            builds a time-calibrated path. Last pin = boarding point.
          </Body>
          <button
            onClick={startNew}
            disabled={busy}
            style={{
              width: '100%', padding: '12px 14px', borderRadius: 10,
              background: COLORS.teal, color: COLORS.paper, border: 'none',
              fontFamily: 'Manrope', fontSize: 14, fontWeight: 700, cursor: 'pointer',
              boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.18)',
            }}
          >{busy ? 'Starting…' : 'Calibrate queue path'}</button>
        </>
      )}

      {err && <Body size={12} color={COLORS.clay} style={{ marginTop: 8 }}>{err}</Body>}
    </Card>
  );
}

function formatElapsed(sec) {
  const h = Math.floor(sec / 3600);
  const m = Math.floor((sec % 3600) / 60);
  const s = sec % 60;
  if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
  return `${m}:${String(s).padStart(2, '0')}`;
}

function formatMeters(m) {
  if (m == null) return '—';
  if (m < 1000) return `${Math.round(m)} m`;
  return `${(m / 1000).toFixed(2)} km`;
}

function CalibStat({ label, value, color }) {
  return (
    <div style={{
      background: 'rgba(26,37,102,0.04)',
      border: `1px solid ${COLORS.paperEdge}`,
      borderRadius: 8,
      padding: '8px 10px',
    }}>
      <Caption style={{ fontSize: 9 }}>{label}</Caption>
      <div style={{
        fontFamily: '"JetBrains Mono", monospace',
        fontWeight: 700, fontSize: 18,
        color: color || COLORS.ink,
        marginTop: 2,
      }}>{value}</div>
    </div>
  );
}

// "Usual wait at this hour" — fetches a 14-day rolling window of wait_times
// for this attraction once per mount, buckets by hour-of-day (local), and
// returns the median for the *current* hour. Bucketing by hour-of-day (vs
// hour-of-week) keeps the data dense — even a 14-day window only gives
// ~14 samples per (day-of-week, hour) pair, which is too sparse for a
// stable median. Hour-of-day rolls weekday and weekend together, which
// is fine for "is the line short for this time of day?" framing.
function useUsualWait(attractionId, days = 14) {
  const [usual, setUsual] = React.useState(null);   // { now, range: [lo, hi] } | null
  React.useEffect(() => {
    let cancelled = false;
    (async () => {
      const sb = window.PH365_SUPABASE;
      if (!sb) { setUsual(null); return; }
      try {
        const since = new Date(Date.now() - days * 24 * 3600 * 1000).toISOString();
        const { data, error } = await sb
          .from('wait_times')
          .select('wait_minutes, recorded_at')
          .eq('attraction_id', attractionId)
          .gte('recorded_at', since)
          .not('wait_minutes', 'is', null);
        if (error) throw error;
        if (cancelled) return;

        // Bucket by hour-of-day; track per-bucket arrays so we can take median
        const buckets = new Array(24).fill(null).map(() => []);
        for (const r of data) {
          const h = new Date(r.recorded_at).getHours();
          buckets[h].push(r.wait_minutes);
        }
        const nowHour = new Date().getHours();
        const samples = buckets[nowHour];
        // Need at least 5 samples in the bucket to surface a meaningful "usual"
        if (!samples || samples.length < 5) { setUsual(null); return; }
        samples.sort((a, b) => a - b);
        const median = samples[Math.floor(samples.length / 2)];
        // Compute IQR for a "typical range" caption — gives users intuition
        // for how variable this hour is
        const q1 = samples[Math.floor(samples.length * 0.25)];
        const q3 = samples[Math.floor(samples.length * 0.75)];
        setUsual({ median: Math.round(median), range: [Math.round(q1), Math.round(q3)], n: samples.length });
      } catch (e) {
        console.warn('useUsualWait failed:', e);
        if (!cancelled) setUsual(null);
      }
    })();
    return () => { cancelled = true; };
  }, [attractionId, days]);
  return usual;
}

function useWaitHistory(attractionId, hours = 24) {
  const [points, setPoints] = React.useState(null);
  React.useEffect(() => {
    let cancelled = false;
    async function load() {
      const sb = window.PH365_SUPABASE;
      if (!sb) { setPoints([]); return; }
      try {
        const since = new Date(Date.now() - hours * 3600 * 1000).toISOString();
        const { data, error } = await sb
          .from('wait_times')
          .select('wait_minutes, recorded_at')
          .eq('attraction_id', attractionId)
          .gte('recorded_at', since)
          .order('recorded_at', { ascending: true });
        if (error) throw error;
        if (cancelled) return;
        // Convert to {h, v} the Sparkline expects (h is decimal hour-of-day)
        const mapped = data
          .filter(r => r.wait_minutes != null)
          .map(r => {
            const d = new Date(r.recorded_at);
            return { h: d.getHours() + d.getMinutes() / 60, v: r.wait_minutes };
          });
        setPoints(mapped);
      } catch (e) {
        console.warn('useWaitHistory failed:', e);
        if (!cancelled) setPoints([]);
      }
    }
    load();
    return () => { cancelled = true; };
  }, [attractionId, hours]);
  return points;
}

function TodayTab({ attraction }) {
  const live = useWaitHistory(attraction.id, 24);
  const usual = useUsualWait(attraction.id, 14);
  const points = live ?? [];
  const values = points.map(p => p.v).filter(v => v != null);
  const hasData = values.length > 0;
  const avg = hasData ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : null;
  const peak = hasData ? Math.max(...values) : null;
  const isShow = attraction.type === 'Show';

  // Compare current wait to usual — drives a "shorter than usual" / "longer
  // than usual" hint that's the actual product, not the raw number.
  let usualHint = null;
  if (usual && attraction.wait != null && attraction.lastUpdated) {
    const diff = attraction.wait - usual.median;
    const pct = usual.median > 0 ? diff / usual.median : 0;
    if (Math.abs(diff) < 4) {
      usualHint = { tone: 'neutral', label: 'about typical', color: COLORS.inkSoft };
    } else if (pct < -0.25) {
      usualHint = { tone: 'good', label: `${Math.abs(diff)} min shorter than usual`, color: COLORS.moss };
    } else if (pct < -0.1) {
      usualHint = { tone: 'good', label: 'a little shorter than usual', color: COLORS.moss };
    } else if (pct > 0.25) {
      usualHint = { tone: 'bad', label: `${diff} min longer than usual`, color: COLORS.clay };
    } else if (pct > 0.1) {
      usualHint = { tone: 'bad', label: 'a little longer than usual', color: COLORS.clay };
    }
  }

  if (isShow) {
    return (
      <Card style={{ padding: 16 }}>
        <Caption>Wait times today</Caption>
        <Body size={13} color={COLORS.inkSoft} style={{ marginTop: 10 }}>
          This is a show — check the official Disneyland app for showtimes.
        </Body>
      </Card>
    );
  }

  return (
    <Card style={{ padding: 16 }}>
      <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: 4 }}>
        <Caption>Wait times today</Caption>
        {hasData && <Caption color={COLORS.moss} style={{ fontSize: 9 }}>● LIVE</Caption>}
      </div>
      <div style={{ display: 'flex', gap: 16, marginTop: 6 }}>
        <div>
          <Caption style={{ fontSize: 9 }}>Now</Caption>
          {attraction.lastUpdated
            ? <Stamp value={attraction.wait} unit="min" size={22} color={waitColor(attraction.wait)} />
            : <Stamp value="—" unit="" size={22} color={COLORS.inkSoft} />}
        </div>
        <div>
          <Caption style={{ fontSize: 9 }}>Usually</Caption>
          {usual
            ? <Stamp value={usual.median} unit="min" size={22} color={COLORS.inkSoft} />
            : <Stamp value="—" unit="" size={22} color={COLORS.inkSoft} />}
        </div>
        <div>
          <Caption style={{ fontSize: 9 }}>Today avg</Caption>
          {hasData
            ? <Stamp value={avg} unit="min" size={22} />
            : <Stamp value="—" unit="" size={22} color={COLORS.inkSoft} />}
        </div>
        <div>
          <Caption style={{ fontSize: 9 }}>Peak</Caption>
          {hasData
            ? <Stamp value={peak} unit="min" size={22} color={COLORS.clay} />
            : <Stamp value="—" unit="" size={22} color={COLORS.inkSoft} />}
        </div>
      </div>
      {usualHint && (
        <div style={{
          marginTop: 8, padding: '6px 10px',
          background: `${usualHint.color}14`,
          border: `1px solid ${usualHint.color}40`,
          borderRadius: 8,
          display: 'inline-block',
        }}>
          <Caption color={usualHint.color} style={{ letterSpacing: '0.06em' }}>
            ● {usualHint.label}
          </Caption>
        </div>
      )}
      {usual && (
        <Caption style={{ marginTop: 6, fontSize: 9, color: COLORS.inkSoft }}>
          Typical {Math.floor(new Date().getHours() % 12 || 12)}{new Date().getHours() < 12 ? 'a' : 'p'} range:
          {' '}{usual.range[0]}–{usual.range[1]} min · {usual.n} samples over 14 days
        </Caption>
      )}
      <div style={{ position: 'relative', marginTop: 14 }}>
        {hasData ? (
          <Sparkline points={points} width={300} height={120} color={COLORS.teal} strokeWidth={2} />
        ) : (
          <div style={{ height: 120, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
            <Caption color={COLORS.inkSoft}>
              {live === null ? 'Loading…' : 'Collecting data — check back soon'}
            </Caption>
          </div>
        )}
        <div style={{
          display: 'flex', justifyContent: 'space-between',
          fontFamily: '"JetBrains Mono"', fontSize: 10, color: COLORS.inkSoft,
          marginTop: 6, padding: '0 4px',
        }}>
          {['8a', '12p', '4p', '8p', '12a'].map(t => <span key={t}>{t}</span>)}
        </div>
      </div>
    </Card>
  );
}

// Hook: load the signed-in user's visits to this attraction (descending).
// Returns null while loading, [] when not signed in or empty, or the list.
function useUserVisits(attractionId) {
  const [visits, setVisits] = React.useState(null);
  const [error,  setError]  = React.useState(null);

  React.useEffect(() => {
    let cancelled = false;
    (async () => {
      const sb = window.PH365_SUPABASE;
      if (!sb) { setVisits([]); return; }
      try {
        const { data: { user } } = await sb.auth.getUser();
        if (!user) { setVisits([]); return; }
        const { data, error } = await sb
          .from('user_visits')
          .select('visited_at, wait_minutes')
          .eq('user_id', user.id)
          .eq('attraction_id', attractionId)
          .order('visited_at', { ascending: false })
          .limit(500);
        if (error) throw error;
        if (cancelled) return;
        setVisits(data || []);
      } catch (e) {
        if (!cancelled) { setVisits([]); setError(e.message); }
      }
    })();
    return () => { cancelled = true; };
  }, [attractionId]);

  return { visits, error };
}

// Personal ride history — every time the signed-in user logged this ride,
// most recent first. This is the user's own log, not multi-day wait stats.
function HistoryTab({ attraction }) {
  const { visits, error } = useUserVisits(attraction.id);

  if (visits === null) {
    return <Card style={{ padding: 16, textAlign: 'center' }}><Caption>Loading your history…</Caption></Card>;
  }
  if (error) {
    return (
      <Card style={{ padding: 16 }}>
        <Caption color={COLORS.clay}>Couldn't load history</Caption>
        <Body size={12} color={COLORS.inkSoft} style={{ marginTop: 4 }}>{error}</Body>
      </Card>
    );
  }
  if (!visits.length) {
    return (
      <Card style={{ padding: 16, textAlign: 'center' }}>
        <Body size={13} color={COLORS.inkSoft}>
          You haven't ridden this yet. Tap "I just rode this" on the Live tab to start your log.
        </Body>
      </Card>
    );
  }

  // Group visits by calendar date so the list reads as a timeline ("Oct 12,
  // 3 rides") rather than a flat dump of one-per-row when the user marathons.
  const byDate = [];
  let currentKey = null;
  for (const v of visits) {
    const d = new Date(v.visited_at);
    const key = d.toDateString();
    if (key !== currentKey) {
      byDate.push({
        key,
        date: d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }),
        weekday: d.toLocaleDateString('en-US', { weekday: 'short' }),
        rides: [],
      });
      currentKey = key;
    }
    byDate[byDate.length - 1].rides.push({
      time: d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }),
      wait: v.wait_minutes,
    });
  }

  return (
    <div>
      <Caption style={{ marginBottom: 8 }}>
        {visits.length} ride{visits.length === 1 ? '' : 's'} across {byDate.length} day{byDate.length === 1 ? '' : 's'}
      </Caption>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
        {byDate.map(group => (
          <Card key={group.key} style={{ padding: 0, overflow: 'hidden' }}>
            <div style={{
              padding: '10px 14px',
              background: COLORS.paperDeep,
              borderBottom: `1px dashed ${COLORS.paperEdge}`,
              display: 'flex', alignItems: 'baseline', gap: 8,
            }}>
              <Body size={13} weight={700} style={{ flex: 1 }}>{group.date}</Body>
              <Caption>{group.weekday} · {group.rides.length} ride{group.rides.length === 1 ? '' : 's'}</Caption>
            </div>
            {group.rides.map((r, i) => (
              <div key={i} style={{
                display: 'flex', alignItems: 'center', padding: '10px 14px',
                borderBottom: i < group.rides.length - 1 ? `1px dashed ${COLORS.paperEdge}` : 'none',
              }}>
                <Body size={13} style={{ flex: 1 }}>{r.time}</Body>
                {r.wait != null
                  ? <Stamp value={r.wait} unit="min" size={16} color={waitColor(r.wait)} />
                  : <Caption>—</Caption>}
              </div>
            ))}
          </Card>
        ))}
      </div>
    </div>
  );
}

// Public reviews from user_ratings (where is_public=true), with display
// names resolved from user_profiles in a single follow-up round-trip.
function ReviewsTab({ attraction }) {
  const [reviews, setReviews] = React.useState(null);
  const [error,   setError]   = React.useState(null);

  React.useEffect(() => {
    let cancelled = false;
    (async () => {
      const sb = window.PH365_SUPABASE;
      if (!sb) { setReviews([]); return; }
      try {
        const { data, error } = await sb
          .from('user_ratings')
          .select('user_id, stars, review_text, created_at, updated_at')
          .eq('attraction_id', attraction.id)
          .eq('is_public', true)
          .order('updated_at', { ascending: false, nullsFirst: false })
          .limit(50);
        if (error) throw error;
        if (cancelled) return;

        const userIds = [...new Set((data || []).map(r => r.user_id))].filter(Boolean);
        let nameById = new Map();
        if (userIds.length) {
          const { data: profs } = await sb
            .from('user_profiles')
            .select('id, display_name')
            .in('id', userIds);
          nameById = new Map((profs || []).map(p => [p.id, p.display_name]));
        }

        if (cancelled) return;
        setReviews((data || []).map(r => ({
          name:  friendlyReviewerName(nameById.get(r.user_id)),
          stars: r.stars,
          text:  r.review_text,
          date:  formatReviewDate(r.updated_at || r.created_at),
        })));
      } catch (e) {
        if (!cancelled) { setReviews([]); setError(e.message); }
      }
    })();
    return () => { cancelled = true; };
  }, [attraction.id]);

  if (reviews === null) {
    return <Card style={{ padding: 16, textAlign: 'center' }}><Caption>Loading reviews…</Caption></Card>;
  }
  if (error) {
    return (
      <Card style={{ padding: 16 }}>
        <Caption color={COLORS.clay}>Couldn't load reviews</Caption>
        <Body size={12} color={COLORS.inkSoft} style={{ marginTop: 4 }}>{error}</Body>
      </Card>
    );
  }
  // Split into ones with text (top of list) and stars-only (footer count)
  const withText = reviews.filter(r => r.text);
  const starsOnly = reviews.length - withText.length;

  if (!reviews.length) {
    return (
      <Card style={{ padding: 16, textAlign: 'center' }}>
        <Body size={13} color={COLORS.inkSoft}>
          No reviews yet. Be the first — rate this attraction on the Live tab after you ride.
        </Body>
      </Card>
    );
  }

  return (
    <div>
      <Caption style={{ marginBottom: 8 }}>
        {withText.length} written review{withText.length === 1 ? '' : 's'}
        {starsOnly > 0 && ` · ${starsOnly} rating${starsOnly === 1 ? '' : 's'} without notes`}
      </Caption>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
        {withText.map((r, i) => {
          const accent = r.stars >= 8 ? COLORS.moss : r.stars >= 5 ? COLORS.mustard : COLORS.clay;
          return (
            <Card key={i} style={{ padding: 14 }}>
              <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
                <Body size={13} weight={600}>{r.name}</Body>
                <Caption>{r.date}</Caption>
              </div>
              <div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 8 }}>
                <Stamp value={r.stars} unit="/10" size={20} color={accent} />
              </div>
              <Body size={13} color={COLORS.inkSoft}>{r.text}</Body>
            </Card>
          );
        })}
      </div>
    </div>
  );
}

// Personal stats summary for this attraction. Total rides, first / last
// ride dates, average posted wait at time of ride, and the user's own
// rating if they left one.
function VisitsTab({ attraction }) {
  const { visits, error } = useUserVisits(attraction.id);
  // Pull the user's rating + review separately from user_ratings — the
  // attraction.userStars overlay only carries stars, not the review text.
  const [myRating, setMyRating] = React.useState(undefined); // undefined = loading

  React.useEffect(() => {
    let cancelled = false;
    (async () => {
      const sb = window.PH365_SUPABASE;
      if (!sb) { setMyRating(null); return; }
      try {
        const { data: { user } } = await sb.auth.getUser();
        if (!user) { setMyRating(null); return; }
        const { data, error } = await sb
          .from('user_ratings')
          .select('stars, review_text, updated_at, created_at')
          .eq('user_id', user.id)
          .eq('attraction_id', attraction.id)
          .maybeSingle();
        if (error) throw error;
        if (!cancelled) setMyRating(data || null);
      } catch {
        if (!cancelled) setMyRating(null);
      }
    })();
    return () => { cancelled = true; };
  }, [attraction.id]);

  if (visits === null) {
    return <Card style={{ padding: 16, textAlign: 'center' }}><Caption>Loading your stats…</Caption></Card>;
  }
  if (error) {
    return (
      <Card style={{ padding: 16 }}>
        <Caption color={COLORS.clay}>Couldn't load stats</Caption>
        <Body size={12} color={COLORS.inkSoft} style={{ marginTop: 4 }}>{error}</Body>
      </Card>
    );
  }

  const total = visits.length;
  // visits is already ordered visited_at DESC, so [0] is most recent and
  // last entry is the first-ever ride.
  const lastVisit  = total ? visits[0] : null;
  const firstVisit = total ? visits[total - 1] : null;
  const waitsLogged = visits.map(v => v.wait_minutes).filter(w => w != null);
  const avgWait = waitsLogged.length
    ? Math.round(waitsLogged.reduce((s, w) => s + w, 0) / waitsLogged.length)
    : null;

  // Days-since helper for the headline ("last rode 12 days ago"). Today
  // and yesterday get friendly labels — anything older falls back to a
  // raw count so the user knows whether to plan a return trip.
  const daysSince = (iso) => {
    const ms = Date.now() - new Date(iso).getTime();
    return Math.floor(ms / (24 * 3600 * 1000));
  };

  const fmtDate = (iso) => new Date(iso).toLocaleDateString('en-US', {
    month: 'short', day: 'numeric', year: 'numeric',
  });
  const fmtTime = (iso) => new Date(iso).toLocaleTimeString('en-US', {
    hour: 'numeric', minute: '2-digit',
  });

  if (!total && myRating == null) {
    return (
      <Card style={{ padding: 16, textAlign: 'center' }}>
        <Body size={13} color={COLORS.inkSoft}>
          You haven't ridden this yet. Tap "I just rode this" on the Live tab
          to start tracking your stats.
        </Body>
      </Card>
    );
  }

  function openRating() {
    window.PH365_ACTIONS?.openRatingModal?.({
      kind: 'attraction',
      attractionId: attraction.id,
      name: attraction.name,
      defaultStars: myRating?.stars ?? attraction.userStars ?? 7,
    });
  }

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
      {/* ── Headline counter ──────────────────────────────────────── */}
      <Card style={{ padding: 16 }}>
        <Caption>Your rides</Caption>
        <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginTop: 4 }}>
          <Stamp value={total} unit={total === 1 ? 'ride' : 'rides'} size={48} />
          {lastVisit && (() => {
            const d = daysSince(lastVisit.visited_at);
            const label = d === 0 ? 'today' : d === 1 ? 'yesterday' : `${d} days ago`;
            return <Caption>last ride {label}</Caption>;
          })()}
        </div>
      </Card>

      {/* ── Stats grid ────────────────────────────────────────────── */}
      {total > 0 && (
        <Card style={{ padding: 14 }}>
          <Caption style={{ marginBottom: 8 }}>Lifetime stats</Caption>
          <div style={{
            display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10,
          }}>
            <StatBox label="First ride">
              <Body size={13} weight={700}>{fmtDate(firstVisit.visited_at)}</Body>
              <Caption style={{ marginTop: 2 }}>{fmtTime(firstVisit.visited_at)}</Caption>
            </StatBox>
            <StatBox label="Most recent">
              <Body size={13} weight={700}>{fmtDate(lastVisit.visited_at)}</Body>
              <Caption style={{ marginTop: 2 }}>{fmtTime(lastVisit.visited_at)}</Caption>
            </StatBox>
            <StatBox label="Avg wait">
              {avgWait != null
                ? <Stamp value={avgWait} unit="min" size={20} color={waitColor(avgWait)} />
                : <Body size={13} color={COLORS.inkSoft}>—</Body>}
              <Caption style={{ marginTop: 2 }}>
                {waitsLogged.length === 0
                  ? 'no wait recorded'
                  : `over ${waitsLogged.length} ride${waitsLogged.length === 1 ? '' : 's'}`}
              </Caption>
            </StatBox>
            <StatBox label="Days at park">
              <Body size={13} weight={700}>
                {new Set(visits.map(v => new Date(v.visited_at).toDateString())).size}
              </Body>
              <Caption style={{ marginTop: 2 }}>distinct visit days</Caption>
            </StatBox>
          </div>
        </Card>
      )}

      {/* ── Your rating + review ──────────────────────────────────── */}
      <Card style={{ padding: 14 }}>
        <Caption style={{ marginBottom: 8 }}>Your rating</Caption>
        {myRating === undefined ? (
          <Caption>Loading…</Caption>
        ) : myRating ? (
          <>
            <div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
              <Stamp
                value={myRating.stars}
                unit="/10"
                size={28}
                color={myRating.stars >= 8 ? COLORS.moss : myRating.stars >= 5 ? COLORS.mustard : COLORS.clay}
              />
              <Caption>
                rated {fmtDate(myRating.updated_at || myRating.created_at)}
              </Caption>
            </div>
            {myRating.review_text && (
              <Body size={13} color={COLORS.inkSoft} style={{ marginTop: 8, lineHeight: 1.5 }}>
                "{myRating.review_text}"
              </Body>
            )}
            <button onClick={openRating} style={{
              marginTop: 10,
              background: 'transparent', border: `1px solid ${COLORS.paperEdge}`,
              color: COLORS.ink, borderRadius: 8, padding: '8px 12px',
              fontFamily: 'Manrope', fontSize: 12, fontWeight: 600, cursor: 'pointer',
            }}>Edit your rating</button>
          </>
        ) : (
          <>
            <Body size={13} color={COLORS.inkSoft} style={{ marginBottom: 10 }}>
              You haven't rated this yet.
            </Body>
            <button onClick={openRating} style={{
              background: COLORS.teal, color: COLORS.paper, border: 'none',
              borderRadius: 8, padding: '8px 14px',
              fontFamily: 'Manrope', fontSize: 12, fontWeight: 600, cursor: 'pointer',
            }}>Leave a rating</button>
          </>
        )}
      </Card>
    </div>
  );
}

function StatBox({ label, children }) {
  return (
    <div style={{
      background: COLORS.paperDeep,
      border: `1px solid ${COLORS.paperEdge}`,
      borderRadius: 10,
      padding: '10px 12px',
      minHeight: 64,
    }}>
      <Caption style={{ fontSize: 9 }}>{label}</Caption>
      <div style={{ marginTop: 4 }}>{children}</div>
    </div>
  );
}

// "Margaret Eldridge" → "Margaret E."
function friendlyReviewerName(s) {
  if (!s) return 'Passholder';
  const parts = String(s).trim().split(/\s+/);
  if (parts.length === 1) return parts[0];
  return `${parts[0]} ${parts[parts.length - 1][0]}.`;
}
function formatReviewDate(iso) {
  if (!iso) return '';
  try {
    return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
  } catch { return ''; }
}

// ─────────────────────────────────────────────────────────────
// CHECKLIST
// ─────────────────────────────────────────────────────────────
function ChecklistScreen() {
  // 'combined' is the default: every item for the land in one list, what's
  // left to do first and what's already done underneath, so the per-land
  // (done/total) header reads against the full set rather than a slice.
  const [filter, setFilter] = React.useState('combined');
  const [query, setQuery] = React.useState('');
  // Scope: 'allTime' counts everything you've ever completed (the lifetime
  // checklist); 'today' restricts to items completed today (uses item.date,
  // which mirrors user_visits.visited_at / user_checklist.completed_at via
  // formatShortDate).
  const [scope, setScope] = React.useState('allTime');
  // Lands start collapsed so the screen opens to a tidy list of category
  // headers — easier to scan than one long scroll of every item.
  const [expandedLand, setExpandedLand] = React.useState({});
  const selectedPark = usePark();

  // Read live so we re-render after hydration
  const ALL_ATTRACTIONS = window.PH365.ATTRACTIONS;
  const ALL_CHECKLIST   = window.PH365.CHECKLIST;
  const LANDS           = window.PH365.LANDS;

  // Scope to the selected park so the Checklist matches what the TopBar
  // label says. 'BOTH' falls through to no filter for parity with the
  // dropdown's third option.
  const inSelectedPark = (landId) =>
    selectedPark === 'BOTH' ? true : (LANDS[landId]?.park === selectedPark);
  const ATTRACTIONS = ALL_ATTRACTIONS.filter(a => inSelectedPark(a.land));
  const CHECKLIST   = ALL_CHECKLIST.filter(c => inSelectedPark(c.land));

  // Build today's friendly date in the same shape live-data.formatShortDate
  // emits ("Apr 18") so we can compare against item.date / a.lastRidden.
  const todayLabel = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' });

  // Combine attractions + checklist into a unified list. In 'today' scope,
  // an item only reads as done if its most-recent completion was today —
  // that keeps the "Today" view honest about what you did *this visit*
  // without touching the underlying lifetime records.
  const rawItems = [
    ...ATTRACTIONS.filter(a => a.queueTimesId != null).map(a => ({
      id: 'r-' + a.id, name: a.name, land: a.land, category: 'Ride',
      done: (a.rides ?? 0) > 0, date: a.lastRidden, verb: 'COMPLETED',
      rides: a.rides ?? 0,
    })),
    ...CHECKLIST.map(c => ({ id: c.id, ...c })),
  ];
  const items = scope === 'today'
    ? rawItems.map(i => ({ ...i, done: !!i.done && i.date === todayLabel }))
    : rawItems;

  // "This Trip" only makes sense when trip mode is on — otherwise it's a
  // dead chip pointing at "things I did today" with no surrounding UX.
  const tripModeOn = (window.PH365_TRIP?.get?.() ?? false);
  const FILTERS = [
    { id: 'combined',   label: 'All' },
    { id: 'all',        label: 'Incomplete' },
    { id: 'completed',  label: 'Completed' },
    { id: 'seasonal',   label: 'Seasonal' },
    ...(tripModeOn ? [{ id: 'trip', label: 'This Trip' }] : []),
  ];

  let visible = items;
  // 'combined' keeps everything — the split happens inside each land group.
  if (filter === 'all') visible = items.filter(i => !i.done);
  if (filter === 'completed') visible = items.filter(i => i.done);
  // "This Trip" used to hardcode 'Apr 18'. Now uses today, so a passholder
  // tapping it actually sees what they did at the parks today.
  if (filter === 'trip') visible = items.filter(i => i.done && i.date === todayLabel);

  // Free-text search applies on top of the filter so users can drill into
  // a category (e.g. Completed) and still narrow by name.
  if (query.trim()) {
    const q = query.trim().toLowerCase();
    visible = visible.filter(i => (i.name || '').toLowerCase().includes(q));
  }

  // Group by land
  const grouped = {};
  visible.forEach(i => {
    if (!grouped[i.land]) grouped[i.land] = [];
    grouped[i.land].push(i);
  });
  // In the combined view each land holds both halves, so order them
  // to-do-first and let the render drop a divider at the seam. Every other
  // filter is already single-state, so leave its natural order alone.
  if (filter === 'combined') {
    for (const landId of Object.keys(grouped)) {
      grouped[landId] = [
        ...grouped[landId].filter(i => !i.done),
        ...grouped[landId].filter(i => i.done),
      ];
    }
  }

  // Total progress
  const completed = items.filter(i => i.done).length;
  const total = items.length;

  const SCOPES = [
    { id: 'today',   label: 'Today' },
    { id: 'allTime', label: 'All Time' },
  ];

  return (
    <div style={{ paddingBottom: 100 }}>
      <div style={{ padding: '14px 16px 4px' }}>
        <Display size={26}>Checklist</Display>
        {/* Today vs All Time — segmented pill control. "Today" shows only
            items completed today (via item.date === todayLabel); "All Time"
            is the lifetime view that's always existed. Sized large so it's
            comfortable to tap on the iPhone frame. */}
        <div role="tablist" aria-label="Checklist scope" style={{
          marginTop: 12,
          display: 'inline-flex',
          padding: 3,
          gap: 2,
          background: COLORS.paperDeep,
          border: `1px solid ${COLORS.paperEdge}`,
          borderRadius: 999,
        }}>
          {SCOPES.map(s => {
            const active = scope === s.id;
            return (
              <button
                key={s.id}
                role="tab"
                aria-selected={active}
                onClick={() => setScope(s.id)}
                style={{
                  padding: '8px 18px',
                  minHeight: 36,
                  borderRadius: 999,
                  border: 'none',
                  background: active ? COLORS.ink : 'transparent',
                  color: active ? COLORS.paper : COLORS.inkSoft,
                  fontFamily: 'Manrope, sans-serif',
                  fontSize: 13,
                  fontWeight: 700,
                  letterSpacing: '0.02em',
                  cursor: 'pointer',
                  WebkitTapHighlightColor: 'transparent',
                }}
              >{s.label}</button>
            );
          })}
        </div>
        <div style={{ marginTop: 12 }}>
          <BarProgress value={completed} total={total} color={COLORS.moss} showLabel height={10} />
          <Caption style={{ marginTop: 6 }}>
            {selectedPark === 'DLR' ? 'Disneyland'
              : selectedPark === 'DCA' ? 'California Adventure'
              : 'Resort'}{' '}
            {scope === 'today' ? 'today' : 'completion'} ·{' '}
            {total ? Math.round((completed/total) * 100) : 0}%
          </Caption>
        </div>
      </div>
      <div style={{ padding: '14px 16px 8px' }}>
        <div style={{ position: 'relative' }}>
          <input
            type="text"
            value={query}
            onChange={e => setQuery(e.target.value)}
            placeholder="Search checklist…"
            style={{
              width: '100%',
              padding: '8px 32px 8px 32px',
              borderRadius: 10,
              border: `1px solid ${COLORS.paperEdge}`,
              background: COLORS.paperDeep,
              fontFamily: 'Manrope, sans-serif',
              fontSize: 13,
              color: COLORS.ink,
              outline: 'none',
              boxSizing: 'border-box',
            }}
          />
          <div style={{ position: 'absolute', left: 10, top: 9, pointerEvents: 'none' }}>
            <Icon name="target" size={14} color={COLORS.inkSoft} />
          </div>
          {query && (
            <button
              onClick={() => setQuery('')}
              aria-label="Clear search"
              style={{
                position: 'absolute', right: 6, top: 6,
                background: 'transparent', border: 'none', cursor: 'pointer',
                padding: 4, display: 'flex',
              }}
            >
              <Icon name="close" size={14} color={COLORS.inkSoft} />
            </button>
          )}
        </div>
      </div>
      <div style={{
        display: 'flex', gap: 6, overflowX: 'auto',
        padding: '0 16px 8px', scrollbarWidth: 'none',
      }}>
        {FILTERS.map(f => (
          <button key={f.id} onClick={() => setFilter(f.id)} style={{
            flexShrink: 0, padding: '6px 12px', borderRadius: 100,
            background: filter === f.id ? COLORS.ink : 'transparent',
            color: filter === f.id ? COLORS.paper : COLORS.ink,
            border: `1px solid ${filter === f.id ? COLORS.ink : COLORS.paperEdge}`,
            fontFamily: 'Manrope', fontSize: 12, fontWeight: 600, cursor: 'pointer',
          }}>{f.label}</button>
        ))}
      </div>

      {Object.keys(grouped).map(landId => {
        const land = LANDS[landId];
        const list = grouped[landId];
        const done = list.filter(i => i.done).length;
        const remaining = list.length - done;
        // Force-expand while the user is searching — otherwise typing a
        // query into a fully-collapsed list looks like it returned nothing.
        const open = expandedLand[landId] === true || !!query.trim();
        // Index of the first completed row in the combined view — that's
        // where the "Completed" divider goes. -1 when the land is all to-do.
        const seam = filter === 'combined' ? list.findIndex(i => i.done) : -1;
        return (
          <div key={landId} style={{ padding: '0 16px', marginTop: 14 }}>
            <button onClick={() => setExpandedLand(s => ({ ...s, [landId]: !open }))}
              style={{
                width: '100%',
                background: open ? 'transparent' : COLORS.paperDeep,
                border: `1px solid ${open ? 'transparent' : COLORS.paperEdge}`,
                borderRadius: 12,
                padding: '14px 14px',
                minHeight: 56,
                cursor: 'pointer',
                display: 'flex', alignItems: 'center', gap: 10,
                marginBottom: open ? 10 : 0,
                WebkitTapHighlightColor: 'transparent',
              }}>
              <Icon name={open ? 'chevronDown' : 'chevron'} size={14} color={COLORS.ink} strokeWidth={2.5} />
              <div style={{ flex: 1, textAlign: 'left', minWidth: 0 }}>
                <Display size={17} weight={700}>
                  {land ? land.name : landId} ({done}/{list.length})
                </Display>
                {filter === 'combined' && (
                  <Caption style={{ marginTop: 2 }}>
                    {remaining === 0
                      ? 'All done here'
                      : `${remaining} to go · ${done} done`}
                  </Caption>
                )}
              </div>
              {filter === 'combined' && (
                <div style={{ flexShrink: 0, width: 64 }}>
                  <BarProgress value={done} total={list.length} color={COLORS.moss} height={6} />
                </div>
              )}
            </button>
            {open && (
              <div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingLeft: 12 }}>
                {list.map((item, idx) => (
                  <React.Fragment key={item.id}>
                    {idx === seam && (
                      <PerforatedDivider label={`Completed (${done})`} style={{ margin: '6px 0 2px' }} />
                    )}
                    <ChecklistItem item={item} />
                  </React.Fragment>
                ))}
              </div>
            )}
          </div>
        );
      })}
    </div>
  );
}

function ChecklistItem({ item }) {
  const [done, setDone] = React.useState(!!item.done);
  const [busy, setBusy] = React.useState(false);
  const verbColor = item.verb === 'EATEN' ? COLORS.mustard
                  : item.verb === 'WATCHED' ? COLORS.moss
                  : COLORS.clay;

  async function toggle() {
    if (busy) return;
    setBusy(true);
    const next = !done;
    setDone(next);  // optimistic
    const isAttractionRow = String(item.id || '').startsWith('r-');
    const attractionId = isAttractionRow ? String(item.id).slice(2) : null;
    try {
      // Ride pseudo-items in the checklist persist through user_visits,
      // not user_checklist — the screen reads "done" from a.rides > 0.
      // Without a real check-in (or a clear-visits when un-checking) the
      // ride row would silently revert on refresh.
      if (isAttractionRow) {
        if (next) {
          await window.PH365_ACTIONS?.checkIn?.({ attractionId, source: 'checklist' });
        } else {
          await window.PH365_ACTIONS?.clearVisitsFor?.(attractionId);
        }
      } else {
        await window.PH365_ACTIONS?.toggleChecklist?.({
          templateId: item.id,
          name: item.name,
          category: item.category,
          landId: item.land,
          parkId: item.park,
          done: next,
          verb: item.verb,
        });
      }
      // Only prompt for a rating when marking as done (food / show / ride)
      if (next) {
        if (isAttractionRow) {
          window.PH365_ACTIONS?.openRatingModal?.({
            kind: 'attraction',
            attractionId,
            name: item.name,
            defaultStars: 7,
          });
        } else {
          window.PH365_ACTIONS?.openRatingModal?.({
            kind: 'checklist',
            templateId:  item.id,
            name:        item.name,
            category:    item.category,
            landId:      item.land,
            verb:        item.verb,
            defaultStars: 7,
          });
        }
      }
    } catch (e) {
      console.warn('checklist toggle failed:', e);
      setDone(!next);  // rollback
    } finally { setBusy(false); }
  }

  return (
    <div onClick={toggle} style={{
      background: COLORS.paperDeep,
      border: `1px solid ${COLORS.paperEdge}`,
      borderRadius: 12,
      padding: '14px 16px',
      minHeight: 56,
      display: 'flex', alignItems: 'center', gap: 14,
      position: 'relative',
      overflow: 'hidden',
      cursor: 'pointer',
      opacity: busy ? 0.7 : 1,
      WebkitTapHighlightColor: 'transparent',
    }}>
      <div style={{
        width: 26, height: 26, borderRadius: 6,
        border: `1.5px solid ${done ? COLORS.moss : COLORS.paperEdge}`,
        background: done ? COLORS.moss : 'transparent',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        flexShrink: 0,
      }}>
        {done && <Icon name="check" size={16} color={COLORS.paper} strokeWidth={2.5} />}
      </div>
      <div style={{ flex: 1, minWidth: 0 }}>
        <Body size={14.5} weight={done ? 500 : 600} style={{
          color: done ? COLORS.inkSoft : COLORS.ink,
        }}>{item.name}</Body>
        {item.date && (
          <Caption style={{ marginTop: 2 }}>
            {item.category} · {item.date}
            {item.rides > 1 && ` · ${item.rides}×`}
          </Caption>
        )}
        {!item.date && <Caption style={{ marginTop: 2 }}>{item.category}</Caption>}
      </div>
      {done && item.verb && (
        <div style={{ flexShrink: 0 }}>
          <StampedOverlay text={item.verb} color={verbColor} rotate={-4 + (item.id.length % 5) - 2} />
        </div>
      )}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// PROFILE / ACHIEVEMENTS
// ─────────────────────────────────────────────────────────────
function ProfileScreen() {
  // The achievement detail sheet is rendered at the App level (in
  // Passholder365.html) so it overlays the entire iPhone frame
  // regardless of scroll position. We just dispatch an event with the
  // achievement; App owns the modal lifecycle.
  const openAchievement = (a) =>
    window.dispatchEvent(new CustomEvent('ph365-open-achievement', { detail: a }));

  // Read live from window so we re-render when live-data.jsx swaps it in
  const user = window.PH365.USER || {};
  const achievements = window.PH365.ACHIEVEMENTS || [];
  const isGuest = !!user.isGuest;

  // Friendly date strings
  const joinedDisplay = user.joined
    ? new Date(user.joined).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
    : null;
  // Pass-number — derive from user id if not stored, else show "—"
  const passNumber = user.passNumber || (user.id ? user.id.slice(0, 8).toUpperCase() : '—');

  if (isGuest) {
    return (
      <div style={{ padding: '40px 24px 100px', textAlign: 'center' }}>
        <div style={{
          width: 64, height: 64, margin: '0 auto 16px', borderRadius: '50%',
          border: `2px solid ${COLORS.mustard}`,
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          fontFamily: 'Fraunces, serif', fontSize: 32, color: COLORS.mustard,
          position: 'relative',
        }}>
          ★
          <div style={{ position: 'absolute', inset: 4, borderRadius: '50%', border: `1.2px dashed ${COLORS.mustard}` }} />
        </div>
        <Display size={24} italic>Sign in to track your year</Display>
        <Body size={13} color={COLORS.inkSoft} style={{ marginTop: 10, maxWidth: 280, margin: '10px auto 24px' }}>
          Save your check-ins, ratings, and achievements across devices. It's free, takes 10 seconds, and we never share your data.
        </Body>
        <button onClick={() => window.dispatchEvent(new CustomEvent('ph365-open-auth'))} style={{
          background: COLORS.teal, color: COLORS.paper, border: 'none',
          borderRadius: 10, padding: '12px 24px',
          fontFamily: 'Manrope', fontSize: 14, fontWeight: 600,
          cursor: 'pointer',
        }}>Sign in / Create account</button>
        <div style={{ marginTop: 28, padding: '0 16px' }}>
          <PerforatedDivider label="About" />
          <Card style={{ marginTop: 12, padding: 14 }}>
            <Body size={11} color={COLORS.inkSoft} style={{ lineHeight: 1.55 }}>
              <strong>Lumenears Hub</strong> is an independent companion app for park enthusiasts.
              Not affiliated with, sponsored by, or endorsed by The Walt Disney Company,
              Disneyland Resort, or any other theme park operator.
            </Body>
          </Card>
        </div>
      </div>
    );
  }

  return (
    <div style={{ paddingBottom: 120 }}>
      <div style={{ padding: '14px 16px 18px' }}>
        <Caption>{joinedDisplay ? `Passholder since ${joinedDisplay}` : 'Welcome'}</Caption>
        <Display size={32} style={{ marginTop: 6, marginBottom: 2 }}>{user.name || user.email || 'Member'}</Display>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 }}>
          <span style={{
            display: 'inline-block', padding: '3px 8px', borderRadius: 4,
            background: COLORS.teal, color: COLORS.paper,
            fontFamily: '"JetBrains Mono"', fontSize: 10, fontWeight: 700,
            letterSpacing: '0.1em',
          }}>{(user.passType || 'Member').toUpperCase()}</span>
          <Caption>· No. {passNumber}</Caption>
        </div>

        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8, marginTop: 18 }}>
          <YTDStat value={user.daysYTD || 0} unit="days" label="In park" />
          <YTDStat value={user.ridesYTD || 0} unit="rides" label="This year" />
          <YTDStat value={user.achievements || 0} unit="won" label="Achievements" />
        </div>
      </div>

      <PerforatedDivider label="Achievement collection" style={{ margin: '0 16px' }} />

      <div style={{
        padding: '16px',
        display: 'grid',
        gridTemplateColumns: '1fr 1fr 1fr',
        gap: 10,
      }}>
        {achievements.map(a => (
          <AchievementTile key={a.id} achievement={a} onClick={() => openAchievement(a)} />
        ))}
      </div>

      <div style={{ padding: '0 16px' }}>
        <PerforatedDivider label="Pass details" />
        <Card style={{ marginTop: 12, padding: 0, overflow: 'hidden' }}>
          {[
            ['Pass No.', passNumber],
            ['Member since', joinedDisplay || '—'],
            ['Email', user.email || '—'],
            ['Type', user.passType || 'Member'],
          ].map((row, i, arr) => (
            <div key={row[0]} style={{
              padding: '12px 14px', display: 'flex',
              borderBottom: i < arr.length - 1 ? `1px dashed ${COLORS.paperEdge}` : 'none',
            }}>
              <Body size={13} style={{ flex: 1 }}>{row[0]}</Body>
              <Body size={13} weight={600}>{row[1]}</Body>
            </div>
          ))}
        </Card>

        {/* Settings entry — sign-out and the rest live behind this. */}
        {window.PH365_AUTH && (() => {
          const { useUser } = window.PH365_AUTH;
          const u = useUser();
          if (!u || u.isGuest) return null;
          return (
            <Card
              onClick={() => window.dispatchEvent(new CustomEvent('ph365-open-settings'))}
              style={{ marginTop: 12, padding: '12px 14px', display: 'flex', alignItems: 'center', cursor: 'pointer' }}
            >
              <div style={{ flex: 1 }}>
                <Caption>Signed in as</Caption>
                <Body size={13} weight={600} style={{ marginTop: 2 }}>{u.email}</Body>
              </div>
              <Body size={13} color={COLORS.teal} weight={600} style={{ marginRight: 6 }}>Settings</Body>
              <Icon name="chevron" size={14} color={COLORS.inkSoft} />
            </Card>
          );
        })()}

        {/* One-line legal footer. Full disclaimer + working links live on
            the Settings → About section now, so this stays a footer not
            a full card — keeps the bottom of Profile from feeling cramped. */}
        <Caption style={{
          textAlign: 'center', marginTop: 22, padding: '0 8px',
          fontSize: 9, lineHeight: 1.6, letterSpacing: '0.08em',
          color: COLORS.inkSoft,
        }}>
          Independent app · Not affiliated with The Walt Disney Company.
          {'  '}
          <a
            onClick={(e) => { e.preventDefault(); window.dispatchEvent(new CustomEvent('ph365-open-settings')); }}
            href="#"
            style={{ color: COLORS.teal, textDecoration: 'underline', cursor: 'pointer' }}
          >More in Settings</a>
        </Caption>
      </div>

    </div>
  );
}

function YTDStat({ value, unit, label }) {
  return (
    <Card style={{ padding: '12px 12px', textAlign: 'left' }}>
      <Stamp value={value} unit={unit} size={28} />
      <Caption style={{ marginTop: 4, fontSize: 9 }}>{label}</Caption>
    </Card>
  );
}

function AchievementTile({ achievement, onClick }) {
  // Prefer the DB glyph (achievements.glyph) when present — falls back to a
  // small static map for legacy MVP IDs and finally a star.
  const fallbackGlyph = { mountain: '⛰', foodie: '☕', opener: '☀', dawn: '☾', ten: '✦', grad: '⚡', first: '★', century: '∞', ghost: '◐' }[achievement.id] || '★';
  const glyph = achievement.glyph || fallbackGlyph;
  return (
    <button onClick={onClick} style={{
      background: COLORS.paperDeep,
      border: `1px solid ${COLORS.paperEdge}`,
      borderRadius: 10,
      padding: '12px 8px 10px',
      cursor: 'pointer',
      // No rotation, no aspectRatio:1 — both pushed the rightmost tile in
      // each row outside its grid cell on a 390-wide frame, clipping the
      // border. Content-driven height with a 2-line clamp keeps rows neat.
      display: 'flex',
      flexDirection: 'column',
      alignItems: 'center',
      gap: 6,
      width: '100%',
      minWidth: 0,
      boxSizing: 'border-box',
      overflow: 'hidden',
      backgroundImage: `url("${window.PH365_UI.PAPER_NOISE}")`,
      backgroundSize: '120px 120px',
      opacity: achievement.unlocked ? 1 : 0.92,
    }}>
      <StampIcon
        tier={achievement.tier}
        size={38}
        locked={!achievement.unlocked && achievement.progress < achievement.total}
        glyph={achievement.unlocked ? glyph : '?'}
      />
      <div style={{ width: '100%', minWidth: 0 }}>
        <div style={{
          fontFamily: 'Fraunces, serif', fontWeight: 600, fontSize: 10.5,
          color: COLORS.ink, lineHeight: 1.18, textAlign: 'center',
          display: '-webkit-box',
          WebkitBoxOrient: 'vertical',
          WebkitLineClamp: 2,
          overflow: 'hidden',
          minHeight: 25,
        }}>{achievement.name}</div>
        {!achievement.unlocked && achievement.progress < achievement.total && (
          <div style={{ marginTop: 6 }}>
            <BarProgress value={achievement.progress} total={achievement.total}
              color={achievement.tier === 'ribbon' ? COLORS.ribbon : COLORS.mustard}
              height={4} />
          </div>
        )}
        {achievement.unlocked && (
          <Caption style={{ textAlign: 'center', marginTop: 4, fontSize: 9, color: COLORS.moss }}>
            ✓ {achievement.date}
          </Caption>
        )}
        {achievement.rare && (
          <Caption color={COLORS.ribbon} style={{ textAlign: 'center', marginTop: 4, fontSize: 9 }}>RARE</Caption>
        )}
      </div>
    </button>
  );
}

function AchievementSheet({ achievement, onClose }) {
  const glyph = { mountain: '⛰', foodie: '☕', opener: '☀', dawn: '☾', ten: '✦', grad: '⚡', first: '★', century: '∞', ghost: '◐' }[achievement.id] || '★';
  return (
    <div onClick={onClose} style={{
      position: 'absolute', inset: 0, background: 'rgba(26,37,102,0.5)',
      zIndex: 100, display: 'flex', alignItems: 'flex-end',
      animation: 'fadeIn 0.18s ease-out',
    }}>
      <div onClick={e => e.stopPropagation()} style={{
        width: '100%',
        background: COLORS.paper,
        borderRadius: '16px 16px 0 0',
        // Cap the sheet at 85% of the visible screen-wrap and let it scroll
        // internally. Without this, long descriptions push the title off
        // the top of the iPhone frame and there's no way to recover —
        // tapping a tile felt like nothing happened.
        maxHeight: '85%',
        overflowY: 'auto',
        WebkitOverflowScrolling: 'touch',
        // The sheet's paper runs to the bottom of the screen — it is the last
        // 32px of padding that has to clear the home indicator, not the panel.
        padding: '14px 20px calc(32px + var(--safe-b))',
        backgroundImage: `url("${window.PH365_UI.PAPER_NOISE}")`,
        backgroundSize: '220px 220px',
        animation: 'slideUp 0.24s cubic-bezier(.2,.7,.3,1)',
        boxShadow: '0 -8px 24px rgba(26,37,102,0.18)',
      }}>
        <div style={{
          width: 36, height: 4, borderRadius: 2, background: COLORS.paperEdge,
          margin: '0 auto 14px',
        }} />
        <div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
          <StampIcon
            tier={achievement.tier}
            size={56}
            locked={!achievement.unlocked && achievement.progress < achievement.total}
            glyph={achievement.unlocked ? glyph : '?'}
          />
          <div style={{ flex: 1, minWidth: 0 }}>
            <Caption color={COLORS[achievement.tier === 'ribbon' ? 'ribbon' : 'inkSoft']}>
              {achievement.unlocked ? '✓ Earned' : `${achievement.progress} of ${achievement.total}`}
              {achievement.rare && ' · Rare'}
            </Caption>
            <Display size={20} style={{ marginTop: 2, lineHeight: 1.15 }}>{achievement.name}</Display>
          </div>
        </div>
        <Body size={13.5} color={COLORS.inkSoft} style={{ marginTop: 12, lineHeight: 1.5 }}>{achievement.desc}</Body>
        {!achievement.unlocked && (
          <div style={{ marginTop: 14 }}>
            <BarProgress value={achievement.progress} total={achievement.total}
              color={achievement.tier === 'ribbon' ? COLORS.ribbon : COLORS.mustard}
              showLabel height={10} />
          </div>
        )}
        {achievement.unlocked && (
          <Caption style={{ marginTop: 14, color: COLORS.moss }}>Unlocked {achievement.date}</Caption>
        )}
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// SETTINGS
// ─────────────────────────────────────────────────────────────
// Opens via a 'ph365-open-settings' event dispatched from the Profile
// screen. The App component (in Passholder365.html) listens for that
// event and routes here.

const TRIP_MODE_KEY = 'ph365_trip_mode';

function getTripMode() {
  try { return localStorage.getItem(TRIP_MODE_KEY) === '1'; } catch { return false; }
}
function setTripMode(on) {
  try {
    if (on) localStorage.setItem(TRIP_MODE_KEY, '1');
    else    localStorage.removeItem(TRIP_MODE_KEY);
  } catch {}
  window.dispatchEvent(new CustomEvent('ph365-trip-mode-changed', { detail: { on } }));
}

// ─────────────────────────────────────────────────────────────
// SELECTED PARK (global)
// ─────────────────────────────────────────────────────────────
// Drives the TopBar label, the Checklist filter, and is available to any
// screen that wants to scope itself by park. Persists in localStorage.
// Values: 'DLR' | 'DCA' | 'BOTH' (default 'DLR').
const PARK_KEY = 'ph365_selected_park';
const VALID_PARKS = new Set(['DLR', 'DCA', 'BOTH']);
function getPark() {
  try {
    const v = localStorage.getItem(PARK_KEY);
    return VALID_PARKS.has(v) ? v : 'DLR';
  } catch { return 'DLR'; }
}
function setSelectedPark(park) {
  if (!VALID_PARKS.has(park)) return;
  try {
    if (park === 'DLR') localStorage.removeItem(PARK_KEY);  // default — keep storage clean
    else localStorage.setItem(PARK_KEY, park);
  } catch {}
  window.dispatchEvent(new CustomEvent('ph365-park-changed', { detail: { park } }));
}
function usePark() {
  const [park, setPark] = React.useState(() => getPark());
  React.useEffect(() => {
    const handler = (e) => setPark(e.detail?.park ?? getPark());
    window.addEventListener('ph365-park-changed', handler);
    return () => window.removeEventListener('ph365-park-changed', handler);
  }, []);
  return park;
}

function SettingsScreen({ onBack }) {
  const user = window.PH365.USER || {};
  const isGuest = !!user.isGuest;
  const sb = window.PH365_SUPABASE;
  const [tripModeOn, setTripModeOnState] = React.useState(getTripMode());
  const [confirmDelete, setConfirmDelete] = React.useState(false);
  const [busy, setBusy] = React.useState(null);
  const [msg, setMsg]   = React.useState(null);
  const [err, setErr]   = React.useState(null);

  function toggleTrip(v) {
    setTripModeOnState(v);
    setTripMode(v);
  }

  async function exportData() {
    if (!sb || isGuest) return;
    setBusy('export'); setErr(null); setMsg(null);
    try {
      const { data: { user: u } } = await sb.auth.getUser();
      if (!u) throw new Error('Not signed in');
      const [profile, visits, ratings, checklist, achievements] = await Promise.all([
        sb.from('user_profiles').select('*').eq('id', u.id).maybeSingle(),
        sb.from('user_visits').select('*').eq('user_id', u.id),
        sb.from('user_ratings').select('*').eq('user_id', u.id),
        sb.from('user_checklist').select('*').eq('user_id', u.id),
        sb.from('user_achievements').select('*').eq('user_id', u.id),
      ]);
      const blob = new Blob([JSON.stringify({
        exported_at: new Date().toISOString(),
        profile:       profile.data,
        visits:        visits.data,
        ratings:       ratings.data,
        checklist:     checklist.data,
        achievements:  achievements.data,
      }, null, 2)], { type: 'application/json' });
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = `lumenears-hub-${u.email || u.id}-${new Date().toISOString().slice(0,10)}.json`;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      URL.revokeObjectURL(url);
      setMsg('Downloaded.');
    } catch (e) { setErr(e.message || 'Export failed'); }
    finally { setBusy(null); }
  }

  async function deleteMyData() {
    if (!sb || isGuest) return;
    setBusy('delete'); setErr(null); setMsg(null);
    try {
      const { data: { user: u } } = await sb.auth.getUser();
      if (!u) throw new Error('Not signed in');
      // Cascades to user_visits, user_ratings, user_checklist, user_achievements,
      // queue_sessions/queue_samples (FK ON DELETE CASCADE on user_profiles.id).
      // The auth.users row stays — so if the user signs back in, they get a
      // fresh empty profile via the handle_new_user trigger.
      const { error } = await sb.from('user_profiles').delete().eq('id', u.id);
      if (error) throw error;
      await sb.auth.signOut();
      setMsg('All your data was deleted. Signing you out…');
      // Wait a moment for the user to read the toast, then close.
      setTimeout(onBack, 1500);
    } catch (e) {
      setErr(e.message || 'Delete failed');
      setConfirmDelete(false);
    } finally { setBusy(null); }
  }

  return (
    <div style={{ paddingBottom: 100 }}>
      <div style={{ padding: '8px 16px 18px', borderBottom: `1px dashed ${COLORS.paperEdge}` }}>
        <button onClick={onBack} style={{
          background: 'transparent', border: 'none', padding: 0, cursor: 'pointer',
          display: 'flex', alignItems: 'center', gap: 4, marginBottom: 14,
          fontFamily: 'Manrope, sans-serif', fontSize: 13, color: COLORS.inkSoft,
        }}>
          <Icon name="chevron" size={14} color={COLORS.inkSoft} />
          <span style={{ transform: 'scaleX(-1)' }}> </span>
          Back
        </button>
        <Display size={26}>Settings</Display>
      </div>

      <div style={{ padding: '14px 16px' }}>
        {/* ── Admin: calibration log (only shown for admins) ───────── */}
        {user.isAdmin && (
          <>
            <PerforatedDivider label="Admin" />
            <Card
              onClick={() => window.dispatchEvent(new CustomEvent('ph365-open-calibration-log'))}
              style={{ marginTop: 12, padding: '12px 14px', display: 'flex', alignItems: 'center', cursor: 'pointer' }}
            >
              <div style={{ flex: 1 }}>
                <Body size={13} weight={600}>Calibration log</Body>
                <Caption style={{ marginTop: 2 }}>
                  Review walks, check accuracy, validate path tracking
                </Caption>
              </div>
              <Icon name="chevron" size={14} color={COLORS.inkSoft} />
            </Card>
          </>
        )}

        {/* ── Trip mode ────────────────────────────────────────────── */}
        <PerforatedDivider label="Trip" style={user.isAdmin ? { marginTop: 22 } : undefined} />
        <Card style={{ marginTop: 12, padding: 14 }}>
          <SettingsToggle
            label="Trip mode"
            sublabel="Surfaces today's check-ins on the checklist's “This Trip” filter."
            value={tripModeOn}
            onChange={toggleTrip}
          />
        </Card>

        {/* ── Account ─────────────────────────────────────────────── */}
        <PerforatedDivider label="Account" style={{ marginTop: 22 }} />
        {isGuest ? (
          <Card style={{ marginTop: 12, padding: 14 }}>
            <Body size={13} color={COLORS.inkSoft}>
              You're browsing as a guest. Sign in below to enable account features.
            </Body>
          </Card>
        ) : (
          <Card style={{ marginTop: 12, padding: 0, overflow: 'hidden' }}>
            <SettingsRow label="Email" value={user.email || '—'} />
            <SettingsRow label="Display name" value={user.name || '—'} />
            <SettingsRow label="Member since" value={
              user.joined ? new Date(user.joined).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) : '—'
            } last />
          </Card>
        )}

        {!isGuest && (
          <div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
            <button
              onClick={async () => { await window.PH365_AUTH?.signOut?.(); onBack(); }}
              style={settingsBtn(COLORS.paperDeep, COLORS.ink)}
            >Sign out</button>
            <button
              onClick={exportData}
              disabled={busy === 'export'}
              style={settingsBtn(COLORS.paperDeep, COLORS.ink)}
            >{busy === 'export' ? 'Exporting…' : 'Export my data'}</button>
          </div>
        )}

        {/* ── Danger zone ──────────────────────────────────────────── */}
        {!isGuest && (
          <>
            <PerforatedDivider label="Danger zone" style={{ marginTop: 22 }} />
            <Card style={{
              marginTop: 12, padding: 14,
              border: `1px solid ${COLORS.clay}40`,
              background: `${COLORS.clay}08`,
            }}>
              <Body size={13} weight={600} style={{ marginBottom: 4 }}>Delete my data</Body>
              <Body size={12} color={COLORS.inkSoft} style={{ marginBottom: 12, lineHeight: 1.5 }}>
                Removes your profile, visits, ratings, checklist, achievements,
                and any calibration sessions. Your sign-in remains, so you can
                start fresh later if you change your mind. This can't be undone.
              </Body>
              {!confirmDelete ? (
                <button
                  onClick={() => setConfirmDelete(true)}
                  style={settingsBtn('transparent', COLORS.clay, COLORS.clay)}
                >Delete my data</button>
              ) : (
                <div style={{ display: 'flex', gap: 8 }}>
                  <button
                    onClick={() => setConfirmDelete(false)}
                    disabled={busy === 'delete'}
                    style={{ ...settingsBtn(COLORS.paperDeep, COLORS.ink), flex: 1 }}
                  >Cancel</button>
                  <button
                    onClick={deleteMyData}
                    disabled={busy === 'delete'}
                    style={{ ...settingsBtn(COLORS.clay, COLORS.paper), flex: 2 }}
                  >{busy === 'delete' ? 'Deleting…' : 'Yes, delete everything'}</button>
                </div>
              )}
            </Card>
          </>
        )}

        {(msg || err) && (
          <Body size={12} color={err ? COLORS.clay : COLORS.moss} style={{ marginTop: 10 }}>
            {err || msg}
          </Body>
        )}

        {/* ── About ────────────────────────────────────────────────── */}
        <PerforatedDivider label="About" style={{ marginTop: 22 }} />
        <Card style={{ marginTop: 12, padding: 14 }}>
          <Body size={11.5} color={COLORS.inkSoft} style={{ lineHeight: 1.55 }}>
            <strong>Lumenears Hub</strong> is an independent companion app for park enthusiasts.
            Not affiliated with, sponsored by, or endorsed by The Walt Disney Company,
            Disneyland Resort, or any other theme park operator. Trademarks and
            attraction names are property of their respective owners. Wait-time data
            is sourced from public APIs and may be delayed.
          </Body>
          <div style={{ marginTop: 12, display: 'flex', gap: 14, flexWrap: 'wrap' }}>
            <a href="https://github.com/Crschnicker/Disney365" target="_blank" rel="noreferrer"
               style={{ color: COLORS.teal, fontFamily: 'Manrope', fontSize: 12, textDecoration: 'underline' }}>
              Source on GitHub
            </a>
            <a href="https://github.com/Crschnicker/Disney365/blob/main/README.md" target="_blank" rel="noreferrer"
               style={{ color: COLORS.teal, fontFamily: 'Manrope', fontSize: 12, textDecoration: 'underline' }}>
              Docs
            </a>
            <a href="https://github.com/Crschnicker/Disney365/issues" target="_blank" rel="noreferrer"
               style={{ color: COLORS.teal, fontFamily: 'Manrope', fontSize: 12, textDecoration: 'underline' }}>
              Report a bug
            </a>
          </div>
        </Card>
      </div>
    </div>
  );
}

function settingsBtn(bg, fg, border) {
  return {
    flex: 1,
    background: bg,
    color: fg,
    border: `1px solid ${border || COLORS.paperEdge}`,
    borderRadius: 10,
    padding: '10px 14px',
    fontFamily: 'Manrope, sans-serif',
    fontSize: 13,
    fontWeight: 600,
    cursor: 'pointer',
  };
}

function SettingsRow({ label, value, last }) {
  return (
    <div style={{
      padding: '12px 14px',
      display: 'flex',
      borderBottom: last ? 'none' : `1px dashed ${COLORS.paperEdge}`,
    }}>
      <Body size={13} color={COLORS.inkSoft} style={{ flex: 1 }}>{label}</Body>
      <Body size={13} weight={600} style={{ textAlign: 'right' }}>{value}</Body>
    </div>
  );
}

function SettingsToggle({ label, sublabel, value, onChange }) {
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
      <div style={{ flex: 1 }}>
        <Body size={14} weight={600}>{label}</Body>
        {sublabel && <Body size={11.5} color={COLORS.inkSoft} style={{ marginTop: 2, lineHeight: 1.45 }}>{sublabel}</Body>}
      </div>
      <button
        onClick={() => onChange(!value)}
        aria-pressed={value}
        style={{
          width: 44, height: 26, borderRadius: 999,
          background: value ? COLORS.teal : COLORS.paperEdge,
          border: 'none', cursor: 'pointer', padding: 0,
          position: 'relative', transition: 'background 0.18s',
          flexShrink: 0,
        }}
      >
        <span style={{
          position: 'absolute',
          top: 3, left: value ? 21 : 3,
          width: 20, height: 20, borderRadius: '50%',
          background: COLORS.paper,
          boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
          transition: 'left 0.18s',
        }} />
      </button>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// CALIBRATION LOG (admin)
// ─────────────────────────────────────────────────────────────
// Lists every calibration session with aggregate stats + a small
// SVG sparkline of the path shape so the admin can sanity-check
// accuracy at home before relying on the data at the parks.
function CalibrationLogScreen({ onBack }) {
  const [sessions, setSessions] = React.useState(null);
  const [error, setError] = React.useState(null);

  React.useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        const supabase = window.PH365_SUPABASE;
        const headers = { 'Content-Type': 'application/json' };
        if (supabase) {
          const { data: { session } } = await supabase.auth.getSession();
          if (session?.access_token) headers.Authorization = `Bearer ${session.access_token}`;
        }
        const apiBase = window.PH365_API_BASE || '';
        const res = await fetch(`${apiBase}/api/queue/sessions`, { headers });
        if (!res.ok) throw new Error(`API /api/queue/sessions → ${res.status}`);
        const list = await res.json();
        if (!cancelled) setSessions(list);
      } catch (e) {
        if (!cancelled) setError(e.message);
      }
    })();
    return () => { cancelled = true; };
  }, []);

  // Pull attraction names from the in-memory PH365 dataset (already loaded
  // by the time anyone reaches this screen) so we don't double-fetch.
  const attractionsById = React.useMemo(() => {
    const map = new Map();
    for (const a of (window.PH365.ATTRACTIONS || [])) map.set(a.id, a);
    return map;
  }, []);

  return (
    <div style={{ paddingBottom: 100 }}>
      <div style={{ padding: '8px 16px 18px', borderBottom: `1px dashed ${COLORS.paperEdge}` }}>
        <button onClick={onBack} style={{
          background: 'transparent', border: 'none', padding: 0, cursor: 'pointer',
          display: 'flex', alignItems: 'center', gap: 4, marginBottom: 14,
          fontFamily: 'Manrope, sans-serif', fontSize: 13, color: COLORS.inkSoft,
        }}>
          <Icon name="chevron" size={14} color={COLORS.inkSoft} />
          <span style={{ transform: 'scaleX(-1)' }}> </span>
          Back
        </button>
        <Display size={26}>Calibration log</Display>
        <Caption style={{ marginTop: 6 }}>
          Walk a known route, then check the path shape and accuracy here.
        </Caption>
      </div>

      <div style={{ padding: '14px 16px' }}>
        {sessions === null && !error && (
          <Caption style={{ textAlign: 'center', padding: '24px 0' }}>Loading sessions…</Caption>
        )}
        {error && (
          <Card style={{ padding: 14 }}>
            <Caption color={COLORS.clay}>Couldn't load sessions</Caption>
            <Body size={12} color={COLORS.inkSoft} style={{ marginTop: 4 }}>{error}</Body>
          </Card>
        )}
        {sessions && sessions.length === 0 && (
          <Card style={{ padding: 14, textAlign: 'center' }}>
            <Caption>No calibration walks yet</Caption>
            <Body size={12} color={COLORS.inkSoft} style={{ marginTop: 6, lineHeight: 1.5 }}>
              Open any ride's detail screen, set its entrance from your
              location, and tap "Start calibration walk" to record your first.
            </Body>
          </Card>
        )}
        {sessions?.length > 0 && (
          <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
            {sessions.map(s => (
              <CalibrationLogRow key={s.id} session={s} attraction={attractionsById.get(s.attraction_id)} />
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

function CalibrationLogRow({ session, attraction }) {
  const [path, setPath] = React.useState(null);
  const [open, setOpen] = React.useState(false);

  // Lazy-load the path samples only when the user opens the row — keeps
  // the list snappy when there are 50+ sessions.
  React.useEffect(() => {
    if (!open || path !== null) return;
    let cancelled = false;
    (async () => {
      try {
        const supabase = window.PH365_SUPABASE;
        const headers = { 'Content-Type': 'application/json' };
        if (supabase) {
          const { data: { session: sess } } = await supabase.auth.getSession();
          if (sess?.access_token) headers.Authorization = `Bearer ${sess.access_token}`;
        }
        const apiBase = window.PH365_API_BASE || '';
        const res = await fetch(`${apiBase}/api/queue/session/${session.id}/path`, { headers });
        if (!res.ok) throw new Error(`API → ${res.status}`);
        const json = await res.json();
        if (!cancelled) setPath(json.samples || []);
      } catch (e) {
        if (!cancelled) setPath([]);
      }
    })();
    return () => { cancelled = true; };
  }, [open, path, session.id]);

  const startedAt = new Date(session.started_at);
  const endedAt   = session.ended_at ? new Date(session.ended_at) : null;
  const dateLabel = startedAt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
  const timeLabel = startedAt.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
  const duration  = endedAt ? Math.round((endedAt - startedAt) / 60_000) : null;
  const accColor  = session.median_accuracy_m == null ? COLORS.inkSoft
                  : session.median_accuracy_m <= 15 ? COLORS.moss
                  : session.median_accuracy_m <= 30 ? COLORS.mustard
                  : COLORS.clay;

  return (
    <Card style={{ padding: 0, overflow: 'hidden' }}>
      <button
        onClick={() => setOpen(o => !o)}
        style={{
          width: '100%', background: 'transparent', border: 'none',
          padding: '14px', cursor: 'pointer', textAlign: 'left',
        }}
      >
        <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
          <div style={{ flex: 1, minWidth: 0 }}>
            <Body size={14} weight={700} style={{
              whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
            }}>{attraction?.name ?? session.attraction_id}</Body>
            <Caption style={{ marginTop: 2 }}>
              {dateLabel} · {timeLabel} · {session.source}
              {session.end_reason && ` · ${session.end_reason}`}
            </Caption>
          </div>
          <Icon name={open ? 'chevronUp' : 'chevronDown'} size={14} color={COLORS.inkSoft} />
        </div>
        <div style={{
          display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)',
          gap: 6, marginTop: 12,
        }}>
          <CalibStat label="Time"      value={duration != null ? `${duration}m` : '—'} />
          <CalibStat label="Samples"   value={session.sample_count ?? 0} />
          <CalibStat label="Distance"  value={formatMeters(session.distance_m)} />
          <CalibStat label="Median ±"  value={session.median_accuracy_m != null ? `${session.median_accuracy_m}m` : '—'} color={accColor} />
        </div>
      </button>
      {open && (
        <div style={{
          padding: '0 14px 14px',
          borderTop: `1px dashed ${COLORS.paperEdge}`,
        }}>
          {path === null ? (
            <Caption style={{ textAlign: 'center', padding: '20px 0' }}>Loading path…</Caption>
          ) : path.length < 2 ? (
            <Caption style={{ textAlign: 'center', padding: '20px 0' }}>
              No samples — session likely cut off before the first 10s tick.
            </Caption>
          ) : (
            <>
              <Caption style={{ marginTop: 12, marginBottom: 8 }}>Path shape</Caption>
              <div style={{
                background: COLORS.paperDeep,
                border: `1px solid ${COLORS.paperEdge}`,
                borderRadius: 10,
                padding: 10,
                display: 'flex', justifyContent: 'center',
              }}>
                <PathSparkline samples={path} width={300} height={180} />
              </div>
              <Caption style={{ marginTop: 8, fontSize: 9, color: COLORS.inkSoft }}>
                Yellow = start · Green = end · Light dots = each 10s sample.
                The sparkline is the literal lat/lon trace, normalized so
                the bounding box fills the panel.
              </Caption>
            </>
          )}
        </div>
      )}
    </Card>
  );
}

// SVG drawing of a lat/lon trace, normalized into a fixed viewport.
// Square aspect (using max(latSpan, lonSpan)) so the path's true shape
// is preserved instead of stretched.
function PathSparkline({ samples, width = 280, height = 180 }) {
  if (!samples || samples.length < 2) return null;
  const lats = samples.map(s => s.lat);
  const lons = samples.map(s => s.lon);
  const minLat = Math.min(...lats), maxLat = Math.max(...lats);
  const minLon = Math.min(...lons), maxLon = Math.max(...lons);
  // Use the larger of the two spans so the shape isn't stretched. Latitude
  // and longitude aren't equal at this scale (lon-degree ≈ lat-degree × cos),
  // so we compensate for the cosine factor before picking the span.
  const cosLat = Math.cos(((minLat + maxLat) / 2) * Math.PI / 180);
  const lonSpanScaled = (maxLon - minLon) * cosLat;
  const latSpan = maxLat - minLat;
  const span = Math.max(latSpan, lonSpanScaled, 1e-6);
  const pad = 12;
  const cx = (minLon + maxLon) / 2;
  const cy = (minLat + maxLat) / 2;
  const proj = (lon, lat) => {
    const x = ((lon - cx) * cosLat / span) * (width - pad * 2) + width / 2;
    const y = -((lat - cy) / span) * (height - pad * 2) + height / 2;
    return [x, y];
  };
  const points = samples.map(s => proj(s.lon, s.lat));
  const d = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' ');
  return (
    <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
      {/* Faint sample dots underneath the line */}
      {points.map((p, i) => (
        <circle key={i} cx={p[0]} cy={p[1]} r="1.5" fill={COLORS.paperEdge} opacity="0.6" />
      ))}
      {/* Glow */}
      <path d={d} fill="none" stroke={COLORS.mustard} strokeWidth="6" strokeOpacity="0.25" strokeLinecap="round" strokeLinejoin="round" />
      {/* Line */}
      <path d={d} fill="none" stroke={COLORS.mustard} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
      {/* Start marker */}
      <circle cx={points[0][0]} cy={points[0][1]} r="4" fill={COLORS.mustard} stroke="#fff" strokeWidth="1.5" />
      {/* End marker */}
      <circle cx={points[points.length - 1][0]} cy={points[points.length - 1][1]} r="4" fill={COLORS.moss} stroke="#fff" strokeWidth="1.5" />
    </svg>
  );
}

Object.assign(window, {
  DetailScreen, ChecklistScreen, ProfileScreen, SettingsScreen,
  AchievementSheet, CalibrationLogScreen,
  PH365_TRIP: { get: getTripMode, set: setTripMode },
  PH365_PARK: { get: getPark, set: setSelectedPark },
  usePark,
});
})();
