/* global React, Row, Field, Spinner, Mascot, signOut,
   useDaySlots, useDayFields, useDayPlay, saveSlot, deleteSlot, publishDayTimes, applyScheduleAcrossDays, useCourseFillRate,
   useDailyYardages, saveDailyYardages, clearDailyYardages, saveHoleQuadrants, setDailyPin,
   useLiveOnCourse, useComingSoon, useAvgRoundTime, useCourseWaitlist, useCourseFinancials,
   syncTeeAssignments, clearTeeAssignments, rescheduleFoursome,
   ghostEmulateSlot, ghostClearAll, ghostStateAt, useGhostGroups, modeForCount,
   ghostEmulateDemand, ghostClearDemand, useGhostDemand, getSlotNine, setSlotNine, getDayNine, setDayNine,
   loadTTTemplate, saveTTTemplate, loadTTOverride, saveTTOverride, clearTTOverride,
   isoFromDate, addCalendarDays, todayStr, dayLabel, timeLabel, TIME_WINDOWS, hmLabel, genTimes, slotHM, minutesOfDay, toMin, money,
   useCountUp, DemoChip, Icon, ConfirmDialog, Sparkline, Bars, HBar, PairedBars, AreaCurve, Dial,
   useDemoMode, setDemoMode, demoWindowDays, recordDemoBump, BusinessPanel, PartnershipPanel,
   useRackRate, suggestedPrice, TWILIGHT_SHARE, PortalShell, normalTier, StickySave, useDirty */
// Course-partner portal — what a course manager sees when they sign in.
// Locked to the course(s) they manage. RLS (course-managers.sql) enforces it;
// this UI just never offers anything outside their course.

const M_SECTIONS = [
  { id: 'times',   group: 'Operations', label: 'Tee Sheet',      icon: 'clock', hint: 'Open times & set pricing' },
  { id: 'yards',   group: 'Operations', label: 'Daily Yardages', icon: 'flag',  hint: 'Set today’s pin distances' },
  { id: 'live',    group: 'Operations', label: 'Live On Course', icon: 'pin',   hint: 'Who’s out playing right now' },
  { id: 'business', group: 'Business',  label: 'Insights',       icon: 'trend', hint: 'Revenue, utilization, rounds & retention' },
  { id: 'partner', group: 'Sandbox',    label: 'Partnership',    icon: 'link',  hint: 'Your contract journey & perks' },
];

// ─── Shell ───────────────────────────────────────────────────────────────────
function ManagerPortal({ session, profile, courses }) {
  const [courseId, setCourseId] = React.useState(courses[0].course.id);
  const [view, setView] = React.useState('times');
  const link = courses.find(c => c.course.id === courseId) || courses[0];
  const course = link.course;
  const name = profile ? ([profile.first_name, profile.last_name].filter(Boolean).join(' ') || session.user.email) : session.user.email;

  // Course switcher, but only when there is a choice to make.
  const aside = courses.length > 1 ? (
    <div style={{ position: 'relative', margin: '0 4px 18px' }}>
      <select className="select" value={courseId} onChange={e => setCourseId(e.target.value)}
        aria-label="Course"
        style={{
          appearance: 'none', background: 'rgba(28,73,42,0.05)', color: 'var(--forest)',
          border: '1px solid var(--line-strong)', fontSize: 13, fontWeight: 700, padding: '9px 32px 9px 11px',
        }}>
        {courses.map(c => <option key={c.course.id} value={c.course.id} style={{ color: '#111' }}>{c.course.short_name}</option>)}
      </select>
      <Icon name="chevron" size={14} style={{ position: 'absolute', right: 11, top: '50%', transform: 'translateY(-50%)', color: 'var(--forest)', opacity: 0.6, pointerEvents: 'none' }}/>
    </div>
  ) : (
    <div style={{ margin: '0 10px 18px', fontSize: 13.5, fontWeight: 700, opacity: 0.92, letterSpacing: '-0.005em' }}>{course.short_name}</div>
  );

  return (
    <PortalShell
      scope="manager-portal" role="Course Partner" name={name}
      groups={['Operations', 'Business', 'Sandbox']} sections={M_SECTIONS}
      view={view} onView={setView} aside={aside} eyebrow={course.short_name}>
      {view === 'live'  && <LiveBoard course={course}/>}
      {view === 'times' && <TeeTimesPanel course={course}/>}
      {view === 'yards' && <YardagesPanel course={course}/>}
      {view === 'business' && <BusinessPanel course={course}/>}
      {view === 'partner' && <PartnershipPanel course={course}/>}
    </PortalShell>
  );
}

// ─── Live on course ──────────────────────────────────────────────────────────
// The command-center page. One progress line per nine (front / back), each
// group shown as its clustered foursome walking hole to hole off the
// players' own live tracking (fairway logged → mid-hole, on the green → at
// the pin, hole done → on to the next). Below it: a single operational
// board — Coming Soon queue, On The Course feed, Completed — as one bordered
// panel with divider rows, not a card stacked on a card.

// Within-hole position from the tracked stage.
const STAGE_FRAC = { tee: 0.12, fairway: 0.5, green: 0.85 };

// Elapsed 'm:ss' (or 'h:mm:ss') running timer text.
function elapsedLabel(startMs, now) {
  const s = Math.max(0, Math.floor((now - startMs) / 1000));
  const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
  const p = (n) => String(n).padStart(2, '0');
  return h ? `${h}:${p(m)}:${p(sec)}` : `${m}:${p(sec)}`;
}

// Unify real + ghost groups into what the map/boards render.
function boardGroups(real, ghosts, now) {
  const out = { onCourse: [], completed: [], queuedGhosts: [] };
  for (const g of (real || [])) {
    const nine = g.slotId ? getSlotNine(g.slotId) : 'front';
    const waiting = g.status === 'waiting';
    // A 'waiting' match is matched-but-not-started — it belongs in Coming Soon
    // (shown from tee_assignments), NOT On The Course. Only when a player checks
    // in & starts (status → 'active') does it move onto the course. That start
    // is exactly what makes the group "move" on the board.
    if (waiting) continue;
    // Done = confirmed complete, or all holes played (finished, awaiting confirm).
    const done = g.status === 'completed' || (g.holesDone >= g.total);
    const row = {
      key: g.id, ghost: false, nine, slotId: g.slotId || null,
      teeISO: g.teeISO, players: g.players || [],
      startMs: g.startedAt ? new Date(g.startedAt).getTime() : now,
      endMs: g.completedAt ? new Date(g.completedAt).getTime() : null,
      waiting: false, finished: done,
      holeIdx: Math.min(g.holesDone, g.total - 1),
      stage: g.stage,
      leader: g.leader,
    };
    (done ? out.completed : out.onCourse).push(row);
  }
  for (const g of (ghosts || [])) {
    const st = ghostStateAt(g, now);
    if (st.phase === 'queued') { out.queuedGhosts.push(g); continue; }
    if (st.phase === 'gone') continue;
    const row = {
      key: g.id, ghost: true, nine: g.nine, slotId: null,
      teeISO: g.teeISO, players: g.players, mode: g.mode || modeForCount(g.players.length),
      startMs: g.startAt, endMs: null,
      waiting: false, finished: st.phase === 'finished',
      holeIdx: st.phase === 'finished' ? 8 : st.holeIdx,
      stage: st.phase === 'finished' ? 'green' : st.stage,
      leader: null,
    };
    (st.phase === 'finished' ? out.completed : out.onCourse).push(row);
  }
  return out;
}

function holeLabelOf(g) {
  const base = g.nine === 'back' ? 9 : 0;
  return base + g.holeIdx + 1;
}

function groupStatusText(g) {
  const hole = holeLabelOf(g);
  if (g.waiting) return 'Waiting to tee off';
  if (g.finished) return 'Finished the round';
  if (g.stage === 'tee') return `Teeing off on hole ${hole}`;
  if (g.stage === 'green') return `On hole ${hole} green`;
  return `Hole ${hole} in progress`;
}

function MiniAvatar({ p, size = 16 }) {
  const st = {
    width: size, height: size, borderRadius: 999, overflow: 'hidden', boxSizing: 'border-box',
    border: '1.5px solid var(--paper)', background: 'var(--forest)', color: 'var(--cream)',
    display: 'flex', alignItems: 'center', justifyContent: 'center',
    fontSize: Math.max(7, size * 0.42), fontWeight: 700, flexShrink: 0,
  };
  return p.avatar_url
    ? <img src={p.avatar_url} alt={p.name || ''} title={p.name} style={{ ...st, objectFit: 'cover' }}/>
    : <div title={p.name} style={st}>{p.initials}</div>;
}

// The clustered foursome marker that walks the line. Live groups get a
// pulsating ring so they read as "moving right now"; finished groups drop
// the pulse, dim slightly and pick up a small check badge — the state
// change itself is what animates (the marker slides via `left` transition,
// the pulse/check swap on the same frame the group's data flips).
function MapGroupMarker({ g, frac }) {
  const live = !g.finished && !g.waiting;
  return (
    <div style={{
      position: 'absolute', left: `${frac * 100}%`, top: 0, transform: 'translateX(-50%)',
      transition: 'left 0.9s var(--ease)', textAlign: 'center', zIndex: 3, pointerEvents: 'none',
    }}>
      <div style={{ fontSize: 9.5, fontFamily: 'var(--font-mono)', fontWeight: 700, color: live ? 'var(--paper)' : 'var(--ink-faint)', whiteSpace: 'nowrap', marginBottom: 3 }}>
        {g.teeISO ? timeLabel(g.teeISO) : ''}
      </div>
      <div style={{ position: 'relative', display: 'inline-block' }}>
        {live && <span className="live-pulse"/>}
        <div style={{
          position: 'relative', display: 'inline-grid', gridTemplateColumns: 'repeat(2, 18px)', gap: 1,
          justifyContent: 'center', opacity: g.finished ? 0.55 : 1, transition: 'opacity var(--dur-slow) var(--ease)',
        }}>
          {g.players.slice(0, 4).map((p, i) => <MiniAvatar key={p.id || i} p={p} size={18}/>)}
        </div>
        {g.finished && (
          <div style={{
            position: 'absolute', right: -3, bottom: -3, width: 13, height: 13, borderRadius: 999,
            background: 'var(--cream)', display: 'flex', alignItems: 'center', justifyContent: 'center',
            border: '1.5px solid var(--forest)',
          }}>
            <svg width="7" height="7" viewBox="0 0 20 20" fill="none" aria-hidden="true">
              <path d="M4 10l4 4 8-8" stroke="var(--forest)" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round"/>
            </svg>
          </div>
        )}
      </div>
    </div>
  );
}

// One nine: tee flag ─ H1 ─ … ─ H9 (or H10 … H18) on a single line.
function NineLine({ nine, groups, showTitle }) {
  const base = nine === 'back' ? 9 : 0;
  return (
    <div style={{ padding: '6px 0 4px' }}>
      {showTitle && (
        <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
          <Icon name="flag" size={12} style={{ color: 'var(--cream)', opacity: 0.65 }}/>
          <span className="eyebrow eyebrow-brand">{nine === 'back' ? 'Back Nine' : 'Front Nine'}</span>
        </div>
      )}
      <div style={{ position: 'relative', height: 100, margin: '0 10px' }}>
        {/* groups walk above the track */}
        {groups.map(g => {
          const frac = g.finished ? 1 : Math.min(1, (g.holeIdx + (STAGE_FRAC[g.stage] || 0.12)) / 9);
          return <MapGroupMarker key={g.key} g={g} frac={frac}/>;
        })}
        {/* the track */}
        <div style={{ position: 'absolute', left: 0, right: 0, top: 66, height: 2, background: 'var(--line-strong)', borderRadius: 2 }}/>
        {/* tee marker */}
        <div style={{ position: 'absolute', left: -1, top: 66, transform: 'translateY(-14px)', color: 'var(--cream)', opacity: 0.8 }}>
          <Icon name="flag" size={13}/>
        </div>
        <div style={{ position: 'absolute', left: 0, top: 77, fontSize: 9, fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--ink-faint)', whiteSpace: 'nowrap' }}>
          Tee
        </div>
        {/* hole checkpoints 1..9 */}
        {Array.from({ length: 9 }, (_, i) => {
          const holeN = base + i + 1;
          const left = ((i + 1) / 9) * 100;
          const isLast = i === 8;
          return (
            <React.Fragment key={holeN}>
              <div style={{
                position: 'absolute', left: `${left}%`, top: 66, transform: 'translate(-50%, -3px)',
                width: 6, height: 6, borderRadius: 99, boxSizing: 'border-box',
                background: isLast ? 'var(--cream)' : 'transparent',
                border: `1.5px solid ${isLast ? 'var(--cream)' : 'var(--line-strong)'}`,
              }}/>
              <div style={{
                position: 'absolute', left: `${left}%`, top: 77, transform: 'translateX(-50%)',
                fontSize: 9, fontFamily: 'var(--font-mono)', whiteSpace: 'nowrap',
                color: isLast ? 'var(--cream)' : 'var(--ink-faint)', fontWeight: isLast ? 700 : 500,
              }}>
                {isLast ? `Hole ${holeN}` : holeN}
              </div>
            </React.Fragment>
          );
        })}
      </div>
    </div>
  );
}

// ─── Board column — one shared shell for Coming Soon / On The Course /
// Completed so the three read as ONE operational board (divider rows
// inside a single bordered panel), not three separate floating cards. ──
function BoardColumn({ title, icon, count, border, empty, emptyText, children }) {
  return (
    <div style={{ background: 'var(--surface)', borderLeft: border ? '1px solid var(--line)' : 'none', display: 'flex', flexDirection: 'column' }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '15px 18px 11px' }}>
        <Icon name={icon} size={13} style={{ color: 'var(--cream)', opacity: 0.75 }}/>
        <span style={{ fontFamily: 'var(--font-display)', fontSize: 15, color: 'var(--paper)' }}>{title}</span>
        {count != null && <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--ink-faint)', fontWeight: 700 }}>{count}</span>}
      </div>
      {empty ? (
        <div style={{ flex: 1, minHeight: 140, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '10px 26px 26px' }}>
          <div style={{ fontSize: 12.5, color: 'var(--ink-faint)', lineHeight: 1.55, textAlign: 'center', maxWidth: 190 }}>{emptyText}</div>
        </div>
      ) : children}
    </div>
  );
}

// One queued group: tee time, nine, roster with SPP marks + SBX chips.
function ComingRow({ c, first }) {
  return (
    <div className="fade-in" style={{ padding: '13px 18px', borderTop: first ? 'none' : '1px solid var(--line-soft)' }}>
      <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: 8 }}>
        <span style={{ fontFamily: 'var(--font-mono)', fontWeight: 700, fontSize: 13.5, color: 'var(--paper)' }}>{timeLabel(c.teeISO)}</span>
        <span style={{ fontSize: 9, fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--ink-faint)' }}>
          {c.nine === 'back' ? 'Back 9' : 'Front 9'}
          {c.ghost && c.mode ? ` · ${c.mode}` : ''}{c.ghost ? ' · demo' : ''}
        </span>
      </div>
      {c.players.map((p, i) => (
        <div key={p.id || i} style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '4px 0' }}>
          <span style={{ width: 16, display: 'inline-flex', justifyContent: 'center', flexShrink: 0 }}>
            {p.member && <img src="assets/monogram-cream.svg" alt="SPP member" title="Sandbox member" style={{ height: 14 }}/>}
          </span>
          <MiniAvatar p={p} size={26}/>
          <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink)', flex: 1, minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.name}</span>
          <span style={{ fontSize: 11, fontFamily: 'var(--font-mono)', fontWeight: 700, color: 'var(--ink-muted)', flexShrink: 0 }}>
            {p.sbx != null ? p.sbx.toFixed(3) : '—'}
          </span>
        </div>
      ))}
    </div>
  );
}

// One active group: avatars, live status text (from STAGE_LABEL), running clock.
function OnCourseRow({ g, now, first }) {
  return (
    <div className="fade-in" style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 18px', borderTop: first ? 'none' : '1px solid var(--line-soft)' }}>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 24px)', gap: 2, flexShrink: 0 }}>
        {g.players.slice(0, 4).map((p, i) => <MiniAvatar key={p.id || i} p={p} size={24}/>)}
      </div>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 7, flexWrap: 'wrap' }}>
          <span className="status-dot status-dot--live"/>
          <span style={{ fontSize: 13, fontWeight: 700, color: 'var(--ink)' }}>{groupStatusText(g)}</span>
        </div>
        <div style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--ink-faint)', marginTop: 3 }}>
          {g.teeISO ? `Off ${timeLabel(g.teeISO)}` : ''} · {elapsedLabel(g.startMs, now)} elapsed
          {g.leader ? ` · ${g.leader}` : ''}
          {g.ghost && g.mode ? ` · ${g.mode}` : ''}{g.ghost ? ' · demo' : ''}
        </div>
      </div>
    </div>
  );
}

// One finished round: quiet check state, no pulse.
function CompletedRow({ g, now, first }) {
  return (
    <div className="fade-in" style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 18px', borderTop: first ? 'none' : '1px solid var(--line-soft)', opacity: 0.75 }}>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 22px)', gap: 2, flexShrink: 0 }}>
        {g.players.slice(0, 4).map((p, i) => <MiniAvatar key={p.id || i} p={p} size={22}/>)}
      </div>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
          <span className="status-dot status-dot--forest"/>
          <span style={{ fontSize: 13, fontWeight: 700, color: 'var(--ink)' }}>Finished</span>
        </div>
        <div style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--ink-faint)', marginTop: 3 }}>
          {g.teeISO ? `Off ${timeLabel(g.teeISO)}` : ''}{g.endMs ? ` · done ${elapsedLabel(g.endMs, now)} ago` : ''}
          {g.leader ? ` · ${g.leader}` : ''}
          {g.ghost && g.mode ? ` · ${g.mode}` : ''}{g.ghost ? ' · demo' : ''}
        </div>
      </div>
    </div>
  );
}

function BoardSkeleton() {
  return (
    <div>
      <div className="skeleton" style={{ height: 150, borderRadius: 'var(--r-md)' }}/>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 0, marginTop: 20, border: 'var(--hairline)', borderRadius: 'var(--r-md)', overflow: 'hidden' }}>
        <div className="skeleton" style={{ height: 220 }}/>
        <div className="skeleton" style={{ height: 220, borderLeft: '1px solid var(--line)' }}/>
      </div>
    </div>
  );
}

function LiveBoard({ course }) {
  const [real, reload] = useLiveOnCourse(course.id);
  const [coming] = useComingSoon(course.id);
  const avgTime = useAvgRoundTime(course.id);
  const ghosts = useGhostGroups();
  const demoMode = useDemoMode();
  const [now, setNow] = React.useState(Date.now());
  React.useEffect(() => {
    const iv = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(iv);
  }, []);

  // Demo-only: spawn a fabricated group that walks straight onto the course
  // right now (synthetic slot, starts_at = now → no "Coming Soon" wait).
  // Ghost emulator tops up the seats from its roster. Purely in-memory —
  // nothing is written to Supabase. `count` (2/3/4) picks the adaptive match
  // mode to demo — real tee-time tiles are still always a foursome today
  // (booking flow hasn't flipped the adaptive-modes flag), but this button
  // has no real booking underneath it, so it can show all three.
  const [liveEmulateCount, setLiveEmulateCount] = React.useState(4);
  function emulateLive() {
    const slot = { id: `demo-live-${Math.random().toString(36).slice(2, 9)}`, starts_at: new Date().toISOString() };
    ghostEmulateSlot(slot, 'front', null, liveEmulateCount);
  }

  const { onCourse, completed, queuedGhosts } = boardGroups(real, ghosts, now);

  // Slots already playing or finished shouldn't also appear as "coming soon".
  const busySlots = new Set([...onCourse, ...completed].map(g => g.slotId).filter(Boolean));

  // Coming soon = real booked-not-started tee times + queued ghost foursomes.
  const comingRows = [
    ...(coming || []).filter(c => !busySlots.has(c.slotId)).map(c => ({ key: c.slotId, teeISO: c.teeISO, players: c.players, nine: getSlotNine(c.slotId), ghost: false })),
    ...queuedGhosts.map(g => ({ key: g.id, teeISO: g.teeISO, players: g.players, nine: g.nine, ghost: true, mode: g.mode || modeForCount(g.players.length) })),
  ].sort((a, b) => new Date(a.teeISO) - new Date(b.teeISO));

  // Which nines get a line: every nine in play or queued; front by default.
  const nines = [...new Set([...onCourse.map(g => g.nine), ...comingRows.map(c => c.nine)])];
  const lines = ['front', 'back'].filter(n => nines.includes(n));
  if (!lines.length) lines.push('front');

  const anyGhost = ghosts.length > 0;
  const loading = real === null && coming === null;
  const completedSorted = completed.slice().sort((a, b) => (b.endMs || b.startMs) - (a.endMs || a.startMs));

  return (
    <div style={{ maxWidth: 1320, margin: '0 auto' }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18, flexWrap: 'wrap', gap: 12 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 22, flexWrap: 'wrap' }}>
          <span className="status"><span className="status-dot status-dot--live"/>Live · updates automatically</span>
          <div style={{ display: 'flex', alignItems: 'baseline', gap: 7 }} title={avgTime && avgTime.rounds ? `Average of the last ${avgTime.rounds} completed rounds` : 'Shows once rounds finish here'}>
            <span className="eyebrow">Avg Round</span>
            <span style={{ fontFamily: 'var(--font-mono)', fontWeight: 700, fontSize: 13, color: 'var(--paper)' }}>
              {avgTime && avgTime.avgMin != null ? `${avgTime.avgMin} min` : '—'}
            </span>
          </div>
        </div>
        <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
          {/* Which adaptive match mode to fabricate. Real tee-time tiles are
              still always a foursome — the booking flow hasn't flipped that
              flag — but this button has no real booking under it, so it can
              show all three. Segmented rather than a select: three options,
              and the current one should be readable without opening it. */}
          {demoMode && (
            <div role="group" aria-label="Match mode to emulate"
              style={{ display: 'flex', border: '1px solid var(--line-strong)', borderRadius: 999, overflow: 'hidden' }}>
              {[[2, '1v1'], [3, '1v1v1'], [4, '2v2']].map(([n, label]) => {
                const on = liveEmulateCount === n;
                return (
                  <button key={n} onClick={() => setLiveEmulateCount(n)} aria-pressed={on}
                    style={{
                      padding: '5px 11px', font: 'inherit', fontSize: 11, fontWeight: 700,
                      fontFamily: 'var(--font-mono)', cursor: 'pointer', border: 'none',
                      background: on ? 'var(--cream)' : 'transparent',
                      color: on ? 'var(--forest)' : 'var(--ink-muted)',
                    }}>{label}</button>
                );
              })}
            </div>
          )}
          {demoMode && <button className="btn btn-ghost" onClick={emulateLive} title="Demo only — spawn a fabricated group onto the live course. In-memory, nothing saved.">Emulate Live on Course</button>}
          {anyGhost && <button className="btn btn-quiet" onClick={ghostClearAll}>Clear emulation</button>}
          <button className="btn btn-quiet" onClick={reload} title="Refresh"><Icon name="refresh" size={14}/></button>
        </div>
      </div>

      {loading ? <BoardSkeleton/> : (
        <>
          {/* The live map */}
          <div className="card" style={{ padding: '20px 24px 16px' }}>
            {lines.map((n, i) => (
              <div key={n} style={{ borderTop: i ? '1px solid var(--line-soft)' : 'none', marginTop: i ? 10 : 0, paddingTop: i ? 10 : 0 }}>
                <NineLine nine={n} groups={onCourse.filter(g => g.nine === n)} showTitle={lines.length > 1}/>
              </div>
            ))}
            {onCourse.length === 0 && (
              <div style={{ textAlign: 'center', fontSize: 12.5, color: 'var(--ink-faint)', paddingBottom: 6 }}>
                Nobody on the course — groups walk this line the moment they tee off.
              </div>
            )}
          </div>

          {/* One operational board: Coming Soon · On The Course · Completed */}
          <div className="card" style={{
            display: 'grid', gridTemplateColumns: completedSorted.length ? 'repeat(3, 1fr)' : 'repeat(2, 1fr)',
            marginTop: 20, overflow: 'hidden',
          }}>
            <BoardColumn title="Coming Soon" icon="clock" count={comingRows.length || null} empty={comingRows.length === 0}
              emptyText="No groups queued up yet — booked tee times land here as they approach.">
              {comingRows.map((c, i) => <ComingRow key={c.key} c={c} first={i === 0}/>)}
            </BoardColumn>
            <BoardColumn title="On The Course" icon="pin" count={onCourse.length || null} border empty={onCourse.length === 0}
              emptyText="Groups appear here the moment they start playing.">
              {onCourse.map((g, i) => <OnCourseRow key={g.key} g={g} now={now} first={i === 0}/>)}
            </BoardColumn>
            {completedSorted.length > 0 && (
              <BoardColumn title="Completed" icon="trend" count={completedSorted.length} border>
                {completedSorted.map((g, i) => <CompletedRow key={g.key} g={g} now={now} first={i === 0}/>)}
              </BoardColumn>
            )}
          </div>
        </>
      )}
    </div>
  );
}

// ─── Waitlist auto-fill ──────────────────────────────────────────────────────
// UPDATED 2026-08-24: match_waitlist (described below) was retired in favor
// of sandbox_sift + sandbox_lock — member-first seating, provisional slots,
// cross-course conflict clearing, and 2/3/4-headcount formats instead of
// foursome-only. This function does not replicate that engine and isn't
// trying to any more: DemandPanel now calls it ONLY over demand the real
// sift hasn't placed yet (no provisionalSlotId), and treats it purely as a
// what-if suggestion tool — "if you add this time, this many more people
// could fit" — never as a claim about who's actually seated. Ground truth
// for who's seated comes from tee_waitlist.provisionalSlotId directly. See
// docs/handoff/2026-08-24-match-waitlist-stale-rows.md for the full context.
//
// Original description of the retired engine's rules, left for reference —
// this function's own logic below still mirrors it, since it's a reasonable
// heuristic for suggestions even though it's no longer the real algorithm:
// Client-side projection of the REAL matching engine (matching.sql —
// match_waitlist): foursomes are formed by SBX proximity, not signup order.
// Per open time (earliest first): seed with the longest-waiting eligible
// golfer — priority (Sandbox+) list first — then seat the 3 closest-SBX
// golfers, preferring their integer SBX band (the engine widens ±1.000/hr
// for solos, ±1.000/30min for teams) and relaxing beyond it only when the
// band can't fill the time (the same way the T-3h sweep opens bands fully).
// Slots the course booked directly (blocked) are skipped — the "bump" path.
function fillSchedule(slots, entries, blockedIds) {
  const open = (slots || [])
    .filter(s => s.status === 'open')
    .slice()
    .sort((a, b) => new Date(a.starts_at) - new Date(b.starts_at));
  const seats = {}; open.forEach(s => { seats[s.id] = []; });
  const pool = [...(entries || [])];
  const sbxOf = (e) => (e.sbx != null ? e.sbx : 4);

  for (const slot of open) {
    if (blockedIds.has(slot.id)) continue;
    const m = minutesOfDay(slot.starts_at);
    const group = [];
    while (group.length < 4) {
      const eligible = pool.filter(e => m >= e.startMin && m <= e.endMin);
      if (!eligible.length) break;
      let pick;
      if (!group.length) {
        // Seed: priority list first, then longest waiting (ready_at order).
        pick = eligible.sort((a, b) =>
          ((b.priority ? 1 : 0) - (a.priority ? 1 : 0)) || (a.createdAt - b.createdAt))[0];
      } else {
        // Fill: closest SBX to the group's average — in-band (≤1.0) first,
        // mirroring attempt_pair's `abs(sbx diff) <= 1.0 order by diff`.
        const anchor = group.reduce((s, e) => s + sbxOf(e), 0) / group.length;
        const ranked = eligible.slice().sort((a, b) =>
          (Math.abs(sbxOf(a) - anchor) - Math.abs(sbxOf(b) - anchor)) || (a.createdAt - b.createdAt));
        pick = ranked.find(e => Math.abs(sbxOf(e) - anchor) <= 1.0) || ranked[0];
      }
      group.push(pick);
      pool.splice(pool.indexOf(pick), 1);
    }
    // This simulation only ever proposes a full foursome — it's a "what
    // could seat if you add a time" suggestion tool, not the real engine
    // (which can lock in a trio or a pair at T-1h; see the header comment
    // above). Anything smaller than 4 goes back in the pool: those golfers
    // can still combine at a later time they share, and otherwise show as
    // "not yet pencilled" — which, for real golfers, is genuinely accurate,
    // since this only ever runs over people the real engine hasn't placed.
    if (group.length === 4) seats[slot.id] = group;
    else pool.push(...group);
  }

  // Split the leftovers: golfers with NO open time inside their window
  // (adding a time would seat them → drives the suggestions) vs golfers
  // who have a time available but not enough companions yet.
  const hasTime = (e) => open.some(s => {
    if (blockedIds.has(s.id)) return false;
    const m = minutesOfDay(s.starts_at);
    return m >= e.startMin && m <= e.endMin;
  });
  const noFit = pool.filter(e => !hasTime(e));
  const waitingMore = pool.filter(e => hasTime(e));
  return { seats, unassigned: pool, noFit, waitingMore };
}

// Times worth ADDING: half-hour marks inside the unassigned golfers'
// windows that aren't live yet, ranked by how many they'd seat (max 4).
function suggestTimes(unassigned, liveHMs) {
  const score = {};
  for (const e of (unassigned || [])) {
    for (let m = Math.ceil(e.startMin / 30) * 30; m <= e.endMin; m += 30) {
      const hm = `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
      if (liveHMs.has(hm)) continue;
      score[hm] = (score[hm] || 0) + 1;
    }
  }
  return Object.entries(score)
    .map(([hm, n]) => ({ hm, fills: Math.min(4, n) }))
    .sort((a, b) => b.fills - a.fills || toMin(a.hm) - toMin(b.hm))
    .slice(0, 3);
}

// Single-value slider with the same circular thumb + forest track as the
// range slider below, so every slider on the page matches.
function SingleSlider({ min, max, step, value, onChange }) {
  const trackRef = React.useRef(null);
  const [drag, setDrag] = React.useState(false);
  const pct = ((value - min) / (max - min)) * 100;
  const valFromX = (clientX) => {
    const r = trackRef.current.getBoundingClientRect();
    const f = Math.min(1, Math.max(0, (clientX - r.left) / r.width));
    return Math.min(max, Math.max(min, Math.round((min + f * (max - min)) / step) * step));
  };
  React.useEffect(() => {
    if (!drag) return undefined;
    const move = (e) => onChange(valFromX(e.clientX));
    const end = () => setDrag(false);
    window.addEventListener('mousemove', move);
    window.addEventListener('mouseup', end);
    return () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', end); };
  }, [drag]);
  return (
    <div ref={trackRef} onMouseDown={(e) => { onChange(valFromX(e.clientX)); setDrag(true); }}
      style={{ position: 'relative', height: 28, cursor: 'pointer' }}>
      <div style={{ position: 'absolute', top: '50%', left: 0, right: 0, height: 5, borderRadius: 99, background: 'rgba(234,226,206,0.18)', transform: 'translateY(-50%)' }}/>
      <div style={{ position: 'absolute', top: '50%', left: 0, height: 5, borderRadius: 99, background: 'var(--cream)', transform: 'translateY(-50%)', width: `${pct}%` }}/>
      <div onMouseDown={(e) => { e.stopPropagation(); setDrag(true); }} style={{
        position: 'absolute', top: '50%', left: `${pct}%`, transform: 'translate(-50%, -50%)',
        width: 22, height: 22, borderRadius: 999, background: 'var(--forest)',
        border: '2.5px solid var(--cream)', boxShadow: 'var(--shadow-sm)', cursor: 'grab', zIndex: 2,
      }}/>
    </div>
  );
}

// Dual-range slider (desktop) — pick the hour range the grid shows.
function TimeRangeSlider({ min, max, step, value, onChange }) {
  const trackRef = React.useRef(null);
  const valueRef = React.useRef(value); valueRef.current = value;
  const [drag, setDrag] = React.useState(null);
  const pct = (v) => ((v - min) / (max - min)) * 100;
  const valFromX = (clientX) => {
    const r = trackRef.current.getBoundingClientRect();
    const f = Math.min(1, Math.max(0, (clientX - r.left) / r.width));
    return Math.min(max, Math.max(min, Math.round((min + f * (max - min)) / step) * step));
  };
  function onTrackDown(e) {
    const v = valFromX(e.clientX);
    const [a, b] = valueRef.current;
    const i = Math.abs(v - a) <= Math.abs(v - b) ? 0 : 1;
    if (i === 0) onChange([Math.min(v, b - step), b]); else onChange([a, Math.max(v, a + step)]);
    setDrag(i);
  }
  React.useEffect(() => {
    if (drag == null) return undefined;
    const move = (e) => {
      const v = valFromX(e.clientX);
      const [a, b] = valueRef.current;
      if (drag === 0) onChange([Math.min(v, b - step), b]);
      else onChange([a, Math.max(v, a + step)]);
    };
    const end = () => setDrag(null);
    window.addEventListener('mousemove', move);
    window.addEventListener('mouseup', end);
    return () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', end); };
  }, [drag]);
  const thumb = (i) => (
    <div onMouseDown={(e) => { e.stopPropagation(); setDrag(i); }} style={{
      position: 'absolute', top: '50%', left: `${pct(value[i])}%`, transform: 'translate(-50%, -50%)',
      width: 22, height: 22, borderRadius: 999, background: 'var(--paper)',
      border: '2.5px solid var(--forest)', boxShadow: 'var(--shadow-sm)', cursor: 'grab', zIndex: 2,
    }}/>
  );
  return (
    <div ref={trackRef} onMouseDown={onTrackDown} style={{ position: 'relative', height: 28, cursor: 'pointer' }}>
      <div style={{ position: 'absolute', top: '50%', left: 0, right: 0, height: 5, borderRadius: 99, background: 'rgba(28,73,42,0.14)', transform: 'translateY(-50%)' }}/>
      <div style={{ position: 'absolute', top: '50%', height: 5, borderRadius: 99, background: 'var(--forest)', transform: 'translateY(-50%)', left: `${pct(value[0])}%`, width: `${pct(value[1]) - pct(value[0])}%` }}/>
      {thumb(0)}{thumb(1)}
    </div>
  );
}

// Multi-window allowed-time slider. Each window is a [start,end] pair on
// one timeline; only the ACTIVE window's circles adjust (the manager picks
// which by tapping its "Window N" label above the track).
function MultiWindowSlider({ min, max, step, windows, active, onChange, onSelectActive }) {
  const trackRef = React.useRef(null);
  const winRef = React.useRef(windows); winRef.current = windows;
  const [drag, setDrag] = React.useState(null); // { win, edge } edge 0=start 1=end
  const pct = (v) => ((v - min) / (max - min)) * 100;
  const valFromX = (clientX) => {
    const r = trackRef.current.getBoundingClientRect();
    const f = Math.min(1, Math.max(0, (clientX - r.left) / r.width));
    return Math.min(max, Math.max(min, Math.round((min + f * (max - min)) / step) * step));
  };
  function setEdge(win, edge, v) {
    onChange(winRef.current.map((w, i) => {
      if (i !== win) return w;
      return edge === 0 ? [Math.min(v, w[1] - step), w[1]] : [w[0], Math.max(v, w[0] + step)];
    }));
  }
  React.useEffect(() => {
    if (!drag) return undefined;
    const move = (e) => {
      const v = valFromX(e.clientX);
      if (drag.edge === 'whole') {
        const [a, b] = drag.origWin; const width = b - a;
        let ns = a + (v - drag.grabVal);
        ns = Math.min(max - width, Math.max(min, Math.round(ns / step) * step));
        onChange(winRef.current.map((w, i) => i === drag.win ? [ns, ns + width] : w));
      } else {
        setEdge(drag.win, drag.edge, v);
      }
    };
    const end = () => setDrag(null);
    window.addEventListener('mousemove', move);
    window.addEventListener('mouseup', end);
    return () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', end); };
  }, [drag]);
  // Grabbing a circle activates its window and starts dragging that edge.
  function grab(win, edge, e) {
    e.stopPropagation();
    if (win !== active) onSelectActive(win);
    setDrag({ win, edge });
  }
  // Dragging a Window N label moves the WHOLE window (both ends together).
  function grabWhole(win, e) {
    e.stopPropagation();
    if (win !== active) onSelectActive(win);
    setDrag({ win, edge: 'whole', grabVal: valFromX(e.clientX), origWin: winRef.current[win].slice() });
  }
  // Tapping the track: select the window under/nearest the click; if it's
  // already active, start dragging its closer edge.
  function onTrackDown(e) {
    const v = valFromX(e.clientX);
    let idx = windows.findIndex(([a, b]) => v >= a && v <= b);
    if (idx === -1) { let bd = Infinity; windows.forEach(([a, b], i) => { const d = v < a ? a - v : v - b; if (d < bd) { bd = d; idx = i; } }); }
    if (idx !== active) { onSelectActive(idx); return; }
    const [a, b] = windows[idx];
    const edge = Math.abs(v - a) <= Math.abs(v - b) ? 0 : 1;
    setEdge(idx, edge, v);
    setDrag({ win: idx, edge });
  }
  const COLORS = ['var(--cream)', 'var(--moss-light)', 'var(--sand)', 'var(--clay, #C98A4E)'];
  return (
    <div>
      {/* Window labels — tap to choose which window you're adjusting */}
      <div style={{ position: 'relative', height: 24, marginBottom: 6 }}>
        {windows.map((w, i) => {
          const center = (pct(w[0]) + pct(w[1])) / 2;
          const on = i === active;
          const c = COLORS[i % COLORS.length];
          return (
            <button key={i} onMouseDown={(e) => grabWhole(i, e)} title="Drag to move the whole window" style={{
              position: 'absolute', left: `${center}%`, transform: 'translateX(-50%)', top: 0,
              padding: '3px 9px', borderRadius: 999, whiteSpace: 'nowrap', cursor: drag && drag.win === i && drag.edge === 'whole' ? 'grabbing' : 'grab',
              fontFamily: 'var(--font-mono)', fontSize: 9, fontWeight: 700, letterSpacing: '0.05em', textTransform: 'uppercase',
              border: on ? 'none' : `1px solid ${c}`,
              background: on ? c : 'transparent', color: on ? 'var(--forest-dark)' : c,
            }}>Window {i + 1}</button>
          );
        })}
      </div>
      <div ref={trackRef} onMouseDown={onTrackDown} style={{ position: 'relative', height: 28, cursor: 'pointer' }}>
        <div style={{ position: 'absolute', top: '50%', left: 0, right: 0, height: 5, borderRadius: 99, background: 'rgba(234,226,206,0.18)', transform: 'translateY(-50%)' }}/>
        {windows.map((w, i) => {
          const on = i === active;
          const c = COLORS[i % COLORS.length];
          return (
            <React.Fragment key={i}>
              <div style={{ position: 'absolute', top: '50%', height: on ? 6 : 5, borderRadius: 99, background: c, opacity: on ? 1 : 0.5, transform: 'translateY(-50%)', left: `${pct(w[0])}%`, width: `${Math.max(0, pct(w[1]) - pct(w[0]))}%`, transition: 'opacity 0.2s ease, height 0.2s ease' }}/>
              {[0, 1].map(edge => (
                <div key={edge} onMouseDown={(e) => grab(i, edge, e)} style={{
                  position: 'absolute', top: '50%', left: `${pct(w[edge])}%`, transform: 'translate(-50%, -50%)',
                  width: on ? 22 : 15, height: on ? 22 : 15, borderRadius: 999, background: 'var(--forest-dark)',
                  border: `2.5px solid ${c}`, boxShadow: on ? 'var(--shadow-sm)' : 'none', cursor: 'grab',
                  opacity: on ? 1 : 0.65, zIndex: on ? 3 : 2, transition: 'width 0.15s ease, height 0.15s ease',
                }}/>
              ))}
            </React.Fragment>
          );
        })}
      </div>
    </div>
  );
}

// Light-switch pill — a two-option toggle with a springy sliding knob
// (the "gooey" feel comes from the overshoot easing).
function PillSwitch({ options, value, onChange, width = 168 }) {
  const n = options.length;
  const idx = Math.max(0, options.findIndex(o => o.key === value));
  return (
    <div style={{ position: 'relative', display: 'flex', background: 'rgba(234,226,206,0.14)', borderRadius: 999, padding: 4, width, boxSizing: 'border-box' }}>
      <div style={{ position: 'absolute', top: 4, bottom: 4, left: 4, width: `calc((100% - 8px) / ${n})`, transform: `translateX(${idx * 100}%)`, background: 'var(--cream)', borderRadius: 999, boxShadow: 'var(--shadow-sm)', transition: 'transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)' }}/>
      {options.map(o => (
        <button key={o.key} onClick={() => onChange(o.key)} style={{
          position: 'relative', zIndex: 1, flex: 1, border: 'none', background: 'transparent', cursor: 'pointer',
          padding: '7px 6px', borderRadius: 999, fontFamily: 'var(--font-mono)', fontSize: 10.5, fontWeight: 800, letterSpacing: '0.03em', textTransform: 'uppercase',
          color: value === o.key ? 'var(--forest)' : 'var(--paper)', transition: 'color 0.2s ease', whiteSpace: 'nowrap',
        }}>{o.label}</button>
      ))}
    </div>
  );
}

// When a group stops being movable.
//
// Play is bucketed into half-hour windows: a golfer the sift puts at 5:35
// is playing the 5:30–6:00 window. The lock is an hour before that
// WINDOW opens, not an hour before their own tee time — so 5:35 locks at
// 4:30, the same moment as everyone else in that window, rather than at
// 4:35. That's what makes a window lock as one unit: the whole field is
// told their exact time together, and from then on the match is set and
// re-sorting isn't the course's call.
//
// A time with nobody on it is never locked — there's no one to move.
const LOCK_LEAD_MS = 3600e3;
const PLAY_WINDOW_MIN = 30;
const PLAY_WINDOW_MS = PLAY_WINDOW_MIN * 60000;
// Floored on the RAW epoch, deliberately — this mirrors the golfer app's
// floor_to_30min() exactly (their migration 20260826030000), and the two
// have to agree to the millisecond or a manager gets offered a bump on a
// group the engine has already locked.
//
// Flooring local clock minutes instead would match only where the timezone
// offset is a whole number of half-hours. Miami always is, but the browser
// decides this, not the course — a manager on India time (+5:30) or Nepal
// (+5:45) would compute a different boundary from the server. Epoch
// flooring has no such dependency.
const playWindowStart = (iso) => Math.floor(new Date(iso).getTime() / PLAY_WINDOW_MS) * PLAY_WINDOW_MS;
const slotLocked = (slot) => !!slot && Date.now() >= playWindowStart(slot.starts_at) - LOCK_LEAD_MS;

// One finished board as a sentence: "R. Piacenti def. B. Young 3&2".
// Names come from the tee time's own roster, so nothing extra is fetched —
// a seat we can't name falls back to "Player" rather than a raw uuid.
function boardLine(m, roster) {
  const nameOf = (id) => {
    const p = (roster || []).find(x => x.id === id);
    return p ? playerName(p) : 'Player';
  };
  const teamA = [m.player_a, m.player_a2].filter(Boolean).map(nameOf).join(' & ');
  const teamB = [m.player_b, m.player_b2].filter(Boolean).map(nameOf).join(' & ');
  if (m.status !== 'completed') {
    return `${teamA} v ${teamB} — ${m.status === 'active' ? 'still playing' : m.status}`;
  }
  if (m.result === 'H') return `${teamA} halved with ${teamB}`;
  const margin = m.final_margin ? ` ${m.final_margin}` : '';
  if (m.result === 'A') return `${teamA} def. ${teamB}${margin}`;
  if (m.result === 'B') return `${teamB} def. ${teamA}${margin}`;
  return `${teamA} v ${teamB} — no result recorded`;
}
// "the 5:30 window" — the same boundary as above, rendered in the course's
// local clock for reading.
const playWindowLabel = (iso) => timeLabel(new Date(playWindowStart(iso)).toISOString());

// One time in the Live Tee Times grid: booked players layered above the
// card, tap to disable (a booked foursome bumps to the next open slot).
// State recipe: Open reads quiet (outline only), Filling picks up a faint
// forest tint, Booked is the one state allowed to read strong (solid
// forest), Disabled/Bumped is muted red. Hover swaps the native title
// tooltip for a small popover — the roster on a filled slot, or the tap
// hint on an empty one.
function LiveTimeCard({ t, slot, players, fillSeats, disabled, blockedDirect, play, onToggle }) {
  const real = (players || []).filter(p => p.status !== 'cancelled');
  const seats = [
    ...real.map(p => ({ kind: 'real', key: p.id, p })),
    ...(fillSeats || []).map(e => ({ kind: 'fill', key: e.id, e })),
  ].slice(0, 4);
  const booked = seats.length >= 4;
  const filling = seats.length > 0 && !booked;
  const status = blockedDirect ? 'Bumped' : booked ? 'Booked' : filling ? `${seats.length}/4 Filling` : 'Open';
  const [hover, setHover] = React.useState(false);
  const [focusIn, setFocusIn] = React.useState(false);
  const [info, setInfo] = React.useState(false);
  const names = seats.map(s => s.kind === 'real' ? playerName(s.p) : s.e.user.name);
  // The controls are hover-revealed, but a keyboard user never hovers — so
  // focus inside the card counts as well, otherwise they are unreachable.
  const showControls = hover || focusIn || info;
  const locked = slotLocked(slot);
  // Already teed off. A separate idea from `locked`: locked means the match
  // is set and can't be re-sorted, played means the round has begun or is
  // over and there is nothing to re-sort at all. Both block the ✕, but they
  // are different sentences and the preview shows different things.
  const started = !!slot && Date.now() >= new Date(slot.starts_at).getTime();
  const finishedAt = play && play.completedAt;

  // The preview is pinned to the VIEWPORT, not to the card. The grid these
  // cards sit in is a scroll container ~118px per column, so a 246px panel
  // positioned inside it gets clipped on the first and last column — which
  // is most of the time. Fixed positioning escapes the clip; the trade is
  // that it has to be re-measured whenever anything moves under it.
  const wrapRef = React.useRef(null);
  const [pos, setPos] = React.useState(null);
  React.useLayoutEffect(() => {
    if (!info || !wrapRef.current) { setPos(null); return undefined; }
    const W = 246;
    const place = () => {
      const el = wrapRef.current;
      if (!el) return;
      const r = el.getBoundingClientRect();
      const left = Math.min(Math.max(8, r.left + r.width / 2 - W / 2), window.innerWidth - W - 8);
      // Above by default; below when there isn't room above for it.
      const above = r.top > 300;
      setPos(above
        ? { left, bottom: window.innerHeight - r.top + 10, width: W }
        : { left, top: r.bottom + 10, width: W });
    };
    place();
    window.addEventListener('resize', place);
    window.addEventListener('scroll', place, true);
    return () => { window.removeEventListener('resize', place); window.removeEventListener('scroll', place, true); };
  }, [info]);

  // Who ARRIVED together — the thing that decides whether closing this time
  // splits up friends. Everyone on a tee time plays together regardless.
  //
  // partySize comes from the golfer app's own party record, not inferred.
  // match_type is deliberately NOT used for this: it is the FORMAT a group
  // ends up playing, not who booked with whom. Three strangers the sift
  // seats together also play '1v1v1', and three friends who pick up a
  // fourth become '2v2' — reading a party off it is wrong both ways.
  //
  // A party only gets a slot_id once locked, so absence means "not
  // recorded", never "booked alone". An unpaired seat is therefore left
  // UNMARKED unless it explicitly says it's still looking (needs_partner):
  // stamping "solo" on three friends is the one error that would push a
  // manager to split a party.
  const realIds = new Set(real.map(p => p.id));
  const PARTY_WORD = { 2: 'PAIR', 3: 'TRIO', 4: 'GROUP' };
  const seatGroupLabel = (p) => {
    if (p.partySize >= 2) return PARTY_WORD[Math.min(4, p.partySize)];
    if (p.matchType === '2v2' && p.partnerId && realIds.has(p.partnerId)) return 'PAIR';
    if (p.needsPartner) return 'SOLO';
    return null;
  };
  // Distinct booked parties on this time, largest first.
  const partySizes = [...new Map(
    real.filter(p => p.partyId && p.partySize >= 2).map(p => [p.partyId, p.partySize])
  ).values()].sort((a, b) => b - a);
  const pairSeats = real.filter(p => !p.partyId && p.matchType === '2v2' && p.partnerId && realIds.has(p.partnerId)).length;
  const unknownSeats = real.filter(p => seatGroupLabel(p) === null).length;
  const format = (real.find(p => p.matchType) || {}).matchType || null;
  const memberCount = real.filter(p => normalTier(p.tier) === 'plus').length;

  const recipe = disabled
    ? { bg: 'rgba(155,58,46,0.14)', border: '1px solid rgba(231,184,167,0.4)', color: 'var(--loss-soft)' }
    : booked
      ? { bg: 'var(--cream)', border: '1px solid var(--cream)', color: 'var(--forest)' }
      : filling
        ? { bg: 'rgba(234,226,206,0.07)', border: '1px solid rgba(234,226,206,0.24)', color: 'var(--paper)' }
        : { bg: 'var(--surface)', border: '1px solid var(--line-strong)', color: 'var(--ink-soft)' };

  // Every card is the same size: the time never wraps, the status sits on its
  // own line, and the bottom strip is always reserved (avatars or not) so
  // spacing stays uniform whether or not a time is booked.
  const corner = (side) => ({
    position: 'absolute', top: -8, [side]: -8, zIndex: 4,
    width: 22, height: 22, borderRadius: 999, padding: 0,
    display: 'flex', alignItems: 'center', justifyContent: 'center',
    fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 800, lineHeight: 1,
    cursor: 'pointer', border: '1px solid var(--line-strong)',
    background: 'var(--forest-dark)', color: 'var(--paper)',
    boxShadow: 'var(--shadow-xs)',
    opacity: showControls ? 1 : 0,
    pointerEvents: showControls ? 'auto' : 'none',
    transition: 'opacity var(--dur-fast) var(--ease)',
  });

  return (
    <div ref={wrapRef} style={{ position: 'relative' }}
      onMouseEnter={() => setHover(true)}
      // Closing the preview on the way out keeps two cards from leaving two
      // panels floating over the grid at once. A keyboard user holding
      // focus inside keeps theirs.
      onMouseLeave={() => { setHover(false); if (!focusIn) setInfo(false); }}
      onFocus={() => setFocusIn(true)}
      onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) { setFocusIn(false); setInfo(false); } }}>
      <div style={{
        width: '100%', boxSizing: 'border-box', borderRadius: 'var(--r-xs)', textAlign: 'left',
        padding: '7px 12px 9px', border: recipe.border, background: recipe.bg, color: recipe.color,
        display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 1,
        transform: showControls ? 'translateY(-1px)' : 'none', boxShadow: showControls ? 'var(--shadow-xs)' : 'none',
        transition: 'transform var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease)',
      }}>
        <span style={{ fontFamily: 'var(--font-mono)', fontWeight: 800, fontSize: 13.5, whiteSpace: 'nowrap', textDecoration: disabled ? 'line-through' : 'none' }}>{hmLabel(toMin(t))}</span>
        <span style={{ fontSize: 8.5, fontFamily: 'var(--font-mono)', fontWeight: 700, letterSpacing: '0.05em', textTransform: 'uppercase', opacity: booked || disabled ? 0.85 : 0.65 }}>{disabled ? 'Off' : status}</span>
      </div>

      {/* ⓘ — what's actually on this tee time */}
      <button type="button" style={corner('left')}
        aria-label={`Preview the ${hmLabel(toMin(t))} tee time`}
        aria-expanded={info}
        onClick={() => setInfo(v => !v)}>i</button>

      {/* ✕ — close this time (or reopen it). Re-sorting real players is
          gated at T-1h, when the lock has already formed the group. */}
      <button type="button"
        style={{
          ...corner('right'),
          ...(disabled
            ? { background: 'var(--cream)', color: 'var(--forest)', border: '1px solid var(--cream)' }
            : {}),
          ...((locked || started) && seats.length && !disabled
            ? { opacity: showControls ? 0.4 : 0, cursor: 'not-allowed' }
            : {}),
        }}
        aria-label={disabled ? `Reopen the ${hmLabel(toMin(t))} tee time` : `Close the ${hmLabel(toMin(t))} tee time`}
        title={disabled
          ? 'Reopen this tee time'
          : started && seats.length
            ? (finishedAt ? 'Already played — nothing to move' : 'Already under way — nothing to move')
            : locked && seats.length
              ? `Too late to re-sort — the ${slot ? playWindowLabel(slot.starts_at) : ''} window locked an hour before it opened`
              : seats.length ? 'Close this tee time and re-sort its players' : 'Close this tee time'}
        onClick={() => onToggle(seats, locked || started)}>
        {disabled ? '↺' : '✕'}
      </button>
      {/* Foursome avatars straddle the BOTTOM edge (space reserved above) */}
      {seats.length > 0 && (
        <div style={{ position: 'absolute', left: 11, bottom: 0, transform: 'translateY(50%)', display: 'flex', zIndex: 2, pointerEvents: 'none' }}>
          {seats.map((s, i) => (
            <div key={s.key} style={{ marginLeft: i ? -8 : 0 }}>
              {s.kind === 'real' ? <Avatar player={s.p}/> : (
                s.e.user.avatar_url ? (
                  <img src={s.e.user.avatar_url} alt={s.e.user.name} style={{ width: 24, height: 24, borderRadius: 999, objectFit: 'cover', border: '2px solid var(--paper)', background: '#ddd' }}/>
                ) : (
                  <div style={{ width: 24, height: 24, borderRadius: 999, border: '2px solid var(--paper)', background: s.e.priority ? 'var(--cream)' : 'rgba(234,226,206,0.22)', color: 'var(--forest)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 9, fontWeight: 800 }}>{s.e.user.initials}</div>
                )
              )}
            </div>
          ))}
        </div>
      )}
      {/* Light hover tooltip — only while the full preview is closed, so the
          two never stack on top of each other in the same spot. */}
      {hover && !info && (
        <div className="fade-in" style={{
          position: 'absolute', bottom: '100%', left: '50%', transform: 'translateX(-50%)', marginBottom: 9,
          background: 'var(--forest-dark)', color: 'var(--paper)', padding: '7px 11px', borderRadius: 'var(--r-xs)',
          fontSize: 11, fontWeight: 600, whiteSpace: 'nowrap', zIndex: 10, boxShadow: 'var(--shadow-float)', pointerEvents: 'none',
        }}>
          {names.length ? names.join(', ') : disabled ? 'Closed' : 'Open — nobody on it yet'}
        </div>
      )}

      {/* ⓘ preview — the roster, how they got here, and who's a member */}
      {info && pos && (
        <div className="fade-in" style={{
          position: 'fixed', ...pos,
          background: 'var(--forest-dark)', color: 'var(--paper)',
          borderRadius: 'var(--r-sm)', border: '1px solid var(--line-strong)',
          padding: '12px 13px', zIndex: 60, boxShadow: 'var(--shadow-float)', textAlign: 'left',
        }}>
          <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8 }}>
            <span style={{ fontFamily: 'var(--font-mono)', fontWeight: 800, fontSize: 13 }}>{hmLabel(toMin(t))}</span>
            <span style={{ fontSize: 9, fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.05em', opacity: 0.7 }}>
              {disabled ? 'Closed' : status}
            </span>
          </div>

          {seats.length === 0 ? (
            <div style={{ fontSize: 11.5, opacity: 0.7, marginTop: 8, lineHeight: 1.5 }}>
              Nobody on this tee time yet.
            </div>
          ) : (
            <div style={{ marginTop: 9, display: 'flex', flexDirection: 'column', gap: 7 }}>
              {seats.map(s => {
                const isReal = s.kind === 'real';
                const nm = isReal ? playerName(s.p) : s.e.user.name;
                const plus = isReal ? normalTier(s.p.tier) === 'plus' : !!s.e.priority;
                const groupWord = isReal ? seatGroupLabel(s.p) : null;
                return (
                  <div key={s.key} style={{ display: 'flex', alignItems: 'center', gap: 7, fontSize: 11.5 }}>
                    <span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                      {nm}
                      {isReal && s.p.handle && (
                        <span style={{ opacity: 0.5 }}> @{String(s.p.handle).replace(/^@/, '')}</span>
                      )}
                    </span>
                    {plus && (
                      <span className="pill-mono" style={{ fontSize: 8, padding: '2px 6px', background: 'var(--cream)', color: 'var(--forest)', border: 'none' }}>SBX+</span>
                    )}
                    <span style={{ fontSize: 8.5, fontFamily: 'var(--font-mono)', opacity: 0.55, whiteSpace: 'nowrap' }}>
                      {isReal ? groupWord : 'PROJECTED'}
                    </span>
                  </div>
                );
              })}
            </div>
          )}

          {seats.length > 0 && (
            <div style={{ fontSize: 10.5, opacity: 0.6, marginTop: 10, lineHeight: 1.5, borderTop: '1px solid var(--line-strong)', paddingTop: 8 }}>
              {(() => {
                const bits = [];
                const word = { 2: 'A pair', 3: 'A threesome', 4: 'A foursome' };
                partySizes.forEach(n => bits.push(`${word[Math.min(4, n)]} booked together — keep them together.`));
                if (pairSeats >= 2) {
                  const pairs = pairSeats / 2;
                  bits.push(`${pairs === 1 ? 'A pair' : `${pairs} pairs`} booked together.`);
                }
                // Never claim "solo" for a seat we can't actually check.
                if (unknownSeats >= 2) bits.push(`${unknownSeats} seats have no party on record.`);
                else if (unknownSeats === 1) bits.push('One seat has no party on record.');
                if (!bits.length && !partySizes.length && !pairSeats) bits.push('Everyone here signed up on their own.');
                return bits.join(' ');
              })()}
              {format && ` Format: ${format}.`}
              {memberCount > 0 && ` ${memberCount} SBX+ member${memberCount === 1 ? '' : 's'}.`}
              {real.length === 0 && ' These seats are a projection, not real bookings.'}
            </div>
          )}

          {/* Played, or playing — what happened rather than who might move */}
          {started && play && play.boards.length > 0 && (
            <div style={{ marginTop: 10, borderTop: '1px solid var(--line-strong)', paddingTop: 8 }}>
              <div className="eyebrow" style={{ fontSize: 8.5, marginBottom: 6 }}>
                {finishedAt ? 'Round' : 'In progress'}
              </div>
              <div style={{ fontSize: 10.5, fontFamily: 'var(--font-mono)', opacity: 0.8 }}>
                {play.startedAt ? timeLabel(play.startedAt) : '—'}
                {finishedAt ? ` → ${timeLabel(finishedAt)}` : ' → still out'}
                {play.durationMin != null && ` · ${Math.floor(play.durationMin / 60)}h ${String(play.durationMin % 60).padStart(2, '0')}m`}
              </div>
              <div style={{ marginTop: 7, display: 'flex', flexDirection: 'column', gap: 4 }}>
                {play.boards.map(b => (
                  <div key={b.id} style={{ fontSize: 10.5, opacity: 0.85, lineHeight: 1.45 }}>
                    {boardLine(b, real)}
                  </div>
                ))}
              </div>
            </div>
          )}

          {started && seats.length > 0 && !(play && play.boards.length) && (
            <div style={{ fontSize: 10.5, opacity: 0.6, marginTop: 8, lineHeight: 1.5 }}>
              This time has passed, but no match was ever recorded against it.
            </div>
          )}

          {!started && locked && seats.length > 0 && (
            <div style={{ fontSize: 10.5, color: 'var(--loss-soft, #E7B8A7)', marginTop: 8, lineHeight: 1.5 }}>
              Locked — the {slot ? playWindowLabel(slot.starts_at) : ''} window closed to changes an hour
              before it opened. These players have been told their exact time.
            </div>
          )}
        </div>
      )}
    </div>
  );
}

// ─── SuggestedPriceCallout ───────────────────────────────────────────────────
// Sits under the Price Per Golfer slider and answers the question the slider
// itself cannot: what should this actually be? Sandbox's recommendation is a
// share of what the course already charges the public in that window, so the
// callout has two states.
//
// With a rate on file it is a pill showing the number, and tapping it moves
// the slider there — a recommendation you have to copy across by hand is one
// most people will ignore. Without one it asks for the rate instead of
// guessing, because a suggestion whose stated basis is "40% of your rate"
// cannot be shown next to a rate we do not have.
//
// The rate stays editable either way: courses run seasonal and weekday /
// weekend pricing, so this is a number that moves.
function SuggestedPriceCallout({ course, price, onApply }) {
  const [rack, setRack] = useRackRate(course);
  const [editing, setEditing] = React.useState(false);
  const [draft, setDraft] = React.useState('');
  const inputRef = React.useRef(null);

  React.useEffect(() => { if (editing && inputRef.current) inputRef.current.focus(); }, [editing]);

  const open = () => { setDraft(rack != null ? String(rack) : ''); setEditing(true); };
  const commit = () => {
    const n = Number(draft);
    setRack(Number.isFinite(n) && n > 0 ? Math.round(n) : 0);
    setEditing(false);
  };

  const pct = Math.round(TWILIGHT_SHARE * 100);
  const wrap = {
    display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap',
    marginTop: 10, padding: '9px 11px', borderRadius: 'var(--r-sm)',
    background: 'var(--surface-sunken)', border: '1px solid var(--line-soft)',
  };
  const note = { fontSize: 11.5, lineHeight: 1.35, color: 'var(--ink-muted)' };
  const linkBtn = {
    background: 'none', border: 'none', padding: 0, font: 'inherit',
    fontSize: 11.5, color: 'var(--ink-muted)', textDecoration: 'underline', cursor: 'pointer',
  };

  // The price column is narrow (min 190px), so the editor stacks rather than
  // sitting on one line, and Cancel is the same text link as "Edit rate"
  // instead of a second button that would wrap on its own.
  if (editing) {
    return (
      <div style={{ ...wrap, display: 'block' }}>
        <label style={{ ...note, fontWeight: 700, color: 'var(--ink)', display: 'block' }} htmlFor="rack-rate">
          Your normal rate
        </label>
        <div style={{ display: 'flex', alignItems: 'center', gap: 6, margin: '6px 0 7px', flexWrap: 'wrap' }}>
          <span style={{ ...note, fontFamily: 'var(--font-mono)' }}>$</span>
          <input
            id="rack-rate" ref={inputRef} className="input" type="number" min="0" max="999"
            value={draft} placeholder="55"
            onChange={e => setDraft(e.target.value)}
            onKeyDown={e => {
              if (e.key === 'Enter') { e.preventDefault(); commit(); }
              if (e.key === 'Escape') { e.preventDefault(); setEditing(false); }
            }}
            style={{ width: 64, padding: '5px 8px', fontSize: 13, fontFamily: 'var(--font-mono)' }}/>
          <button className="pill-mono suggest-pill" onClick={commit}>Save</button>
          <button onClick={() => setEditing(false)} style={linkBtn}>Cancel</button>
        </div>
        <div style={note}>
          What a walk-up pays you for this window on a normal day. Only used to work out the suggestion.
        </div>
      </div>
    );
  }

  if (rack == null) {
    return (
      <div style={wrap}>
        <button className="btn btn-ghost" onClick={open} style={{ padding: '5px 12px', fontSize: 12, fontWeight: 700 }}>
          Add your normal rate
        </button>
        <div style={{ ...note, flex: '1 1 150px' }}>
          We suggest a price once we know it — we recommend about {pct}% of your normal tee time rate for this window.
        </div>
      </div>
    );
  }

  const s = suggestedPrice(course, null, null, rack);
  const atSuggestion = Number(price) === s.suggested;
  return (
    <div style={wrap}>
      <button
        onClick={() => onApply(s.suggested)}
        disabled={atSuggestion}
        title={atSuggestion ? "You're at the suggested price" : `Set the price to $${s.suggested}`}
        className="pill-mono suggest-pill">
        {atSuggestion ? `✓ Suggested $${s.suggested}` : `Suggested $${s.suggested}`}
      </button>
      <div style={{ ...note, flex: '1 1 150px' }}>
        About {pct}% of your ${rack} tee time rate for this window.{' '}
        <button onClick={open} style={linkBtn}>Edit rate</button>
      </div>
    </div>
  );
}

// ─── Tee times & pricing ─────────────────────────────────────────────────────
// Left: waitlist demand (requested windows, unmet demand, suggested times).
// Right: date strip · interval/price/cart · allowed-window slider · Save ·
// collapsible "Disable Specific Tee Times" blocklist grid · collapsible
// "Available tee times" with the waitlist auto-fill + bump.
//
// BLOCKLIST MODEL: every grid time inside the Allowed Tee Time Window is live
// by default; the course only marks the few exceptions it does NOT want (red).
// Save makes the day's schedule exactly window-minus-disabled — the window is
// authoritative, so live times outside it come down too (booked protected).
function TeeTimesPanel({ course }) {
  const [dateStr, setDateStr] = React.useState(todayStr());
  const [intervalMin, setIntervalMin] = React.useState(5);
  const [includesCart, setIncludesCart] = React.useState(false);
  const [price, setPrice] = React.useState(Math.min(75, course.suggested_price || 22));
  const [windows, setWindows] = React.useState([[16 * 60, 20 * 60]]); // allowed windows (twilight default)
  const [activeWin, setActiveWin] = React.useState(0);
  const [disabled, setDisabled] = React.useState(() => new Set()); // 'HH:MM' the course blocked (red)
  const [forced, setForced] = React.useState(() => new Set());     // 'HH:MM' added from demand suggestions
  const [blocked, setBlocked] = React.useState(() => new Set()); // slot ids tapped to block — PENDING until Save
  const [committedBlocked, setCommittedBlocked] = React.useState(() => new Set()); // blocks that have actually been saved → drive the bump
  const [timesOpen, setTimesOpen] = React.useState(true);        // collapsible live-times section
  const [gridOpen, setGridOpen] = React.useState(false);         // "Disable Specific Tee Times" — collapsed by default
  const [showFormula, setShowFormula] = React.useState(false);
  const [confirmClose, setConfirmClose] = React.useState(null); // { t, slot, seats }
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState('');
  const [msg, setMsg] = React.useState('');
  const [dirty, setDirty] = React.useState(false);              // has the manager touched a control since last save/day-change?
  const markDirty = () => { setDirty(true); setMsg(''); };

  const [slots, reload] = useDaySlots(course.id, dateStr);
  const [fields] = useDayFields(course.id, dateStr);
  const [dayPlay] = useDayPlay(course.id, dateStr);
  const [fill] = useCourseFillRate(course.id);
  const [realDemand] = useCourseWaitlist(course.id, dateStr);
  const ghostDemand = useGhostDemand(dateStr);
  // sandbox_lock forms a real booking straight from a tee_waitlist row and
  // doesn't clear the row it read — so a golfer who's actually booked at
  // THIS course keeps showing here as still waiting. Drop anyone who already
  // has a real seat here today before the panel (and fillSchedule's counts)
  // ever see them; ghost entries have no user.id, so this can't touch them.
  const bookedUserIds = React.useMemo(() => {
    const s = new Set();
    Object.values(fields || {}).forEach(players => (players || []).forEach(p => { if (p.id) s.add(p.id); }));
    return s;
  }, [fields]);
  // Same idea, cross-course: golfers_booked_elsewhere (PR #299, gated RPC —
  // see docs/handoff/2026-08-24-cross-course-conflict-rpc.md) flags anyone
  // sandbox_sift has already excluded for holding a seat at a DIFFERENT
  // course today. Retire those rows the same way — they're dead, not
  // "waiting" — rather than folding them into the pencilled/not-pencilled
  // distinction, which means something else entirely (still being worked vs.
  // done for good).
  const demand = React.useMemo(
    () => [...(realDemand || []), ...ghostDemand].filter(e =>
      !(e.user && e.user.id && (bookedUserIds.has(e.user.id) || e.bookedElsewhere))
    ),
    [realDemand, ghostDemand, bookedUserIds]
  );

  // Which nine golfers see for the day — auto-defaults to the nine Sandbox
  // measured (course_holes quadrants), overridable via the pill.
  const [dayHoles] = useDailyYardages(course.id, dateStr);
  const measuredF = (dayHoles || []).some(h => h.hole_number <= 9 && Array.isArray(h.quadrants) && h.quadrants.some(x => x != null && x !== ''));
  const measuredB = (dayHoles || []).some(h => h.hole_number > 9 && Array.isArray(h.quadrants) && h.quadrants.some(x => x != null && x !== ''));
  const [dayNine, setDayNineState] = React.useState('front');
  React.useEffect(() => {
    const stored = getDayNine(course.id, dateStr);
    setDayNineState(stored || (measuredB && !measuredF ? 'back' : 'front'));
  }, [dateStr, measuredF, measuredB]);
  function chooseNine(v) { setDayNineState(v); setDayNine(course.id, dateStr, v); }

  // Waitlist → open times projection (and what can't fit).
  // Bumps only take effect once SAVED — the projection (and the persisted
  // reassignments below) read committedBlocked, not the pending `blocked` set.
  //
  // Only run the projection over people sandbox_sift hasn't already placed.
  // A real waitlist row with provisionalSlotId set is ground truth — the
  // real engine already seated them — and re-simulating it here could
  // disagree (this projection doesn't replicate the real band-widening or
  // cross-course logic) and show a contradiction exactly like the one that
  // started this: someone real and pencilled, displayed as still waiting.
  // Ghost entries have no real placement to defer to, so they always run
  // through the simulation.
  const notYetPlaced = React.useMemo(
    () => (demand || []).filter(e => e.ghost || !e.provisionalSlotId),
    [demand]
  );
  const filled = React.useMemo(
    () => fillSchedule(slots, notYetPlaced, committedBlocked),
    [slots, notYetPlaced, committedBlocked]
  );
  // Which tee time a real, already-pencilled entry is actually sitting in —
  // sandbox_sift can place someone anywhere inside their consented window,
  // not necessarily the exact time they first asked for.
  const slotTimeById = React.useMemo(() => {
    const m = {};
    (slots || []).forEach(s => { m[s.id] = s.starts_at; });
    return m;
  }, [slots]);
  const liveHMs = React.useMemo(() => {
    const s = new Set(); (slots || []).forEach(x => { if (x.status === 'open') s.add(slotHM(x.starts_at)); });
    return s;
  }, [slots]);
  const suggestions = React.useMemo(
    () => suggestTimes(filled.noFit, liveHMs),
    [filled, liveHMs]
  );

  // NOTE: the board no longer writes tee_assignments. The automated matcher
  // (sandbox_sift on the half hour, sandbox_lock at T-1h) is the sole author
  // of foursomes/trios/pairs — it forms real bookings + matches + assignments
  // straight from tee_waitlist. Auto-writing 'booked' assignments here used
  // to make the old engine SKIP those golfers (it ignored anyone who already
  // had a booked/rescheduled assignment), so they never got a real, playable
  // match. The board is a live view + bump control only; the fill projection
  // below is a what-if preview for unplaced demand and persists nothing.

  // Candidate times = interval grid across the allowed window, PLUS any
  // existing live slots inside it (so they always show up), PLUS any times
  // added from demand suggestions.
  const inAnyWindow = React.useCallback(
    (m) => windows.some(([a, b]) => m >= a && m < b),
    [windows]
  );
  const candidates = React.useMemo(() => {
    const set = new Set();
    windows.forEach(([a, b]) => genTimes(a, b, intervalMin).forEach(t => set.add(t)));
    (slots || []).forEach(s => {
      const m = minutesOfDay(s.starts_at);
      if (s.status === 'open' && inAnyWindow(m)) set.add(slotHM(s.starts_at));
    });
    forced.forEach(t => { if (inAnyWindow(toMin(t))) set.add(t); });
    return [...set].sort((a, b) => toMin(a) - toMin(b));
  }, [windows, intervalMin, slots, forced, inAnyWindow]);

  const liveSet = React.useMemo(() => {
    const s = new Set();
    (slots || []).forEach(x => { if (x.status === 'open') s.add(slotHM(x.starts_at)); });
    return s;
  }, [slots]);

  // On first load of a day that already has live times: reconstruct the
  // window shape, match the interval to the schedule's spacing, and mark
  // any gaps as disabled (red) — the page always tells the truth about the
  // day. Live Supabase data is always the source of truth for WHICH times
  // are actually live. Only when a day has NO live slots yet do we fall
  // back to this date's saved override, then the course's shared template
  // (from "Apply to next 10 days"), so a fresh date starts from what the
  // manager already set up rather than the hardcoded twilight default.
  const snappedFor = React.useRef(null);
  React.useEffect(() => {
    if (slots === null || snappedFor.current === dateStr) return;
    snappedFor.current = dateStr;
    const mins = (slots || []).filter(s => s.status === 'open').map(s => minutesOfDay(s.starts_at)).sort((a, b) => a - b);
    const override = loadTTOverride(course.id, dateStr);
    if (!mins.length) {
      const cfg = override || loadTTTemplate(course.id);
      if (cfg) {
        setIntervalMin(cfg.intervalMin);
        setPrice(cfg.price);
        setIncludesCart(cfg.includesCart);
        setWindows(cfg.windows);
        setActiveWin(0);
        setDisabled(new Set());
      }
      return;
    }

    const gapCount = {};
    for (let i = 1; i < mins.length; i++) { const g = mins[i] - mins[i - 1]; if (g >= 3 && g <= 15) gapCount[g] = (gapCount[g] || 0) + 1; }
    const best = Object.entries(gapCount).sort((a, b) => b[1] - a[1])[0];
    const iv = best ? Number(best[0]) : 5;
    if (best) setIntervalMin(iv);

    // This exact date was saved through this panel before — trust its saved
    // window shape (could be 2+ separate windows) instead of re-deriving a
    // single span from the raw slot times. That's what stops a day saved
    // with, say, a 9-11am window AND a 4-8pm window from coming back as one
    // combined 9am-8pm window with everything between marked "disabled".
    if (override && Array.isArray(override.windows) && override.windows.length) {
      const ovIv = override.intervalMin || iv;
      setIntervalMin(ovIv);
      setWindows(override.windows); setActiveWin(0);
      const live = new Set((slots || []).filter(s => s.status === 'open').map(s => slotHM(s.starts_at)));
      const candidateSet = new Set();
      override.windows.forEach(([a, b]) => genTimes(a, b, ovIv).forEach(t => candidateSet.add(t)));
      setDisabled(new Set([...candidateSet].filter(t => !live.has(t))));
      return;
    }

    // No saved override for this date (slots from before this system, or
    // from somewhere else) — best-effort reconstruction: split the live
    // times into separate windows wherever there's a large gap, rather than
    // always collapsing everything into one span. A gap only counts as a
    // window break once it's both >60min AND >6x the detected interval, so
    // a cluster of merely-disabled slots in the middle of one window
    // doesn't get misread as two windows.
    const breakGap = Math.max(60, iv * 6);
    const clusters = [];
    let clusterStart = mins[0], prev = mins[0];
    for (let i = 1; i <= mins.length; i++) {
      const m = mins[i];
      if (m === undefined || m - prev > breakGap) {
        clusters.push([clusterStart, prev]);
        clusterStart = m;
      }
      prev = m;
    }
    const newWindows = clusters.map(([a, b]) => [
      Math.max(6 * 60, Math.floor(a / 30) * 30),
      Math.min(22 * 60, Math.max(Math.floor(a / 30) * 30 + 30, Math.ceil((b + iv) / 30) * 30)),
    ]);
    setWindows(newWindows); setActiveWin(0);
    const live = new Set((slots || []).filter(s => s.status === 'open').map(s => slotHM(s.starts_at)));
    const candidateSet = new Set();
    newWindows.forEach(([a, b]) => genTimes(a, b, iv).forEach(t => candidateSet.add(t)));
    setDisabled(new Set([...candidateSet].filter(t => !live.has(t))));
  }, [slots, dateStr]);

  // Blocklist: every candidate is live unless the course disabled it.
  const isEnabled = (t) => !disabled.has(t);
  const selectedTimes = candidates.filter(isEnabled);
  const selectedSet = new Set(selectedTimes);
  const disabledInView = candidates.filter(t => disabled.has(t));

  // Live "expected daily revenue" from this course's real fill rate.
  const rate = (fill && fill.rate != null && fill.sampleSlots >= 4) ? fill.rate : null;
  const seats = selectedTimes.length * 4;
  const full = seats * (Number(price) || 0);
  const expected = rate != null ? Math.round(seats * rate * (Number(price) || 0)) : null;
  const revenue = expected != null ? expected : full;

  // Tap a chip to toggle its block: red = disabled, won't be offered.
  function toggleTime(t) {
    markDirty();
    setDisabled(p => { const n = new Set(p); if (n.has(t)) n.delete(t); else n.add(t); return n; });
  }
  // Live-grid tap: flip the block, and when disabling a booked time bump its
  // foursome (re-enabling clears the bump).
  function applyToggle(t, slot, hasPlayers) {
    const willDisable = !disabled.has(t);
    toggleTime(t);
    if (!slot) return;
    // Only a time with real players needs a bump on save; an empty one just
    // stops being published.
    if (willDisable && hasPlayers) setBlocked(p => new Set(p).add(slot.id));
    else if (!willDisable) setBlocked(p => { const n = new Set(p); n.delete(slot.id); return n; });
  }

  // The ✕ on a card. Closing an empty time is immediate; closing one with
  // people on it asks first, because confirming means moving real golfers to
  // a different tee time.
  function toggleLiveTime(t, slot, seats, locked) {
    const reopening = disabled.has(t);
    const n = (seats || []).length;
    if (reopening || n === 0) { applyToggle(t, slot, false); return; }
    if (locked) return;                       // past T-1h — the ✕ is inert
    setConfirmClose({ t, slot, seats });
  }
  // Bulk: re-enable everything red in view, or block everything in view.
  function toggleAll() {
    if (disabledInView.length) setDisabled(p => { const n = new Set(p); disabledInView.forEach(t => n.delete(t)); return n; });
    else setDisabled(p => { const n = new Set(p); candidates.forEach(t => n.add(t)); return n; });
  }
  // Demand panel "+ Add" — pull the suggested time into the allowed window
  // (widening it if needed) and clear any block on it; the manager still saves.
  function addSuggestedTime(hm) {
    const m = toMin(hm);
    setWindows(ws => {
      if (ws.some(([a, b]) => m >= a && m < b)) return ws; // already covered
      const lo = Math.floor(m / 60) * 60, hi = Math.ceil((m + 30) / 60) * 60;
      let bi = 0, bd = Infinity;
      ws.forEach(([a, b], i) => { const d = m < a ? a - m : m >= b ? m - b : 0; if (d < bd) { bd = d; bi = i; } });
      return ws.map((w, i) => i === bi ? [Math.min(w[0], lo), Math.max(w[1], hi)] : w);
    });
    setForced(p => new Set(p).add(hm));
    setDisabled(p => { const n = new Set(p); n.delete(hm); return n; });
    markDirty();
  }

  // Add another allowed window — drops a 2-hour window into the biggest
  // free gap on the timeline and makes it the active one to adjust.
  function addWindow() {
    const MINH = 6 * 60, MAXH = 22 * 60;
    setWindows(ws => {
      const sorted = [...ws].sort((a, b) => a[0] - b[0]);
      const gaps = []; let cursor = MINH;
      sorted.forEach(([a, b]) => { if (a - cursor >= 60) gaps.push([cursor, a]); cursor = Math.max(cursor, b); });
      if (MAXH - cursor >= 60) gaps.push([cursor, MAXH]);
      const g = gaps.sort((x, y) => (y[1] - y[0]) - (x[1] - x[0]))[0] || [MINH, MAXH];
      const start = g[0], end = Math.min(g[1], start + 120);
      return [...ws, [start, end]];
    });
    setActiveWin(windows.length); // the new one
    markDirty();
  }
  function removeWindow(i) {
    setWindows(ws => ws.length > 1 ? ws.filter((_, j) => j !== i) : ws);
    setActiveWin(a => (i < a || a >= windows.length - 1) ? Math.max(0, a - 1) : a);
    markDirty();
  }

  // Save makes the day's live schedule EXACTLY window-minus-disabled: publish
  // every enabled time in the window; remove live times that are disabled OR
  // outside the allowed window (the window is authoritative for the day) —
  // keeping any that already have players booked.
  async function saveChanges() {
    setBusy(true); setErr(''); setMsg('');
    try {
      const dayLive = (slots || []).filter(s => s.status === 'open');
      const toRemove = dayLive.filter(s => !selectedSet.has(slotHM(s.starts_at)));
      const removable = toRemove.filter(s => !(fields[s.id] && fields[s.id].length)); // protect booked
      const keptBooked = toRemove.length - removable.length;

      if (selectedTimes.length) {
        await publishDayTimes({ courseId: course.id, dateStr, times: selectedTimes, price, includesCart });
      }
      for (const s of removable) { await deleteSlot(s.id); } // eslint-disable-line no-await-in-loop

      // Bumps: move each blocked booked foursome to the next open slot. This is
      // a real server-side reschedule (reschedule_foursome) — the golfers' real
      // bookings + assignments move and they get a "tee time moved" card.
      // Member-affected times go FIRST. The golfer app tells a bumped SBX+
      // member "you were re-seated first", and with several closures in one
      // save that is only true if we actually call them in that order — two
      // displaced groups can compete for the same later slot, and the RPC
      // sees one slot per call, so whoever we ask for first wins it. Their
      // side flagged this rather than let the copy quietly overpromise.
      const bumpOrder = [...blocked].sort((a, b) => {
        const plus = (id) => ((fields[id] || []).some(p => normalTier(p.tier) === 'plus') ? 1 : 0);
        return plus(b) - plus(a);
      });
      let bumped = 0; let released = 0; const bumpErrs = [];
      for (const slotId of bumpOrder) {
        try {
          // null = no later time had room. Not a failure any more: the app
          // cancels those bookings and notifies the players.
          const moved = await rescheduleFoursome(slotId, 'manager_override'); // eslint-disable-line no-await-in-loop
          if (moved) bumped++; else released++;
        } catch (e) { bumpErrs.push(e.message || 'bump failed'); }
      }
      setBlocked(new Set()); setCommittedBlocked(new Set());

      const parts = [`${selectedTimes.length} tee time${selectedTimes.length === 1 ? '' : 's'} live`];
      if (removable.length) parts.push(`${removable.length} removed`);
      if (keptBooked) parts.push(`${keptBooked} kept (booked)`);
      if (bumped) parts.push(`${bumped} moved`);
      if (released) parts.push(`${released} cancelled`);
      setMsg('Saved — ' + parts.join(' · ') + '.');
      // A cancellation is an outcome worth stating on its own, not a number
      // buried in the save line: real golfers were told their round is off.
      if (released) {
        setErr(`${released} group${released === 1 ? '' : 's'} had no later time with room, so `
          + `${released === 1 ? 'its' : 'their'} booking${released === 1 ? ' was' : 's were'} cancelled and `
          + `${released === 1 ? 'that group has' : 'those groups have'} been notified. Nothing was charged.`);
      } else if (bumpErrs.length) {
        setErr(`${bumpErrs.length} tee time${bumpErrs.length === 1 ? '' : 's'} could not be re-sorted — `
          + `those players are unchanged. (${bumpErrs[0]})`);
      }
      setDirty(false);
      // Saving a specific date pins it: an override always wins over the
      // shared template, so this date won't get overwritten by a later
      // "Apply to next 10 days" or reset when the manager just revisits it.
      saveTTOverride(course.id, dateStr, { intervalMin, price, includesCart, windows });
      reload();
    } catch (e) { setErr(e.message || 'Could not save.'); }
    setBusy(false);
  }

  // Push the current interval/price/cart/windows to 10 days as the course's
  // shared template. This actually UPDATES each day to match — publishing
  // the new times and removing whatever's live outside them (never a booked
  // slot, that's always protected) — so changing the interval really does
  // replace the old schedule instead of layering on top of it. Other days
  // that already have their OWN saved override (the manager customized and
  // saved that specific date) are left untouched.
  //
  // The run is anchored at the date currently open, not at today: the setup
  // being pushed is the one on screen for THAT date, so rolling it forward
  // from there is what the manager means — and it guarantees the open date
  // is included, which an always-from-today range silently failed to do
  // whenever they were looking further than 10 days out.
  const APPLY_DAYS = 10;
  const [applying, setApplying] = React.useState(false);
  const [confirmApply, setConfirmApply] = React.useState(false);
  const applyDays = React.useMemo(() => {
    const anchor = new Date(`${dateStr}T00:00:00`);
    return Array.from({ length: APPLY_DAYS }, (_, i) => isoFromDate(addCalendarDays(anchor, i)));
  }, [dateStr]);

  const shortDate = (iso) => new Date(`${iso}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
  const applyRangeLabel = `${shortDate(applyDays[0])} – ${shortDate(applyDays[applyDays.length - 1])}`;

  async function applyToNextDays() {
    setConfirmApply(false);
    setApplying(true); setErr(''); setMsg('');
    try {
      saveTTTemplate(course.id, { intervalMin, price, includesCart, windows });
      const times = [...new Set(windows.flatMap(([a, b]) => genTimes(a, b, intervalMin)))];
      // The open date always applies (running this while looking at it is an
      // explicit act on that date); every other customized date is skipped.
      const targetDays = applyDays.filter(d => d === dateStr || !loadTTOverride(course.id, d));
      const skipped = applyDays.length - targetDays.length;

      const { daysChanged, protectedCount } = await applyScheduleAcrossDays({
        courseId: course.id, dates: targetDays, times, price, includesCart,
      });

      // This date now matches the template, so it is no longer "customized" —
      // drop its override, otherwise the banner keeps claiming otherwise and
      // a later apply from another date would needlessly skip it.
      clearTTOverride(course.id, dateStr);

      const parts = [`Applied to ${daysChanged} day${daysChanged === 1 ? '' : 's'}`];
      if (skipped) parts.push(`${skipped} customized day${skipped === 1 ? '' : 's'} left alone`);
      if (protectedCount) parts.push(`${protectedCount} booked time${protectedCount === 1 ? '' : 's'} kept`);
      setMsg(parts.join(' · ') + '.');

      setDirty(false); setDisabled(new Set()); setForced(new Set()); reload();
    } catch (e) { setErr(e.message || 'Could not apply.'); }
    setApplying(false);
  }

  // The live-times list shows the whole day's schedule, soonest first.
  const daySlots = (slots || []).slice().sort((a, b) => new Date(a.starts_at) - new Date(b.starts_at));
  const dateIsCustomized = !!loadTTOverride(course.id, dateStr);

  // Shared look for both sliders.
  const sliderHeading = { fontWeight: 700, fontSize: 13.5, color: 'var(--ink)' };
  const sliderBig = { fontFamily: 'var(--font-display)', fontSize: 34, color: 'var(--paper)', lineHeight: 1, fontVariantNumeric: 'tabular-nums' };

  return (
    <div style={{ maxWidth: 1560, margin: '0 auto' }}>
      {/* Only Save rides the bar. "Apply to N days" is not a save of pending
          edits — it republishes the setup forward — and putting the two side
          by side at the top of every screen is how someone reaches for one
          and hits the other. */}
      <StickySave dirty={dirty} saving={busy} error={err}
        label="Save changes" onSave={saveChanges} disabled={applying}
        note={`${selectedTimes.length} tee time${selectedTimes.length === 1 ? '' : 's'} will be live`}/>

      {/* Date strip — a full-width scroll bar across the whole top of the
          panel (not squeezed into a column); everything else lives below
          it, so picking a day always reads as the first, page-wide act. */}
      <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap', marginBottom: 10 }}>
        <div className="eyebrow">Set The Tee Sheet For The Day</div>
        {dateIsCustomized && (
          <span style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--ink-faint)' }}>
            Customized for this date · won't follow your default schedule
          </span>
        )}
      </div>
      <DateStrip value={dateStr} onChange={d => { setDateStr(d); setDisabled(new Set()); setForced(new Set()); setBlocked(new Set()); setCommittedBlocked(new Set()); setMsg(''); setDirty(false); }}/>

      {/* Requested Times sits next to the schedule-controls card — both are
          "decide what to do with this date" tools, read together. */}
      <div style={{ display: 'flex', gap: 20, alignItems: 'flex-start', marginTop: 22 }}>
        <DemandPanel
          demand={demand}
          filled={filled}
          suggestions={suggestions}
          slotTimeById={slotTimeById}
          dateStr={dateStr}
          loading={realDemand === null}
          onAddTime={addSuggestedTime}
          onClearDemo={() => clearTeeAssignments(course.id, dateStr)}
          allowedWindows={windows}
        />

        <div style={{ flex: '1 1 460px', minWidth: 380 }}>
      {/* 2 - Tee time details */}
      <div className="card" style={{ padding: 22 }}>
        <div style={{ display: 'flex', gap: 22, flexWrap: 'wrap', alignItems: 'stretch' }}>
          <div style={{ flex: '1 1 200px', minWidth: 190 }}>
            <div style={sliderHeading}>Tee Time Interval</div>
            <div style={{ display: 'flex', alignItems: 'baseline', gap: 8, margin: '10px 0 4px' }}>
              <span style={sliderBig}>{intervalMin}</span><span style={{ fontSize: 14, opacity: 0.6 }}>minutes apart</span>
            </div>
            <SingleSlider min={3} max={15} step={1} value={intervalMin} onChange={v => { setIntervalMin(v); markDirty(); }}/>
            <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, opacity: 0.5, fontFamily: 'var(--font-mono)', marginTop: 2 }}><span>3 Min</span><span>15 Min</span></div>
          </div>
          <div style={{ flex: '1 1 200px', minWidth: 190 }}>
            <div style={sliderHeading}>Price Per Golfer</div>
            <div style={{ display: 'flex', alignItems: 'baseline', gap: 6, margin: '10px 0 4px' }}>
              <span style={sliderBig}>${price}</span><span style={{ fontSize: 14, opacity: 0.6 }}>per golfer</span>
            </div>
            <SingleSlider min={0} max={75} step={1} value={price} onChange={v => { setPrice(v); markDirty(); }}/>
            <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, opacity: 0.5, fontFamily: 'var(--font-mono)', marginTop: 2 }}><span>$0</span><span>$75</span></div>
            <SuggestedPriceCallout course={course} price={price}
              onApply={v => { setPrice(v); markDirty(); }}/>
          </div>
          <div style={{ flex: '1 1 200px', minWidth: 190, display: 'flex', flexDirection: 'column' }}>
            <div style={{ position: 'relative', flex: 1, background: 'var(--surface-sunken)', borderRadius: 'var(--r-sm)', padding: '14px 16px', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
              <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
                <span className="eyebrow">Expected Daily Revenue</span>
                <button onClick={() => setShowFormula(v => !v)} title="How is this calculated?" style={{ border: '1px solid currentColor', borderRadius: 999, width: 16, height: 16, background: 'transparent', cursor: 'pointer', fontSize: 10, fontWeight: 800, lineHeight: 1, opacity: showFormula ? 1 : 0.55, padding: 0 }}>?</button>
              </div>
              <div style={{ ...sliderBig, fontSize: 28, lineHeight: 1.05, marginTop: 4 }}>${revenue.toLocaleString('en-US')}</div>
              <div style={{ fontSize: 12, opacity: 0.65, marginTop: 3 }}>Based on your average booking rate</div>
              {showFormula && (
                <div className="fade-in" style={{ position: 'absolute', top: '100%', left: 0, right: 0, marginTop: 8, zIndex: 6, background: 'var(--forest-dark)', color: 'var(--paper)', borderRadius: 'var(--r-sm)', boxShadow: 'var(--shadow-float)', padding: '14px 16px', fontSize: 12, lineHeight: 1.5 }}>
                  <div style={{ fontWeight: 700, marginBottom: 4, color: 'var(--paper)' }}>How This Is Calculated</div>
                  <div style={{ opacity: 0.8 }}>Tee times x 4 players x price x avg booking rate</div>
                  <div style={{ fontFamily: 'var(--font-mono)', marginTop: 6, opacity: 0.8 }}>{selectedTimes.length} x 4 x ${Number(price) || 0}{rate != null ? ` x ${Math.round(rate * 100)}%` : ''} = ${revenue.toLocaleString('en-US')}</div>
                  <div style={{ opacity: 0.55, marginTop: 6 }}>{rate != null ? `Avg booking rate is ${Math.round(rate * 100)}% from ${course.short_name}'s last 90 days (${fill.booked}/${fill.seats} seats).` : `No booking history yet - showing full potential ($${full.toLocaleString('en-US')}).`}</div>
                </div>
              )}
            </div>
          </div>
        </div>

        {/* Allowed windows */}
        <div style={{ marginTop: 20 }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
            <div style={sliderHeading}>Allowed Tee Time Window{windows.length > 1 ? 's' : ''}</div>
            <span style={{ fontFamily: 'var(--font-mono)', fontWeight: 700, fontSize: 13, color: 'var(--paper)' }}>{windows.slice().sort((a, b) => a[0] - b[0]).map(w => `${hmLabel(w[0])}-${hmLabel(w[1])}`).join('   ')}</span>
          </div>
          <div style={{ marginTop: 12 }}>
            <MultiWindowSlider min={6 * 60} max={22 * 60} step={30} windows={windows} active={activeWin} onChange={w => { setWindows(w); markDirty(); }} onSelectActive={setActiveWin}/>
          </div>
          <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, opacity: 0.5, fontFamily: 'var(--font-mono)', marginTop: 2 }}><span>6 AM</span><span>10 PM</span></div>
          <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 12, flexWrap: 'wrap' }}>
            <button onClick={addWindow} className="btn btn-ghost" style={{ padding: '7px 12px', fontSize: 12 }}>+ Add Another Window</button>
            {windows.length > 1 && (
              <button onClick={() => removeWindow(activeWin)} style={{ border: 'none', background: 'transparent', cursor: 'pointer', fontSize: 12, fontWeight: 700, color: 'var(--loss-soft)', padding: '7px 4px' }}>Remove Window {activeWin + 1}</button>
            )}
          </div>
        </div>

        {/* Golfer-facing settings: cart + which nine */}
        <div style={{ display: 'flex', gap: 26, marginTop: 20, flexWrap: 'wrap', alignItems: 'flex-start', borderTop: '1px solid var(--line-soft)', paddingTop: 18 }}>
          <div>
            <div className="eyebrow" style={{ marginBottom: 8 }}>Cart</div>
            <PillSwitch options={[{ key: 'walk', label: 'Walk Only' }, { key: 'cart', label: 'Cart Included' }]} value={includesCart ? 'cart' : 'walk'} onChange={v => { setIncludesCart(v === 'cart'); markDirty(); }} width={200}/>
          </div>
          <div>
            <div className="eyebrow" style={{ marginBottom: 8 }}>Which Nine - Shown To Golfers</div>
            <PillSwitch options={[{ key: 'front', label: 'Front 9' }, { key: 'back', label: 'Back 9' }]} value={dayNine} onChange={chooseNine} width={168}/>
            <div style={{ fontSize: 10, opacity: 0.5, marginTop: 6, fontFamily: 'var(--font-mono)' }}>
              {measuredF && measuredB ? 'Both nines measured - your pick.' : measuredB && !measuredF ? 'Auto: back nine (only one measured).' : 'Auto: front nine.'}
            </div>
          </div>
        </div>
      </div>

      {/* 3 - The two things you can do with this setup, in one place: save it
          to this date only, or roll it forward as the default. Both stay
          rendered (Save just disables when there's nothing to save) so the
          row never reflows and neither action hides. */}
      <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 16, flexWrap: 'wrap' }}>
        <button className="btn btn-forest" onClick={saveChanges} disabled={busy || applying || !dirty}
          title={dirty ? 'Publish this setup to this date only' : 'No unsaved changes'}>
          {busy ? 'Saving...' : 'Save Changes'}
        </button>
        <button className="btn btn-ghost" onClick={() => setConfirmApply(true)} disabled={applying || busy}
          title={`Publish this interval, price, cart setting, and windows across ${applyRangeLabel} — days you've saved on their own are skipped.`}>
          {applying ? 'Applying…' : `Apply this setup to ${APPLY_DAYS} days`}
        </button>
        {dirty && <span style={{ fontSize: 12, opacity: 0.6 }}>{selectedTimes.length} tee time{selectedTimes.length === 1 ? '' : 's'} will be live - 4 players per time - {includesCart ? 'cart included' : 'walk only'}</span>}
        {msg && <span style={{ fontSize: 13, color: 'var(--paper)', fontWeight: 600, marginLeft: 'auto' }}>{msg}</span>}
      </div>

      {/* Live Tee Times lives in this same right-hand column, right after
          the schedule card — not as a separate full-width section below
          everything. Requested Times (to the left) often runs taller than
          this column; keeping the grid here means it starts filling space
          the moment the schedule card ends instead of leaving a dead gap
          while it waits for the taller left column to finish. */}
      <div style={{ marginTop: 28 }}>
        <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap', marginBottom: 6 }}>
          <div style={{ fontFamily: 'var(--font-display)', fontSize: 17, color: 'var(--paper)' }}>
            Live Tee Times · {selectedTimes.length}
          </div>
          <div style={{ display: 'flex', gap: 12, fontSize: 10.5, fontFamily: 'var(--font-mono)', color: 'var(--ink-faint)' }}>
            <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}><span style={{ width: 7, height: 7, borderRadius: 2, border: '1px solid var(--line-strong)' }}/>Open</span>
            <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}><span style={{ width: 7, height: 7, borderRadius: 2, background: 'rgba(234,226,206,0.35)' }}/>Filling</span>
            <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}><span style={{ width: 7, height: 7, borderRadius: 2, background: 'var(--cream)' }}/>Booked</span>
          </div>
        </div>
        <div style={{ fontSize: 12, color: 'var(--ink-faint)', marginBottom: 14 }}>
          Tap a time to disable it — a booked foursome gets bumped to the next open slot.
        </div>
        <div style={{ maxHeight: 'calc(100vh - 340px)', overflowY: 'auto', paddingRight: 6 }}>
        {slots === null ? <Spinner/> : candidates.length === 0 ? (
          <div className="card" style={{ padding: 24, textAlign: 'center' }}>
            <Mascot size={100} style={{ margin: '0 auto 8px' }}/>
            <div style={{ fontSize: 13, opacity: 0.7 }}>No tee times in your window yet - widen the window above.</div>
          </div>
        ) : (() => {
          const slotByHM = {};
          (slots || []).forEach(s => { if (s.status === 'open') slotByHM[slotHM(s.starts_at)] = s; });
          return (
            <div key={dateStr} className="fade-in" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(118px, 1fr))', columnGap: 10, rowGap: 28 }}>
              {candidates.map(t => {
                const slot = slotByHM[t];
                return (
                  <LiveTimeCard key={t} t={t} slot={slot}
                    players={slot ? (fields[slot.id] || []) : []}
                    fillSeats={slot ? (filled.seats[slot.id] || []) : []}
                    play={slot ? (dayPlay[slot.id] || null) : null}
                    disabled={disabled.has(t)}
                    blockedDirect={!!(slot && committedBlocked.has(slot.id))}
                    onToggle={(seats, locked) => toggleLiveTime(t, slot, seats, locked)}/>
                );
              })}
            </div>
          );
        })()}
        </div>
      </div>
        </div>
      </div>

      {/* Closing a tee time that has players on it. Nothing moves until the
          manager saves — this only marks it — so the wording says that
          rather than implying the golfers have already been told. */}
      {confirmClose && (() => {
        const names = confirmClose.seats.map(s => s.kind === 'real' ? playerName(s.p) : s.e.user.name);
        const realCount = confirmClose.seats.filter(s => s.kind === 'real').length;
        return (
          <ConfirmDialog
            open danger
            title={`Close the ${hmLabel(toMin(confirmClose.t))} tee time?`}
            body={
              <>
                {names.join(', ')} {names.length === 1 ? 'is' : 'are'} on this time.
                Closing it moves {names.length === 1 ? 'them' : 'them all'} to the next open tee time
                on this day.
                {realCount === 0 && (
                  <div style={{ marginTop: 8 }}>
                    These seats are a projection, not real bookings yet — nothing will actually be
                    rescheduled.
                  </div>
                )}
                {realCount > 0 && (
                  // The golfer app cancels-and-notifies when no later time
                  // has room (it used to raise and leave them put). Say what
                  // actually happens to a real person, since it's the worse
                  // of the two outcomes and the manager is choosing it.
                  <div style={{ marginTop: 8 }}>
                    If no later time has room their booking is cancelled and they&rsquo;re told —
                    nothing is charged either way.
                  </div>
                )}
                <div style={{ marginTop: 8 }}>
                  Nothing moves until you hit <strong>Save Changes</strong>.
                </div>
              </>
            }
            confirmLabel="Close and re-sort"
            onConfirm={() => {
              applyToggle(confirmClose.t, confirmClose.slot, realCount > 0);
              setConfirmClose(null);
            }}
            onCancel={() => setConfirmClose(null)}
          />
        );
      })()}

      <ConfirmDialog
        open={confirmApply}
        busy={applying}
        title={`Apply this setup to ${applyRangeLabel}?`}
        body={
          `This updates each of those ${APPLY_DAYS} days to match — interval, price, cart setting, and allowed windows — publishing the new times and removing whatever's live outside them. Booked times are always kept, and any other date you've already customized and saved on its own is left alone.`
          + (disabledInView.length
            ? ` Heads up: the ${disabledInView.length} time${disabledInView.length === 1 ? '' : 's'} you've disabled on this date will go back live, since blocks are per-date and this publishes the full window.`
            : '')
        }
        confirmLabel="Apply"
        onConfirm={applyToNextDays}
        onCancel={() => setConfirmApply(false)}
      />
    </div>
  );
}

// ─── One live tee time (pill card) ──────────────────────────────────────────
// Time + the foursome's four seats (real bookings, then the SBX matchmaking
// fill), a Booked/Open status, front/back nine + cart labels (neutral look —
// they don't light up either way), Rebook (move the foursome when the course
// books the slot directly) and ✕ (remove the time entirely).
function TeeTimePill({ slot, players = [], fillSeats = [], blockedDirect, onBump, onUnbump, onSaved, onError }) {
  const [nine, setNine] = React.useState(() => getSlotNine(slot.id));
  const [cart, setCart] = React.useState(!!slot.includes_cart);
  const [busy, setBusy] = React.useState(false);
  const [emulated, setEmulated] = React.useState(false);

  const real = players.filter(p => p.status !== 'cancelled');
  const seatList = [
    ...real.map(p => ({ kind: 'real', key: p.id, p })),
    ...fillSeats.map(e => ({ kind: 'fill', key: e.id, e })),
  ].slice(0, 4);
  const status = blockedDirect ? 'Bumped' : seatList.length >= 4 ? 'Booked' : 'Open';

  function toggleNine() {
    const v = nine === 'front' ? 'back' : 'front';
    setSlotNine(slot.id, v); setNine(v);
  }
  async function toggleCart() {
    const v = !cart; setCart(v);
    try { await saveSlot({ ...slot, includes_cart: v }); onSaved(); }
    catch (e) { setCart(!v); onError(e.message || 'Could not save.'); }
  }
  // Emulate walks THIS tile's exact foursome onto the live map — the real
  // booked golfers (avatars and all) plus the matchmaking fill; any missing
  // seats top up with roster ghosts inside the emulator.
  function emulate() {
    const seatPlayers = seatList.map(s => s.kind === 'real'
      ? { id: s.p.id, name: playerName(s.p), initials: initials(s.p), avatar_url: s.p.avatar_url || null, member: true }
      : { id: s.e.id, name: s.e.user.name, initials: s.e.user.initials, avatar_url: s.e.user.avatar_url || null, sbx: s.e.sbx, member: !!s.e.priority });
    ghostEmulateSlot(slot, nine, seatPlayers);
    setEmulated(true);
    window.setTimeout(() => setEmulated(false), 2000);
  }
  async function remove() {
    // The FK is ON DELETE RESTRICT as of 2026-08-26 (it used to CASCADE and
    // silently delete the booking rows, which is what this dialog used to
    // claim was a cancellation). A booked time now refuses to delete, so
    // this promises removal only for an empty one and lets the error speak
    // for the rest.
    if (!window.confirm(`Remove the ${timeLabel(slot.starts_at)} tee time? A time with players on it can’t be removed — close it instead so they get re-sorted.`)) return;
    setBusy(true); onError('');
    try { await deleteSlot(slot.id); onSaved(); }
    catch (e) { onError(e.message || 'Could not remove.'); }
    setBusy(false);
  }

  // Neutral pill look for every control — no fill-color flip on toggle.
  const miniBtn = {
    padding: '5px 11px', borderRadius: 999, cursor: 'pointer', fontSize: 11, fontWeight: 700,
    border: '1px solid rgba(14,28,19,0.22)', background: 'var(--paper)', color: 'var(--ink)',
  };

  return (
    <div className="card" style={{ padding: '12px 14px', opacity: blockedDirect ? 0.88 : 1 }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
        <span style={{ fontFamily: 'var(--font-mono)', fontWeight: 800, fontSize: 17, color: 'var(--ink)' }}>{timeLabel(slot.starts_at)}</span>
        <span style={{
          fontSize: 9, fontFamily: 'var(--font-mono)', fontWeight: 800, letterSpacing: '0.08em', textTransform: 'uppercase',
          padding: '4px 10px', borderRadius: 999,
          border: status === 'Booked' ? '1px solid var(--forest)' : '1px solid rgba(14,28,19,0.25)',
          background: status === 'Booked' ? 'var(--forest)' : 'transparent',
          color: status === 'Booked' ? 'var(--cream)' : 'var(--forest)',
        }}>{status}</span>
      </div>

      {/* The foursome — 4 overlapping seats, filled by the SBX matchmaking */}
      <div style={{ display: 'flex', alignItems: 'center', marginTop: 10 }}>
        {Array.from({ length: 4 }).map((_, i) => {
          const s = seatList[i];
          const wrap = { marginLeft: i ? -8 : 0 };
          if (!s) return <div key={`e-${i}`} style={wrap}><EmptySeat/></div>;
          if (s.kind === 'real') return <div key={s.key} style={wrap}><Avatar player={s.p}/></div>;
          const e = s.e;
          const label = `${e.user.name}${e.sbx != null ? ` · SBX ${Number(e.sbx).toFixed(3)}` : ''}${e.priority ? ' · Priority (Sandbox+)' : ''}`;
          return (
            <div key={s.key} style={wrap} title={label}>
              {e.user.avatar_url ? (
                <img src={e.user.avatar_url} alt={e.user.name} style={{
                  width: 28, height: 28, borderRadius: 999, objectFit: 'cover',
                  border: '2px solid var(--paper)', background: '#ddd', display: 'block',
                }}/>
              ) : (
                <div style={{
                  width: 28, height: 28, borderRadius: 999, border: '2px solid var(--paper)',
                  background: e.priority ? 'var(--forest)' : 'rgba(28,73,42,0.16)',
                  color: e.priority ? 'var(--cream)' : 'var(--forest)',
                  display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 10, fontWeight: 800,
                }}>{e.user.initials}</div>
              )}
            </div>
          );
        })}
        {blockedDirect && (
          <span style={{ marginLeft: 10, fontSize: 9, fontFamily: 'var(--font-mono)', fontWeight: 700, color: 'var(--loss, #9B3A2E)', letterSpacing: '0.04em', textTransform: 'uppercase' }}>
            Booked Direct
          </span>
        )}
      </div>

      <div style={{ display: 'flex', gap: 6, marginTop: 10, flexWrap: 'wrap' }}>
        <button style={miniBtn} onClick={toggleNine} title="Which nine this tee time plays">{nine === 'back' ? 'Back 9' : 'Front 9'}</button>
        <button style={miniBtn} onClick={toggleCart} title="Cart included?">{cart ? 'Cart' : 'No Cart'}</button>
        <button style={miniBtn} onClick={emulate} title="Fabricate a foursome here and watch it play the live map — demo only.">{emulated ? '✓' : '▶'}</button>
        <button
          style={{ ...miniBtn, opacity: (seatList.length || blockedDirect) ? 1 : 0.45, cursor: (seatList.length || blockedDirect) ? 'pointer' : 'default' }}
          disabled={!seatList.length && !blockedDirect}
          onClick={() => {
            if (blockedDirect) { onUnbump(); return; }
            onBump();
            if (seatList.length) {
              const first = seatList[0];
              recordDemoBump({
                golfer: first.kind === 'real' ? playerName(first.p) : first.e.user.name,
                movedTo: null, makeGood: true,
              });
            }
          }}
          title={blockedDirect ? 'Undo — reopen this time to the waitlist' : 'The course booked this time directly — move the foursome to the next open time.'}>
          {blockedDirect ? 'Undo' : 'Rebook'}
        </button>
        <button style={{ ...miniBtn, marginLeft: 'auto', color: '#9B3A2E', borderColor: 'rgba(155,58,46,0.35)', background: 'rgba(155,58,46,0.06)' }}
          disabled={busy} onClick={remove} title="Remove this tee time entirely">✕</button>
      </div>
    </div>
  );
}

// ─── Sift / lock countdown ────────────────────────────────────────────────
// The two crons that actually run seating: sandbox_sift pencils people into
// slots on the half hour, sandbox_lock checks every 5 minutes and finalizes
// anything whose tee time has reached T-1h into a real booking. Both are
// wall-clock cron schedules, not tied to this course or date — so the
// countdown is just "next time each one fires," not "next time something
// happens to this page."
const nextAligned = (nowMs, stepMin) => {
  const stepMs = stepMin * 60000;
  return Math.ceil((nowMs + 1) / stepMs) * stepMs;
};
const fmtCountdown = (ms) => {
  const s = Math.max(0, Math.floor(ms / 1000));
  const m = Math.floor(s / 60), sec = s % 60;
  return `${m}:${String(sec).padStart(2, '0')}`;
};
function SiftCountdown() {
  const [now, setNow] = React.useState(() => Date.now());
  React.useEffect(() => {
    const iv = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(iv);
  }, []);
  return (
    <div style={{ display: 'flex', gap: 12, marginTop: 8, fontFamily: 'var(--font-mono)' }}>
      <span style={{ fontSize: 10.5, color: 'var(--ink-muted)' }}
        title="sandbox_sift runs on the half hour and pencils waitlist windows into open slots.">
        Next sift <strong style={{ color: 'var(--paper)' }}>{fmtCountdown(nextAligned(now, 30) - now)}</strong>
      </span>
      <span style={{ fontSize: 10.5, color: 'var(--ink-muted)' }}
        title="sandbox_lock checks every 5 minutes and finalizes any group within an hour of its tee time.">
        Next lock check <strong style={{ color: 'var(--paper)' }}>{fmtCountdown(nextAligned(now, 5) - now)}</strong>
      </span>
    </div>
  );
}

// ─── Requested tee times (left panel) ───────────────────────────────────────
// What the waitlist is asking for on this date: each requested window with a
// head count, how much of it the current schedule seats, and — when demand
// can't fit — the times worth adding to catch the rest.
function DemandPanel({ demand, filled, suggestions, slotTimeById, dateStr, loading, onAddTime, onClearDemo, allowedWindows }) {
  // Group identical windows ("6–7pm × 4 waiting").
  const windows = React.useMemo(() => {
    const m = {};
    for (const e of demand) {
      const k = `${e.startMin}-${e.endMin}`;
      (m[k] = m[k] || { startMin: e.startMin, endMin: e.endMin, entries: [] }).entries.push(e);
    }
    return Object.values(m).sort((a, b) => a.startMin - b.startMin || a.endMin - b.endMin);
  }, [demand]);
  // Seated = ground truth for a real golfer (sandbox_sift already set
  // provisionalSlotId — no need to guess) union whatever the projection
  // seated for ghosts, which have no real placement to defer to.
  const seatedIds = React.useMemo(() => {
    const s = new Set();
    (demand || []).forEach(e => { if (!e.ghost && e.provisionalSlotId) s.add(e.id); });
    Object.values(filled.seats).flat().forEach(e => s.add(e.id));
    return s;
  }, [demand, filled]);
  const seated = demand.filter(e => seatedIds.has(e.id)).length;
  const pencilledTime = (e) => {
    if (e.ghost || !e.provisionalSlotId) return null;
    const iso = slotTimeById && slotTimeById[e.provisionalSlotId];
    return iso ? timeLabel(iso) : null;
  };

  return (
    <div style={{ width: 264, flexShrink: 0 }}>
      <div className="card" style={{ padding: 18 }}>
        <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}>
          <div style={{ fontFamily: 'var(--font-display)', fontSize: 17, color: 'var(--paper)' }}>Requested Times</div>
          <span style={{ fontSize: 10, fontFamily: 'var(--font-mono)', opacity: 0.5 }}>{dayLabel(`${dateStr}T12:00`)}</span>
        </div>
        <div style={{ fontSize: 12, opacity: 0.6, marginTop: 4, lineHeight: 1.45 }}>
          Waitlist windows golfers gave for this day. sandbox_sift pencils people in on the half hour;
          sandbox_lock finalizes a group into a real booking at T-1h.
        </div>
        <SiftCountdown/>

        {loading && !demand.length ? <Spinner/> : windows.length === 0 ? (
          <div style={{ padding: '18px 0 6px', textAlign: 'center', fontSize: 12, opacity: 0.55 }}>
            No requests yet for this day.
          </div>
        ) : (
          <div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
            {windows.map(w => {
              const pri = w.entries.filter(e => e.priority).length;
              // How much of this window is actually pencilled: solid forest
              // when everyone in it has a real provisionalSlotId (or, for
              // ghosts, the projection seated them), outlined otherwise.
              // Still not a booking — nothing here is final until
              // sandbox_lock fires at T-1h.
              const pencilled = w.entries.filter(e => seatedIds.has(e.id)).length;
              const fullyPencilled = pencilled === w.entries.length;
              return (
                <div key={`${w.startMin}-${w.endMin}`} style={{ border: 'var(--hairline)', borderRadius: 'var(--r-sm)', padding: '10px 12px' }}>
                  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 6 }}>
                    <span style={{ fontFamily: 'var(--font-mono)', fontWeight: 700, fontSize: 13, color: 'var(--paper)' }}>
                      {hmLabel(w.startMin)}–{hmLabel(w.endMin)}
                    </span>
                    <span style={{
                      fontSize: 10, fontFamily: 'var(--font-mono)', fontWeight: 700,
                      padding: '3px 8px', borderRadius: 999,
                      background: fullyPencilled ? 'var(--cream)' : 'transparent',
                      color: fullyPencilled ? 'var(--forest)' : 'var(--paper)',
                      border: fullyPencilled ? '1px solid var(--cream)' : '1px solid rgba(234,226,206,0.4)',
                    }}>{pencilled}/{w.entries.length} pencilled</span>
                  </div>
                  <div style={{ fontSize: 11, opacity: 0.6, marginTop: 5, lineHeight: 1.4 }}>
                    {w.entries.map((e, i) => {
                      const at = pencilledTime(e);
                      return (
                        <React.Fragment key={e.id}>
                          {i > 0 && ', '}
                          <span style={e.ghost ? { fontStyle: 'italic' } : undefined} title={e.ghost ? 'Fabricated demo demand — not a real golfer' : (at ? `Pencilled by sandbox_sift for ${at} — not final until T-1h` : undefined)}>
                            {e.user.name}{e.ghost ? '*' : ''}{at ? ` (${at})` : ''}
                          </span>
                        </React.Fragment>
                      );
                    })}
                  </div>
                  {pri > 0 && (
                    <div style={{ fontSize: 9, fontFamily: 'var(--font-mono)', color: 'var(--paper)', opacity: 0.75, marginTop: 4, letterSpacing: '0.05em', textTransform: 'uppercase' }}>
                      ★ {pri} priority (Sandbox+)
                    </div>
                  )}
                </div>
              );
            })}
          </div>
        )}

        {demand.length > 0 && (
          <div style={{ marginTop: 12, fontSize: 11, fontFamily: 'var(--font-mono)', opacity: 0.65, letterSpacing: '0.02em' }}>
            {seated}/{demand.length} pencilled in
          </div>
        )}

        {/* Partial demand — golfers this projection hasn't placed yet. Not a
            guarantee they need a fourth: sandbox_lock forms a foursome first
            but will lock in a trio or a pair at T-1h if that's all a window
            has, so someone here can still end up with a real tee time. */}
        {filled.waitingMore.length > 0 && (
          <div style={{ marginTop: 10, background: 'rgba(234,226,206,0.07)', borderRadius: 'var(--r-sm)', padding: '11px 12px' }}>
            <div style={{ fontSize: 12, fontWeight: 700, color: 'var(--paper)' }}>
              {filled.waitingMore.length} not yet pencilled
            </div>
            <div style={{ fontSize: 11, opacity: 0.65, marginTop: 4, lineHeight: 1.45 }}>
              {filled.waitingMore.map((e, i) => (
                <React.Fragment key={e.id}>
                  {i > 0 && ', '}
                  <span style={e.ghost ? { fontStyle: 'italic' } : undefined} title={e.ghost ? 'Fabricated demo demand — not a real golfer' : undefined}>
                    {e.user.name}{e.ghost ? '*' : ''}
                  </span>
                </React.Fragment>
              ))}
            </div>
            <div style={{ fontSize: 10, opacity: 0.55, marginTop: 6, lineHeight: 1.4 }}>
              sandbox_sift favors a foursome when one's available, but will pencil in a trio or a pair
              at T-1h rather than leave an open window empty.
            </div>
          </div>
        )}

        {/* Unmet demand → the times worth adding */}
        {filled.noFit.length > 0 && (
          <div style={{ marginTop: 10, background: 'rgba(155,58,46,0.18)', borderRadius: 'var(--r-sm)', padding: '11px 12px' }}>
            <div style={{ fontSize: 12, fontWeight: 700, color: 'var(--loss-soft)' }}>
              {filled.noFit.length} can't fit your current times
            </div>
            {suggestions.length > 0 && (
              <div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 6 }}>
                {suggestions.map(s => (
                  <div key={s.hm} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
                    <span style={{ fontSize: 12, fontFamily: 'var(--font-mono)', fontWeight: 700 }}>{hmLabel(toMin(s.hm))}</span>
                    <span style={{ fontSize: 10, opacity: 0.6 }}>fills {s.fills}</span>
                    <button className="btn btn-forest" style={{ padding: '4px 10px', fontSize: 11 }} onClick={() => onAddTime(s.hm)}>
                      + Add
                    </button>
                  </div>
                ))}
              </div>
            )}
            <div style={{ fontSize: 10, opacity: 0.55, marginTop: 8, lineHeight: 1.4 }}>
              Adding a time selects it in the grid — hit Save to make it live.
            </div>
          </div>
        )}
      </div>

      {demand.some(e => e.ghost) && (
        <div style={{ fontSize: 10, opacity: 0.5, marginTop: 8, fontStyle: 'italic' }}>
          * fabricated demo demand, not a real golfer
        </div>
      )}

      {/* Demo the demand side without real golfers */}
      <div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
        <button className="btn btn-ghost" style={{ flex: 1, padding: '8px 10px', fontSize: 12 }}
          title="Fabricate a few waitlist windows for this day, leaning toward your selected tee-time windows — demo data, nothing saved."
          onClick={() => ghostEmulateDemand(dateStr, allowedWindows)}>
          ▶ Emulate Demand
        </button>
        <button className="btn btn-ghost" style={{ padding: '8px 10px', fontSize: 12 }}
          onClick={() => { ghostClearDemand(dateStr); onClearDemo && onClearDemo(); }}>Clear</button>
      </div>
    </div>
  );
}

// Multi-select time-of-day window filter (All day / Morning / … / Night).
function WindowFilter({ selected, onToggle, hint }) {
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
      <span className="eyebrow" style={{ marginRight: 4 }}>View</span>
      {TIME_WINDOWS.map(w => {
        const on = selected.has(w.key);
        return (
          <button key={w.key} onClick={() => onToggle(w.key)} style={{
            padding: '7px 14px', borderRadius: 999, cursor: 'pointer', fontSize: 13, fontWeight: 700,
            border: on ? '1px solid var(--forest)' : '1px solid rgba(14,28,19,0.15)',
            background: on ? 'var(--forest)' : 'transparent', color: on ? 'var(--cream)' : 'var(--ink-soft)',
          }}>{w.label}</button>
        );
      })}
      {hint && <span style={{ marginLeft: 'auto', fontSize: 12, opacity: 0.55, fontFamily: 'var(--font-mono)' }}>{hint}</span>}
    </div>
  );
}

// Horizontal date picker, mirroring the golfer app's Play date strip.
// A genuinely scrollable strip — 60 days out, not just next-10 — sized to
// fill the same width as the cards beneath it rather than shrinking to fit
// a handful of chips, with scroll-snap so it settles cleanly.
function DateStrip({ value, onChange }) {
  const days = React.useMemo(() => {
    const out = [], today = new Date(); today.setHours(0, 0, 0, 0);
    for (let i = 0; i < 60; i++) {
      const d = addCalendarDays(today, i);
      out.push({ str: isoFromDate(d), d, i });
    }
    return out;
  }, []);

  // Click-and-drag scrolling ("spin the wheel") instead of a visible native
  // scrollbar. Drag state lives in a ref (not React state) so mousemove can
  // write scrollLeft directly every frame without a re-render; a small
  // pixel threshold (`moved`) is tracked so a plain click on a date chip
  // doesn't get swallowed by drag logic.
  const scrollerRef = React.useRef(null);
  const drag = React.useRef({ active: false, moved: false, startX: 0, startScroll: 0 });
  const [grabbing, setGrabbing] = React.useState(false);

  const onMouseDown = (e) => {
    const el = scrollerRef.current;
    if (!el) return;
    drag.current = { active: true, moved: false, startX: e.pageX, startScroll: el.scrollLeft };
    setGrabbing(true);
  };

  React.useEffect(() => {
    const onMove = (e) => {
      const st = drag.current;
      if (!st.active) return;
      const el = scrollerRef.current;
      if (!el) return;
      const dx = e.pageX - st.startX;
      if (Math.abs(dx) > 4) st.moved = true;
      el.scrollLeft = st.startScroll - dx;
    };
    const onUp = () => {
      if (drag.current.active) { drag.current.active = false; setGrabbing(false); }
    };
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
    return () => {
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseup', onUp);
    };
  }, []);

  // Carousel-style Previous/Next paging (same idea as shadcn's
  // CarouselPrevious/CarouselNext) layered on top of the drag/scroll strip.
  // The strip itself stays exactly the width of the cards beneath it — the
  // arrows just overlay its edges and page by ~one viewport at a time,
  // disabling themselves at the start/end of the 60-day range.
  const [atStart, setAtStart] = React.useState(true);
  const [atEnd, setAtEnd] = React.useState(false);

  const updateEdges = React.useCallback(() => {
    const el = scrollerRef.current;
    if (!el) return;
    setAtStart(el.scrollLeft <= 2);
    setAtEnd(el.scrollLeft >= el.scrollWidth - el.clientWidth - 2);
  }, []);

  React.useEffect(() => {
    const el = scrollerRef.current;
    if (!el) return;
    updateEdges();
    el.addEventListener('scroll', updateEdges, { passive: true });
    window.addEventListener('resize', updateEdges);
    return () => {
      el.removeEventListener('scroll', updateEdges);
      window.removeEventListener('resize', updateEdges);
    };
  }, [updateEdges]);

  const page = (dir) => {
    const el = scrollerRef.current;
    if (!el) return;
    el.scrollBy({ left: dir * el.clientWidth * 0.85, behavior: 'smooth' });
  };

  const arrowBtn = (side, disabled, onClick) => (
    <button
      onClick={onClick}
      disabled={disabled}
      aria-label={side === 'left' ? 'Earlier dates' : 'Later dates'}
      style={{
        position: 'absolute', top: '50%', [side]: 0, transform: 'translateY(-50%)', zIndex: 2,
        width: 32, height: 32, borderRadius: 999, display: 'flex', alignItems: 'center', justifyContent: 'center',
        background: 'var(--cream)', border: '1px solid var(--line-strong)', boxShadow: 'var(--shadow-xs)',
        cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.35 : 1, pointerEvents: disabled ? 'none' : 'auto',
        transition: 'opacity var(--dur-fast) var(--ease), transform var(--dur-fast) var(--ease)',
      }}
    >
      <Icon name="chevron" size={14} style={{ color: 'var(--forest)', transform: side === 'left' ? 'rotate(90deg)' : 'rotate(-90deg)' }}/>
    </button>
  );

  return (
    <div style={{ position: 'relative', width: '100%' }}>
      {arrowBtn('left', atStart, () => page(-1))}
      <div
        ref={scrollerRef}
        className="no-scrollbar"
        onMouseDown={onMouseDown}
        style={{
          display: 'flex', gap: 8, overflowX: 'auto', paddingBottom: 2, paddingLeft: 40, paddingRight: 40, width: '100%',
          scrollSnapType: grabbing ? 'none' : 'x proximity',
          cursor: grabbing ? 'grabbing' : 'grab', userSelect: 'none',
        }}
      >
        {days.map(({ str, d, i }) => {
          const on = str === value;
          return (
            <button
              key={str}
              onClick={() => { if (drag.current.moved) return; onChange(str); }}
              style={{
                flex: '0 0 auto', width: 66, padding: '10px 0', borderRadius: 'var(--r-sm)', cursor: 'inherit', textAlign: 'center',
                scrollSnapAlign: 'start',
                border: on ? '1px solid var(--cream)' : '1px solid var(--line-strong)',
                background: on ? 'var(--cream)' : 'var(--surface)', color: on ? 'var(--forest)' : 'var(--ink)',
                transition: 'background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease)',
              }}
            >
              <div style={{ fontSize: 10.5, fontFamily: 'var(--font-mono)', textTransform: 'uppercase', opacity: on ? 0.85 : 0.5 }}>
                {i === 0 ? 'Today' : d.toLocaleDateString('en-US', { weekday: 'short' })}
              </div>
              <div style={{ fontFamily: 'var(--font-display)', fontSize: 20, lineHeight: 1.15, marginTop: 2 }}>{d.getDate()}</div>
              <div style={{ fontSize: 9.5, fontFamily: 'var(--font-mono)', textTransform: 'uppercase', opacity: on ? 0.85 : 0.5 }}>{d.toLocaleDateString('en-US', { month: 'short' })}</div>
            </button>
          );
        })}
      </div>
      {arrowBtn('right', atEnd, () => page(1))}
    </div>
  );
}

// Auto status from who's signed up + play state (a tee time = a foursome of 4):
//   open · searching for N more · booked · in progress · closed
// Derived live; the manager only manually opens/closes.
function teeStatus(slot, players) {
  if (slot.status === 'closed' || slot.status === 'cancelled') return { label: 'closed', tone: 'mute' };
  const n = players.length;
  const anyPlaying = players.some(p => p.status === 'playing');
  const allDone = n > 0 && players.every(p => p.status === 'completed');
  if (allDone) return { label: 'closed', tone: 'mute' };
  if (anyPlaying) return { label: 'in progress', tone: 'live' };
  if (n >= 4) return { label: 'booked', tone: 'booked' };
  if (n === 0) return { label: 'open', tone: 'open' };
  return { label: `searching for ${4 - n} more`, tone: 'search' };
}

// Avatar stack + the auto status badge for a tee time's field (out of 4).
function FieldStack({ slot, players }) {
  const st = teeStatus(slot, players);
  const tones = {
    open:   { background: 'transparent', color: 'var(--forest)', border: '1px solid var(--forest)' },
    search: { background: 'rgba(14,28,19,0.06)', color: 'var(--ink-soft)', border: '1px solid transparent' },
    booked: { background: 'var(--forest)', color: 'var(--cream)', border: '1px solid var(--forest)' },
    live:   { background: 'var(--forest)', color: 'var(--cream)', border: '1px solid var(--forest)' },
    mute:   { background: 'rgba(14,28,19,0.06)', color: 'var(--ink-soft)', border: '1px solid transparent' },
  };
  const tone = tones[st.tone] || tones.search;
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 10 }} title={players.map(playerName).join(', ') || 'No players yet'}>
      <span style={{
        fontSize: 10, fontFamily: 'var(--font-mono)', fontWeight: 700, letterSpacing: '0.05em', textTransform: 'uppercase',
        padding: '4px 9px', borderRadius: 999, whiteSpace: 'nowrap', display: 'inline-flex', alignItems: 'center', gap: 5, ...tone,
      }}>
        {st.tone === 'live' && <span style={{ width: 6, height: 6, borderRadius: 99, background: 'var(--cream)' }}/>}
        {st.label}
      </span>
      <div style={{ display: 'flex' }}>
        {Array.from({ length: 4 }).map((_, i) => {
          const p = players[i];
          return <div key={i} style={{ marginLeft: i ? -8 : 0 }}>{p ? <Avatar player={p}/> : <EmptySeat/>}</div>;
        })}
      </div>
    </div>
  );
}

function Avatar({ player }) {
  const ring = '2px solid var(--paper)';
  if (player.avatar_url) {
    return <img src={player.avatar_url} alt={playerName(player)} title={playerName(player)}
      style={{ width: 28, height: 28, borderRadius: 999, objectFit: 'cover', border: ring, background: '#ddd' }}/>;
  }
  return (
    <div title={playerName(player)} style={{
      width: 28, height: 28, borderRadius: 999, border: ring, background: 'var(--forest)', color: 'var(--cream)',
      display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontWeight: 700,
    }}>{initials(player)}</div>
  );
}

function EmptySeat() {
  return <div style={{ width: 28, height: 28, borderRadius: 999, border: '1.5px dashed rgba(14,28,19,0.22)', background: 'var(--paper)' }}/>;
}

function playerName(p) {
  return [p.first_name, p.last_name].filter(Boolean).join(' ') || (p.handle ? '@' + String(p.handle).replace(/^@/, '') : 'Player');
}
function initials(p) {
  const a = (p.first_name || '').trim(), b = (p.last_name || '').trim();
  if (a || b) return ((a[0] || '') + (b[0] || '')).toUpperCase();
  const h = (p.handle || '').replace(/^@/, '');
  return (h[0] || '?').toUpperCase();
}

// ─── Daily yardages ──────────────────────────────────────────────────────────
// The draggable marker on a yardage row: a golf flag (SPP monogram on the
// pennant) whose base sits on the line — drag it to set the pin distance.
function FlagThumb({ w = 34, fill = false }) {
  const h = Math.round(w / 34 * 50);
  const wrap = fill
    ? { position: 'relative', width: '100%', filter: 'drop-shadow(0 3px 4px rgba(14,28,19,0.4))' }
    : { position: 'relative', width: w, height: h, filter: 'drop-shadow(0 2px 3px rgba(14,28,19,0.35))' };
  return (
    <div style={wrap}>
      <svg viewBox="0 0 30 44" width={fill ? '100%' : w} height={fill ? 'auto' : h} style={{ display: 'block' }} aria-hidden="true">
        <ellipse cx="7.5" cy="41" rx="6" ry="2" fill="var(--forest-dark)"/>
        <rect x="6" y="3" width="3" height="37" rx="1.5" fill="var(--forest-dark)"/>
        <path d="M9 3 C 19 1 25 6 29 4 C 25 9 27 13 28 16 C 21 13 14 16 9 14 Z" fill="var(--forest)"/>
      </svg>
      <img src="assets/monogram-cream.svg" alt="" style={{ position: 'absolute', left: '58%', top: '19%', transform: 'translate(-50%, -50%)', width: '32%', pointerEvents: 'none' }}/>
    </div>
  );
}

// Front 9 / Back 9 pill switch (shared by both yardage flows).
function NineToggle({ nine, onChange }) {
  const seg = (key, label) => {
    const on = nine === key;
    return (
      <button onClick={() => onChange(key)} style={{
        padding: '6px 16px', borderRadius: 999, border: 'none', cursor: 'pointer',
        fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 800, letterSpacing: '0.04em', textTransform: 'uppercase',
        background: on ? 'var(--cream)' : 'transparent', color: on ? 'var(--forest)' : 'var(--paper)',
        boxShadow: on ? 'var(--shadow-sm)' : 'none', transition: 'background 0.15s ease',
      }}>{label}</button>
    );
  };
  return (
    <div style={{ display: 'inline-flex', gap: 2, background: 'rgba(234,226,206,0.14)', borderRadius: 999, padding: 3, flexShrink: 0 }}>
      {seg('front', 'Front 9')}{seg('back', 'Back 9')}
    </div>
  );
}
const inNine = (h, nine) => nine === 'back' ? h.hole_number > 9 : h.hole_number <= 9;

// One hole: number + par on the left, the flag slider on the shared track,
// and the yardage with ‹ › nudge arrows on the right. A faint tick marks
// the standard distance; dragging the flag sets a custom pin for the day.
function YardageRow({ h, index, onChange, min, max }) {
  const trackRef = React.useRef(null);
  const [drag, setDrag] = React.useState(false);
  const base = (h.base_yards != null && h.base_yards !== '') ? Number(h.base_yards) : null;
  const value = (h.yards != null && h.yards !== '') ? Number(h.yards) : (base != null ? base : Math.round((min + max) / 2));
  const pct = (v) => ((Math.min(max, Math.max(min, v)) - min) / (max - min)) * 100;
  const valFromX = (clientX) => {
    const r = trackRef.current.getBoundingClientRect();
    const f = Math.min(1, Math.max(0, (clientX - r.left) / r.width));
    return Math.round(min + f * (max - min));
  };
  React.useEffect(() => {
    if (!drag) return undefined;
    const move = (e) => { const cx = (e.touches ? e.touches[0] : e).clientX; onChange(index, valFromX(cx)); if (e.cancelable) e.preventDefault(); };
    const up = () => setDrag(false);
    window.addEventListener('mousemove', move);
    window.addEventListener('mouseup', up);
    window.addEventListener('touchmove', move, { passive: false });
    window.addEventListener('touchend', up);
    return () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); window.removeEventListener('touchmove', move); window.removeEventListener('touchend', up); };
  }, [drag]);
  const nudge = (d) => onChange(index, Math.min(max, Math.max(min, value + d)));
  const overridden = (h.yards != null && h.yards !== '') && Number(h.yards) !== base;
  const arrow = { width: 28, height: 28, borderRadius: 'var(--r-xs)', border: '1px solid var(--line-strong)', background: 'var(--surface)', cursor: 'pointer', fontSize: 17, fontWeight: 700, color: 'var(--paper)', lineHeight: 1, flexShrink: 0, transition: 'border-color var(--dur-fast) var(--ease)' };

  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '4px 0' }}>
      <div style={{ width: 44, flexShrink: 0, textAlign: 'center' }}>
        <div style={{ fontFamily: 'var(--font-display)', fontSize: 24, color: 'var(--paper)', lineHeight: 1 }}>{h.hole_number}</div>
        <div style={{ fontSize: 9, fontFamily: 'var(--font-mono)', opacity: 0.5, letterSpacing: '0.06em' }}>PAR {h.par}</div>
      </div>

      <div ref={trackRef}
        onMouseDown={(e) => { onChange(index, valFromX(e.clientX)); setDrag(true); }}
        onTouchStart={(e) => { onChange(index, valFromX(e.touches[0].clientX)); setDrag(true); }}
        style={{ position: 'relative', flex: 1, height: 54, cursor: 'pointer', touchAction: 'none' }}>
        <div style={{ position: 'absolute', left: 0, right: 0, bottom: 15, height: 4, borderRadius: 99, background: 'rgba(234,226,206,0.18)' }}/>
        <div style={{ position: 'absolute', left: 0, bottom: 15, height: 4, borderRadius: 99, background: 'var(--cream)', width: `${pct(value)}%` }}/>
        <div onMouseDown={(e) => { e.stopPropagation(); setDrag(true); }} onTouchStart={(e) => { e.stopPropagation(); setDrag(true); }}
          style={{ position: 'absolute', left: `${pct(value)}%`, bottom: 13, transform: 'translateX(-50%)', cursor: 'grab', zIndex: 2 }}>
          <FlagThumb/>
        </div>
      </div>

      <div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
        <button aria-label="Less" style={arrow} onClick={() => nudge(-1)}>‹</button>
        <div style={{ width: 62, textAlign: 'center' }}>
          <div style={{ fontFamily: 'var(--font-mono)', fontSize: 16, fontWeight: 700, color: overridden ? 'var(--paper)' : 'var(--ink)' }}>
            {value}<span style={{ fontSize: 10, opacity: 0.5 }}> yds</span>
          </div>
          <div style={{ fontSize: 8, fontFamily: 'var(--font-mono)', letterSpacing: '0.06em', color: overridden ? 'var(--cream)' : 'transparent' }}>
            {overridden ? 'CUSTOM' : '·'}
          </div>
        </div>
        <button aria-label="More" style={arrow} onClick={() => nudge(1)}>›</button>
      </div>
    </div>
  );
}

// Plain-English today, e.g. "Wednesday, July 8, 2026".
function todayEnglish() {
  return new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
}

// The green as a 3×3 tic-tac-toe of pin positions (index 0..8, left→right,
// top→bottom). In select mode a tap sets the day's pin to that quadrant's
// pre-measured yardage; in edit mode each cell is an input for the measured
// distance from the tee mat.
// One hole card: number up top, the green as a 3×3 pin selector (no numbers
// shown), the SPP flag planted on the chosen quadrant, and the approximate
// yardage underneath. Forest-green "green" with cream accents = on brand.
function QuadrantCard({ h, editing, draftQuads, onSelect, onEditCell }) {
  const quads = editing ? draftQuads : h.quadrants;
  const has = Array.isArray(quads);
  const sel = editing ? null : h.selectedQuadrant;
  const row = sel != null ? Math.floor(sel / 3) : 0;
  const col = sel != null ? sel % 3 : 0;
  return (
    <div className="card" style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 10 }}>
      <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}>
        <span style={{ fontFamily: 'var(--font-display)', fontSize: 22, color: 'var(--paper)', lineHeight: 1 }}>Hole {h.hole_number}</span>
        <span style={{ fontSize: 9, fontFamily: 'var(--font-mono)', opacity: 0.5 }}>PAR {h.par}</span>
      </div>

      {/* The green — an organic silhouette sitting in a fairway-toned frame,
          instead of a flat rectangle, so it reads as an actual green. The
          organic shape + grid need overflow:hidden to clip cleanly, but the
          flag does NOT — it lives on this outer, unclipped layer so it can
          stick up past the green's edge (a top-row pin) instead of getting
          cut off flush with the boundary. */}
      <div style={{ padding: 9, borderRadius: 'var(--r-md)', background: 'var(--surface-sunken)' }}>
        <div style={{ position: 'relative', aspectRatio: '1.4 / 1' }}>
          <div style={{
            position: 'absolute', inset: 0, overflow: 'hidden',
            borderRadius: '40% 42% 38% 36% / 48% 42% 46% 52%',
            background: 'linear-gradient(155deg, var(--moss-light) 0%, var(--moss) 55%, var(--forest) 100%)',
            border: '1px solid var(--line)', boxShadow: 'inset 0 0 0 1px rgba(234,226,206,0.08)',
          }}>
            {/* tic-tac-toe dividers */}
            {[1, 2].map(k => <div key={'v' + k} style={{ position: 'absolute', top: '12%', bottom: '12%', left: `${k * 33.333}%`, width: 1, background: 'rgba(234,226,206,0.32)' }}/>)}
            {[1, 2].map(k => <div key={'z' + k} style={{ position: 'absolute', left: '10%', right: '10%', top: `${k * 33.333}%`, height: 1, background: 'rgba(234,226,206,0.32)' }}/>)}
            {/* cells */}
            <div style={{ position: 'absolute', inset: 0, display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gridTemplateRows: 'repeat(3, 1fr)', padding: 4, gap: 2 }}>
              {Array.from({ length: 9 }).map((_, q) => {
                if (editing) {
                  const v = has ? quads[q] : '';
                  return (
                    <input key={q} type="number" min="0" max="200" value={v == null ? '' : v}
                      onChange={e => onEditCell(q, e.target.value)}
                      /* No outline:none here — it removes the only cue a
                         keyboard user has about which cell they are in. */
                      style={{ border: 'none', background: 'rgba(234,226,206,0.9)', color: 'var(--forest)', textAlign: 'center', fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 700, borderRadius: 6, minWidth: 0, width: '100%', boxSizing: 'border-box' }}/>
                  );
                }
                const disabled = !has || quads[q] == null || quads[q] === '';
                const on = sel === q;
                return (
                  <button key={q} disabled={disabled} onClick={() => onSelect(q, has ? quads[q] : null)}
                    title={disabled ? 'Not measured yet' : `${quads[q]} yds`} className="quadrant-cell"
                    style={{ border: 'none', borderRadius: 6, background: on ? 'rgba(234,226,206,0.22)' : 'transparent', cursor: disabled ? 'default' : 'pointer', padding: 0, transition: 'background var(--dur-fast) var(--ease)' }}/>
                );
              })}
            </div>
          </div>
          {/* SPP flag planted on the selected quadrant — a soft ground
              shadow first (grounds it, sells the "sticking up" depth), then
              the flag itself. Positioning transform stays on this outer
              element; the rise+fade on quadrant change lives on an INNER
              wrapper instead, so the entrance animation's own transform
              never clobbers the anchor. */}
          {!editing && sel != null && (
            <React.Fragment key={sel}>
              <div className="fade-in" style={{
                position: 'absolute', left: `${(col + 0.5) / 3 * 100}%`, top: `${(row + 0.62) / 3 * 100}%`,
                transform: 'translate(-50%, -50%)', width: '11%', aspectRatio: '2.6 / 1',
                background: 'radial-gradient(ellipse, rgba(14,28,19,0.4) 0%, rgba(14,28,19,0) 72%)',
                pointerEvents: 'none', zIndex: 1,
              }}/>
              <div style={{ position: 'absolute', left: `${(col + 0.5) / 3 * 100}%`, top: `${(row + 0.62) / 3 * 100}%`, transform: 'translate(-50%, -100%)', width: '15%', pointerEvents: 'none', zIndex: 2 }}>
                <div className="rise-in"><FlagThumb fill/></div>
              </div>
            </React.Fragment>
          )}
        </div>
      </div>

      {/* Yardage / hint */}
      <div style={{ textAlign: 'center', minHeight: 30 }}>
        {editing
          ? <span style={{ fontSize: 9, fontFamily: 'var(--font-mono)', opacity: 0.5, textTransform: 'uppercase', letterSpacing: '0.06em' }}>Yards per quadrant</span>
          : (sel != null
              ? <span><span className="metric metric-md">{h.yards != null ? h.yards : '—'}</span><span style={{ fontSize: 11, color: 'var(--ink-faint)', marginLeft: 4 }}>yds</span></span>
              : <span style={{ fontSize: 11.5, color: 'var(--ink-faint)' }}>{has ? 'Tap the pin position' : 'Measure first (Edit)'}</span>)}
      </div>
    </div>
  );
}

function quadrantsFromAnchor(a) {
  const rowOff = [-5, 0, 5];   // front · middle · back
  const colOff = [-1, 0, 1];   // left · center · right
  const out = [];
  for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) out.push(Math.max(10, Math.round(a + rowOff[r] + colOff[c])));
  return out;
}
const holeAnchor = (h) => (h.yards != null && h.yards !== '') ? Number(h.yards) : (h.base_yards != null ? Number(h.base_yards) : 60);

// Pin-placement flow: pick the quadrant each hole's pin is on today. Edit mode
// captures the one-time measured yardages per quadrant.
function PinPlacement({ course, onBack }) {
  const dateStr = todayStr();
  const [holes, reload] = useDailyYardages(course.id, dateStr);
  const [err, setErr] = React.useState('');
  const [nine, setNine] = React.useState('front');

  async function selectQuadrant(hole, q, yards) {
    setErr('');
    try { await setDailyPin(course.id, dateStr, hole.hole_number, q, yards); reload(); }
    catch (e) { setErr(e.message || 'Could not set the pin.'); }
  }

  const measured = (h) => Array.isArray(h.quadrants) && h.quadrants.some(x => x != null && x !== '');
  const hasFront = (holes || []).some(h => h.hole_number <= 9 && measured(h));
  const hasBack = (holes || []).some(h => h.hole_number > 9 && measured(h));

  // Managers only see the nine(s) Sandbox has pre-measured. Default to a
  // measured nine; only show the pill when BOTH nines have quadrants.
  React.useEffect(() => {
    if (!holes) return;
    if (nine === 'front' && !hasFront && hasBack) setNine('back');
    if (nine === 'back' && !hasBack && hasFront) setNine('front');
  }, [hasFront, hasBack]);

  const shown = (holes || []).filter(h => inNine(h, nine) && measured(h));
  const pinsSet = shown.filter(h => h.selectedQuadrant != null).length;

  return (
    <div style={{ maxWidth: 1120, margin: '0 auto' }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap', marginBottom: 18 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
          <button onClick={onBack} className="btn btn-ghost" style={{ padding: '6px 10px', fontSize: 12 }}>‹ Back</button>
          <div>
            <div className="eyebrow">Pin Placement · {todayEnglish()}</div>
            <div style={{ fontFamily: 'var(--font-display)', fontSize: 20, color: 'var(--paper)', marginTop: 2 }}>Where's the pin today?</div>
          </div>
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
          {shown.length > 0 && (
            <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
              <div style={{ width: 60, height: 5, borderRadius: 99, background: 'var(--line)', overflow: 'hidden' }}>
                <div style={{ width: `${Math.round((pinsSet / shown.length) * 100)}%`, height: '100%', background: 'var(--cream)', transition: 'width var(--dur-slow) var(--ease-out)' }}/>
              </div>
              <span style={{ fontSize: 11.5, fontFamily: 'var(--font-mono)', color: 'var(--ink-muted)', whiteSpace: 'nowrap' }}>{pinsSet} of {shown.length} set</span>
            </div>
          )}
          {hasFront && hasBack && <NineToggle nine={nine} onChange={setNine}/>}
        </div>
      </div>

      {err && <div role="alert" style={{ marginBottom: 14, fontSize: 13, color: 'var(--loss-soft)', background: 'rgba(155,58,46,0.18)', padding: '10px 14px', borderRadius: 'var(--r-xs)' }}>{err}</div>}

      {holes === null ? <Spinner/> : (!hasFront && !hasBack) ? (
        <div className="card" style={{ textAlign: 'center', padding: '28px 0' }}>
          <Mascot size={108} style={{ margin: '0 auto 10px' }}/>
          <div style={{ fontSize: 14, opacity: 0.7 }}>This course's pin quadrants haven't been measured yet — Sandbox sets those up.</div>
        </div>
      ) : !shown.length ? (
        <div className="card" style={{ textAlign: 'center', padding: '28px 0', fontSize: 14, opacity: 0.7 }}>
          This course's {nine === 'back' ? 'back' : 'front'} nine isn't measured yet.
        </div>
      ) : (
        <div key={nine} className="rise-in">
          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 14 }}>
            {shown.map(h => (
              <QuadrantCard
                key={h.hole_number}
                h={h}
                editing={false}
                draftQuads={null}
                onSelect={(q, v) => selectQuadrant(h, q, v)}
                onEditCell={() => {}}
              />
            ))}
          </div>
          <div style={{ fontSize: 12, opacity: 0.6, marginTop: 12, textAlign: 'center' }}>
            Tap the quadrant each pin sits on today. Golfers get the pre-measured distance — no rangefinder needed.
          </div>
        </div>
      )}
    </div>
  );
}

// Manual flow: the draggable flag sliders (one row per hole).
function ManualYardages({ course, onBack }) {
  const dateStr = todayStr();
  const [holes, reload] = useDailyYardages(course.id, dateStr);
  const [draft, setDraft] = React.useState(null);
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState('');
  const [msg, setMsg] = React.useState('');

  const [nine, setNine] = React.useState('front');
  const pristine = React.useRef(null);
  React.useEffect(() => {
    const next = holes ? holes.map(h => ({ ...h })) : null;
    setDraft(next);
    pristine.current = next;
  }, [holes]);
  const dirty = useDirty(draft, pristine);
  const setYard = (i, v) => setDraft(d => d.map((h, j) => j === i ? { ...h, yards: v } : h));

  async function save() {
    setBusy(true); setErr(''); setMsg('');
    try {
      await saveDailyYardages(course.id, dateStr, draft);
      pristine.current = draft;
      setMsg('Saved — golfers starting today play these.');
      reload();
    }
    catch (e) { setErr(e.message || 'Could not save.'); }
    setBusy(false);
  }

  return (
    <div style={{ maxWidth: 760, margin: '0 auto' }}>
      <StickySave dirty={dirty} saving={busy} error={err}
        message={!dirty && msg ? msg : ''}
        label="Save yardages" note={`${nine === 'back' ? 'Back' : 'Front'} nine · ${todayEnglish()}`}
        onSave={save} onDiscard={() => { setDraft(pristine.current); setErr(''); }}/>
      <div className="card" style={{ padding: 22 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8, flexWrap: 'wrap' }}>
          <button onClick={onBack} className="btn btn-ghost" style={{ padding: '6px 10px', fontSize: 12 }}>‹ Back</button>
          <div className="eyebrow" style={{ flex: 1 }}>Manual Yardages · {todayEnglish()}</div>
          <NineToggle nine={nine} onChange={setNine}/>
        </div>
        <div style={{ fontSize: 13, opacity: 0.65, marginBottom: 16 }}>
          Drag each hole's flag to set where the pin is today. A match picks these up when the group starts play.
        </div>


        {draft === null ? <Spinner/> : draft.length === 0 ? (
          <div style={{ textAlign: 'center', padding: '12px 0' }}>
            <Mascot size={108} style={{ margin: '0 auto 10px' }}/>
            <div style={{ fontSize: 14, opacity: 0.7 }}>This course has no hole layout yet. Ask an admin to add the Sandbox 9 in Courses.</div>
          </div>
        ) : (
          <>
            <div key={nine} className="rise-in" style={{ display: 'flex', flexDirection: 'column' }}>
              {draft.map((h, i) => ({ h, i })).filter(x => inNine(x.h, nine)).map(({ h, i }, r) => (
                <div key={h.hole_number} style={{ borderTop: r ? '1px solid var(--line-soft)' : 'none' }}>
                  <YardageRow h={h} index={i} onChange={setYard} min={10} max={130}/>
                </div>
              ))}
              {draft.filter(h => inNine(h, nine)).length === 0 && (
                <div style={{ textAlign: 'center', padding: '20px 0', fontSize: 14, opacity: 0.6 }}>
                  This course's {nine === 'back' ? 'back' : 'front'} nine isn't set up yet.
                </div>
              )}
            </div>
            <div style={{ fontSize: 11, opacity: 0.5, marginTop: 10, fontFamily: 'var(--font-mono)', letterSpacing: '0.02em' }}>
              Drag the flag to set each pin · ‹ › nudge by a yard.
            </div>
          </>
        )}
      </div>
    </div>
  );
}

// One selectable row on the Yardages entry screen — an icon, a title, a
// description, and a chevron. Quiet by default, picks up a forest border
// and a slight lift on hover. Replaces the old pair of gradient hero cards.
function YardageModeOption({ icon, title, sub, onClick }) {
  const [hover, setHover] = React.useState(false);
  return (
    <button onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} style={{
      display: 'flex', alignItems: 'center', gap: 16, width: '100%', textAlign: 'left', cursor: 'pointer',
      padding: '18px 20px', borderRadius: 'var(--r-md)', background: 'var(--surface)',
      border: `1px solid ${hover ? 'var(--cream)' : 'var(--line-strong)'}`,
      transform: hover ? 'translateY(-2px)' : 'none',
      transition: 'border-color var(--dur-fast) var(--ease), transform var(--dur-fast) var(--ease)',
    }}>
      <div style={{
        width: 42, height: 42, borderRadius: 999, flexShrink: 0, background: 'var(--surface-sunken)', color: 'var(--cream)',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
      }}>
        <Icon name={icon} size={19}/>
      </div>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ fontSize: 15.5, fontWeight: 700, color: 'var(--paper)' }}>{title}</div>
        <div style={{ fontSize: 13, color: 'var(--ink-faint)', marginTop: 3, lineHeight: 1.45 }}>{sub}</div>
      </div>
      <Icon name="chevron" size={15} style={{
        color: hover ? 'var(--cream)' : 'var(--ink-faint)', flexShrink: 0, transform: `rotate(-90deg) ${hover ? 'translateY(-3px)' : ''}`,
        transition: 'transform var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease)',
      }}/>
    </button>
  );
}

// Entry: choose how to input today's yardages, then route to the flow.
function YardagesPanel({ course }) {
  const [mode, setMode] = React.useState(null); // null | 'manual' | 'pins'
  if (mode === 'manual') return <ManualYardages course={course} onBack={() => setMode(null)}/>;
  if (mode === 'pins') return <PinPlacement course={course} onBack={() => setMode(null)}/>;

  return (
    <div style={{ maxWidth: 560, margin: '0 auto', paddingTop: 20 }}>
      <div style={{ textAlign: 'center', marginBottom: 26 }}>
        <div className="eyebrow" style={{ opacity: 0.55 }}>{todayEnglish()}</div>
        <div className="display" style={{ fontSize: 26, color: 'var(--paper)', marginTop: 8 }}>How do you want to set today's pins?</div>
      </div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
        <YardageModeOption icon="flag" title="Manually" sub="Drag a flag along each hole to set the pin distance yourself." onClick={() => setMode('manual')}/>
        <YardageModeOption icon="pin" title="Pin Placement" sub="Tap which quadrant of the green each pin is on — pre-measured, no rangefinder needed." onClick={() => setMode('pins')}/>
      </div>
    </div>
  );
}

Object.assign(window, { ManagerPortal, QuadrantCard, NineToggle, inNine, quadrantsFromAnchor, holeAnchor });
