// TurnoutPage.jsx
const { useMemo: useMemoT, useState: useStateT } = React;

function TurnoutPage({ store, updateStore, precincts, baseline, baselineKey, baselineTotals, tweaks, setTweak, agg, pace, now, pollsClosed, beforePolls, onPrecinctClick, onBulkOpen, flash }) {
  const [sortBy, setSortBy] = useStateT('id');
  const [sortDir, setSortDir] = useStateT('asc');
  const [filter, setFilter] = useStateT('');
  const [view, setView] = useStateT('cards'); // 'cards' | 'heatmap' | 'table'

  // Compute baseline projected turnout for the current time of day.
  // i.e. given baseline's total ED turnout, how many should we have by now?
  const share = window.shareAt(now);

  // Per-precinct rows
  const rows = useMemo(() => {
    return precincts.map(p => {
      const entries = store.turnoutEntries[p.id] || [];
      const latest = window.latestTurnout(entries);
      const count = latest ? latest.count : null;
      const ts = latest ? latest.ts : null;
      const base = baseline[p.id];
      const baseEday = base ? base.eday : null;
      const reg = base ? base.registered : null;
      // Project this precinct using ITS OWN latest-entry timestamp, not "now".
      // A precinct reported at 11am should be extrapolated using 11am's share,
      // not the share at the current clock time.
      const tsShare = ts ? window.shareAt(new Date(ts)) : share;
      // "expected at the time of last entry" — what the baseline year had by
      // that moment of day. Comparing live count to this is the right
      // pace check; comparing to "expected now" would penalize stale entries.
      const expectedAtTs = base ? Math.round(base.eday * tsShare) : null;
      const projTotal = (count != null && tsShare > 0.02) ? Math.round(count / tsShare) : null;
      const vsBaseTotal = (projTotal != null && baseEday) ? (projTotal - baseEday) / baseEday : null;
      // Suppress wild deltas at very-low expected counts (early morning / tiny
      // precincts) — divisor < 5 produces +5000%-style noise that's not useful.
      const vsBaseNow = (count != null && expectedAtTs != null && expectedAtTs >= 5)
        ? (count - expectedAtTs) / expectedAtTs
        : null;
      const expectedNow = expectedAtTs; // alias kept for component props
      const pctReg = (count != null && reg) ? count / reg : null;
      const eday22 = window.TURNOUT_2022[p.id]?.eday ?? null;
      const eday23 = window.TURNOUT_2023[p.id]?.eday ?? null;
      const ev22   = window.TURNOUT_2022[p.id]?.ev   ?? null;
      const ev23   = window.TURNOUT_2023[p.id]?.ev   ?? null;
      return { p, entries, count, ts, tsShare, base, baseEday, expectedNow, reg, projTotal, vsBaseTotal, vsBaseNow, pctReg, eday22, eday23, ev22, ev23 };
    });
  }, [precincts, store.turnoutEntries, baseline, share]);

  const filtered = useMemo(() => {
    const q = filter.trim().toLowerCase();
    if (!q) return rows;
    return rows.filter(r =>
      r.p.id.toLowerCase().includes(q) ||
      r.p.location.toLowerCase().includes(q) ||
      r.p.city.toLowerCase().includes(q)
    );
  }, [rows, filter]);

  const sorted = useMemo(() => {
    const arr = [...filtered];
    arr.sort((a, b) => {
      let av, bv;
      switch (sortBy) {
        case 'id': av = a.p.id; bv = b.p.id; break;
        case 'location': av = a.p.location; bv = b.p.location; break;
        case 'count': av = a.count ?? -1; bv = b.count ?? -1; break;
        case 'expected': av = a.expectedNow ?? -1; bv = b.expectedNow ?? -1; break;
        case 'vs': av = a.vsBaseNow ?? -999; bv = b.vsBaseNow ?? -999; break;
        case 'proj': av = a.projTotal ?? -1; bv = b.projTotal ?? -1; break;
        case 'baseline': av = a.baseEday ?? -1; bv = b.baseEday ?? -1; break;
        case 'reg': av = a.reg ?? -1; bv = b.reg ?? -1; break;
        case 'ts': av = a.ts ?? ''; bv = b.ts ?? ''; break;
        default: av = 0; bv = 0;
      }
      if (av < bv) return sortDir === 'asc' ? -1 : 1;
      if (av > bv) return sortDir === 'asc' ? 1 : -1;
      return 0;
    });
    return arr;
  }, [filtered, sortBy, sortDir]);

  const sortClick = (key) => {
    if (sortBy === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
    else { setSortBy(key); setSortDir(key === 'id' || key === 'location' ? 'asc' : 'desc'); }
  };

  // Aggregate stats with proper handling of partial reporting.
  // Each reported precinct is projected using its OWN timestamp's share
  // (correcting for stale data), then we scale the confirmed projection by
  // baseline weight to estimate the full matched-precinct EOD.
  const baseStats = useMemo(() => {
    let totalBaseEday = 0;
    let reportedBaseEday = 0;
    let liveCovered = 0;
    let confirmedProj = 0;        // Σ per-precinct projections (reported only)
    let baselineOnlyProj = 0;     // For each matched precinct: projection if reported, baseline_eday if not
    let coveredReported = 0;
    let covered = 0;
    let excludedTooEarly = 0;
    for (const p of precincts) {
      const b = baseline[p.id];
      if (!b) continue;
      covered++;
      totalBaseEday += b.eday;
      const entries = store.turnoutEntries[p.id] || [];
      const latest = window.latestTurnout(entries);
      if (latest) {
        const s = window.shareAt(new Date(latest.ts));
        // Skip entries logged before ~7:15 AM curve — share is too small to
        // produce a meaningful projection and inflates the pace ratio.
        if (s <= 0.02) {
          liveCovered += latest.count;
          excludedTooEarly++;
          baselineOnlyProj += b.eday;
          continue;
        }
        coveredReported++;
        reportedBaseEday += b.eday;
        liveCovered += latest.count;
        const proj = latest.count / s;
        confirmedProj += proj;
        baselineOnlyProj += proj;
      } else {
        baselineOnlyProj += b.eday;
      }
    }

    // Pace ratio: how much above/below baseline are reported precincts pacing?
    const paceRatio = reportedBaseEday > 0 ? confirmedProj / reportedBaseEday : null;
    // Full projection assumes unreported precincts will track the same pace
    const fullProj = paceRatio != null ? Math.round(paceRatio * totalBaseEday) : null;
    // Coverage by baseline weight (more meaningful than count)
    const coverageWeight = totalBaseEday > 0 ? reportedBaseEday / totalBaseEday : 0;

    return {
      totalBaseEday, reportedBaseEday,
      liveCovered: Math.round(liveCovered),
      confirmedProj: Math.round(confirmedProj),
      baselineOnlyProj: Math.round(baselineOnlyProj),
      fullProj,
      paceRatio,
      coverageWeight,
      coveredReported, covered,
      excludedTooEarly,
    };
  }, [precincts, baseline, store.turnoutEntries]);

  const {
    totalBaseEday, reportedBaseEday, liveCovered,
    confirmedProj, baselineOnlyProj, fullProj, paceRatio,
    coverageWeight, coveredReported, covered,
    excludedTooEarly,
  } = baseStats;

  const projVsBaseline = (fullProj != null && totalBaseEday) ? (fullProj - totalBaseEday) / totalBaseEday : null;
  const coverageNote = covered < precincts.length
    ? `${covered}/${precincts.length} precincts in ${baselineKey} baseline`
    : null;
  // Confidence label based on coverage by baseline weight
  const confLabel = coverageWeight >= 0.6 ? 'high'
    : coverageWeight >= 0.3 ? 'medium'
    : coverageWeight >= 0.1 ? 'low'
    : 'very low';

  return (
    <div>
      {/* KPI row */}
      <div className="kpi-row">
        <div className="kpi accent">
          <div className="kpi-label">Live Election Day Total</div>
          <div className="kpi-value">{fmtNum(agg.total)}</div>
          <div className="kpi-sub">{agg.reported} of {precincts.length} precincts reporting</div>
        </div>

        <div className="kpi">
          <div className="kpi-label">
            Reporting Coverage
            <KpiInfo
              text={`${coveredReported} of ${covered} matched precincts have reported at least one entry. By ${baselineKey} weight, that's ${(coverageWeight*100).toFixed(0)}% of the precinct-EDay we're trying to project — i.e. the reported precincts represent ${(coverageWeight*100).toFixed(0)}% of ${baselineKey}'s final Election Day total. Higher weight = more reliable projection.`}
            />
          </div>
          <div className="kpi-value">{(coverageWeight*100).toFixed(0)}%</div>
          <div className="kpi-sub">
            {coveredReported} / {covered} precincts · {fmtNum(reportedBaseEday)} of {fmtNum(totalBaseEday)} {baselineKey} EDay
            <div className="kpi-cov" style={{marginTop:2}}>
              {coveredReported === covered && covered > 0
                ? <span className="up">✓ All matched precincts reporting</span>
                : <>Confidence: <b>{confLabel}</b></>}
            </div>
          </div>
        </div>

        {pollsClosed ? (
          <div className="kpi">
            <div className="kpi-label">Final live total</div>
            <div className="kpi-value">{fmtNum(liveCovered)}</div>
            <div className="kpi-sub">{coveredReported} of {covered} matched precincts reported · projection retired (polls closed)</div>
          </div>
        ) : (() => {
          const COVERAGE_GATE = 0.10;
          const enough = coverageWeight >= COVERAGE_GATE;
          return (
            <div className="kpi">
              <div className="kpi-label">
                Projected close at 7 PM
                <KpiInfo
                  text={`Three-step model. (1) Project each reported precinct using its own latest timestamp's share-of-day — a precinct entered at 11 AM uses 11 AM's curve value, not now's. (2) Sum those into a 'confirmed projection' for the ${coveredReported} reporting precincts → ${fmtNum(confirmedProj)}. (3) Scale to all ${covered} matched precincts by baseline weight: confirmed × (total ${baselineKey} EDay / reported ${baselineKey} EDay) = ${fmtNum(confirmedProj)} × ${reportedBaseEday > 0 ? (totalBaseEday/reportedBaseEday).toFixed(2) : '—'} ≈ ${fullProj != null ? fmtNum(fullProj) : 'TBD'}. We hide the projection until coverage hits 10% of baseline-EDay weight because below that, a single tiny precinct can swing the number wildly. ${excludedTooEarly ? `${excludedTooEarly} entry(s) before ~7:15 AM are excluded from the pace ratio because their share is too small to project meaningfully.` : ''}`}
                />
              </div>
              <div className="kpi-value">{enough && fullProj != null ? fmtNum(fullProj) : '—'}</div>
              <div className={'kpi-sub ' + (enough && projVsBaseline > 0 ? 'up' : enough && projVsBaseline < 0 ? 'down' : '')}>
                {!enough
                  ? <>Need ≥10% coverage <span className="muted">(currently {(coverageWeight*100).toFixed(0)}%)</span></>
                  : paceRatio != null
                    ? <><span aria-hidden="true">{deltaArrow(paceRatio - 1)} </span>Reported running {fmtSignedPct(paceRatio - 1)} vs {baselineKey}</>
                    : <>{baselineKey} EDay: {fmtNum(totalBaseEday)}</>}
                {enough && projVsBaseline != null && (
                  <div className="kpi-cov" style={{marginTop:2}}>
                    {fmtSignedPct(projVsBaseline)} vs {baselineKey} ({fmtNum(totalBaseEday)})
                  </div>
                )}
              </div>
            </div>
          );
        })()}

        <div className="kpi">
          <div className="kpi-label">Early Vote (Banked)</div>
          <div className="kpi-value">{fmtNum(store.evCountywide)}</div>
          <div className="kpi-sub">vs {baselineKey} EV: {fmtNum(baselineTotals.ev)}</div>
        </div>
      </div>

      <div className="kpi-explainer">
        <details>
          <summary>How is the <b>projection</b> calculated?</summary>
          <p style={{marginTop: 6, marginBottom: 6}}>
            We piecemeal in turnout entries across precincts at different times,
            and most of the day we won't have every precinct yet. The projection
            is built in three steps to handle that honestly:
          </p>
          <ol>
            <li>
              <b>Per-precinct projection (timestamp-aware).</b> For each
              reported precinct, project its EOD using <i>its own</i> latest
              entry's share-of-day, not the current clock. A precinct logged at
              11 AM uses 11 AM's curve value (~38%); one logged at 4 PM uses ~76%.
              So count 100 at 11 AM ≈ 263 EOD; count 100 at 4 PM ≈ 132 EOD.
            </li>
            <li>
              <b>Confirmed projection.</b> Sum the per-precinct projections
              across reporting precincts. Currently:
              {' '}<b>{fmtNum(confirmedProj)}</b> across {coveredReported} of {covered} matched
              precincts ({fmtNum(reportedBaseEday)} {baselineKey}-EDay weight,
              {' '}{(coverageWeight*100).toFixed(0)}% coverage).
            </li>
            <li>
              <b>Full projection.</b> Compute the pace ratio = confirmed ÷
              reported-baseline-EDay {paceRatio != null ? <>(<b>{paceRatio.toFixed(2)}×</b>)</> : '(—)'}
              and apply it to the full baseline:{' '}
              {paceRatio != null
                ? <>{paceRatio.toFixed(2)} × {fmtNum(totalBaseEday)} ≈ <b>{fmtNum(fullProj)}</b></>
                : 'TBD until at least one precinct reports'}.
              This assumes unreported precincts will track the same pace as
              reporting ones.
            </li>
          </ol>
          <p style={{marginTop: 8, marginBottom: 4}}><b>Important caveats:</b></p>
          <ul>
            <li>
              The hour-by-hour share is a <b>generic curve</b> (5/13/22/30/38/46/54/62/69/76/84/93/100% at 7 AM through 7 PM), not real 2022/2023 hourly data — we only have year-end totals.
            </li>
            <li>
              The full-projection scaling assumes unreported precincts behave
              like reporters. If reporting is biased — e.g. heavy Lowery
              precincts come in first — the projection is biased too. Watch the
              confidence label: anything below "medium" (30% baseline weight)
              should be treated as a rough sketch.
            </li>
            <li>
              Coverage is measured by baseline-EDay weight, not precinct count,
              because a few big precincts reporting carry more signal than many
              small ones.
            </li>
          </ul>
        </details>
      </div>

      {/* Controls */}
      <div className="section">
        <div className="section-head">
          <h2 className="section-title">Precinct Turnout</h2>
          <div className="section-spacer"></div>
          <div className="row">
            <BaselineToggle value={baselineKey} onChange={(v)=>setTweak('baseline', v)} />
            <input className="input filter-input" placeholder="Search precincts" value={filter} onChange={e=>setFilter(e.target.value)} />
            <div className="view-switch" role="tablist" aria-label="View">
              <button className={'vbtn ' + (view==='cards'?'on':'')} onClick={()=>setView('cards')}>Cards</button>
              <button className={'vbtn ' + (view==='heatmap'?'on':'')} onClick={()=>setView('heatmap')}>Heatmap</button>
              <button className={'vbtn ' + (view==='table'?'on':'')} onClick={()=>setView('table')}>Table</button>
            </div>
            <button className="btn ghost" onClick={onBulkOpen}>Bulk Entry</button>
          </div>
        </div>
        <div className="section-body">
          <JumpToNext rows={sorted} onClick={onPrecinctClick} pollsClosed={pollsClosed} beforePolls={beforePolls} />
          <div className="legend" style={{marginBottom: 12}}>
            <span><span className="legend-swatch" style={{background:'#8E2D26'}}></span>Cold ({tweaks.lowFlagPct}% or worse)</span>
            <span><span className="legend-swatch" style={{background:'#A84B28'}}></span>Below pace</span>
            <span><span className="legend-swatch" style={{background:'#4F5C73'}}></span>On pace</span>
            <span><span className="legend-swatch" style={{background:'#1F8055'}}></span>Above pace</span>
            <span><span className="legend-swatch" style={{background:'#14694A'}}></span>Hot (+{tweaks.highFlagPct}% or better)</span>
            <span style={{marginLeft:'auto'}} className="muted">Tap a precinct to enter or update count · color = projected vs <b>{baselineKey}</b> total</span>
          </div>

          {view === 'cards' && (
            <PrecinctCards rows={sorted} tweaks={tweaks} onClick={onPrecinctClick} baselineKey={baselineKey} now={now} />
          )}
          {view === 'heatmap' && (
            <PrecinctGrid rows={sorted} tweaks={tweaks} onClick={onPrecinctClick} baselineKey={baselineKey} />
          )}
          {view === 'table' && (
            <TurnoutTable rows={sorted} sortBy={sortBy} sortDir={sortDir} sortClick={sortClick} onClick={onPrecinctClick} baselineKey={baselineKey} />
          )}
        </div>
      </div>
    </div>
  );
}

function JumpToNext({ rows, onClick, pollsClosed, beforePolls }) {
  const next = rows.find(r => r.count == null && r.base);
  const remaining = rows.filter(r => r.count == null && r.base).length;
  if (pollsClosed) return null;
  if (beforePolls) return null;
  if (!next) {
    return (
      <div className="jump-bar" style={{borderLeftColor:'var(--vs-success)'}}>
        <div className="jump-msg">All matched precincts have at least one entry. Nice.</div>
      </div>
    );
  }
  return (
    <div className="jump-bar">
      <div className="jump-msg"><b>{remaining}</b> matched precincts unreported · next: {next.p.id} · {next.p.location}</div>
      <button className="btn teal sm" onClick={() => onClick(next.p)}>Open next →</button>
    </div>
  );
}

function deltaArrow(v) {
  if (v == null) return '';
  if (v > 0.005) return '↑';
  if (v < -0.005) return '↓';
  return '·';
}
function tsFreshness(ts, now) {
  if (!ts) return null;
  const ageMs = (now ? now.getTime() : Date.now()) - new Date(ts).getTime();
  if (ageMs < 0) return 'fresh';
  return ageMs < 2 * 60 * 60 * 1000 ? 'fresh' : 'stale';
}

function KpiInfo({ text }) {
  const [open, setOpen] = useStateT(false);
  const ref = React.useRef(null);
  React.useEffect(() => {
    if (!open) return;
    const onDoc = (e) => {
      if (ref.current && !ref.current.contains(e.target)) setOpen(false);
    };
    const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
    document.addEventListener('mousedown', onDoc);
    document.addEventListener('touchstart', onDoc);
    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('mousedown', onDoc);
      document.removeEventListener('touchstart', onDoc);
      document.removeEventListener('keydown', onKey);
    };
  }, [open]);
  return (
    <span className="kpi-info" ref={ref} onClick={(e) => { e.stopPropagation(); setOpen(o => !o); }}>
      <button type="button" className="kpi-info-btn" aria-label="What does this mean?">i</button>
      {open && (
        <span className="kpi-info-pop" role="tooltip" onClick={e=>e.stopPropagation()}>
          {text}
          <button type="button" className="kpi-info-close" onClick={(e) => { e.stopPropagation(); setOpen(false); }} aria-label="Close">×</button>
        </span>
      )}
    </span>
  );
}

function BaselineToggle({ value, onChange }) {
  const meta = {
    '2022': { label: '2022', sub: 'County General · countywide' },
    '2023': { label: '2023', sub: 'Memphis Mayor · 98 / 141 precincts' },
  };
  return (
    <div className="baseline-toggle" role="group" aria-label="Comparison baseline">
      <span className="bt-label">Compare vs</span>
      <button
        className={'bt-btn ' + (value === '2022' ? 'on' : '')}
        onClick={() => onChange('2022')}
        aria-pressed={value === '2022'}
        title={meta['2022'].sub}
      >
        2022
      </button>
      <button
        className={'bt-btn ' + (value === '2023' ? 'on' : '')}
        onClick={() => onChange('2023')}
        aria-pressed={value === '2023'}
        title={meta['2023'].sub}
      >
        2023
      </button>
      <span className="bt-sub">{meta[value].sub}</span>
    </div>
  );
}

function PrecinctCards({ rows, tweaks, onClick, baselineKey, now }) {
  return (
    <div className="precinct-cards">
      {rows.map(r => {
        const has = r.count != null;
        const hasBase = !!r.base;
        const tooEarly = has && hasBase && r.vsBaseNow == null;
        const cls = tempClass(r.vsBaseNow, tweaks);
        // Stripe / fill state for the card. The "no-base-active" modifier
        // applies whenever the *currently selected* baseline year has no
        // data for this precinct — independent of whether a live count
        // has been entered.
        const stateClass = !has ? 'no-data'
          : !hasBase ? 'has-data t-nobase'
          : tooEarly ? 'has-data t-tooearly'
          : 'has-data ' + (cls || 't-mid');
        const noBaseMod = !hasBase ? ' nobase-active' : '';
        const pctReg = r.pctReg != null ? Math.min(100, r.pctReg * 100) : 0;
        return (
          <button
            key={r.p.id}
            className={'pcard ' + stateClass + noBaseMod}
            onClick={() => onClick(r.p)}
            title={`${r.p.id} · ${r.p.location}, ${r.p.city}${!hasBase ? ` · no ${baselineKey} race here` : ''}`}
          >
            {!hasBase && (
              <div className="pcard-corner" aria-hidden="true">
                <span>no '{baselineKey.slice(2)} race</span>
              </div>
            )}
            <div className="pcard-head">
              <span className="pcard-id">{r.p.id}</span>
              <div className="pcard-head-right">
                {has && r.ts && (
                  <span className={'pcard-ts ' + tsFreshness(r.ts, now)} aria-label="Last entry time">
                    ✓ {fmtTime(r.ts)}
                  </span>
                )}
                {has && hasBase && r.vsBaseNow != null && (
                  <span className={'pcard-delta ' + (r.vsBaseNow > 0 ? 'up' : r.vsBaseNow < 0 ? 'down' : 'flat')}>
                    <span aria-hidden="true">{deltaArrow(r.vsBaseNow)}</span> {fmtSignedPct(r.vsBaseNow, 0)}
                  </span>
                )}
                {has && hasBase && r.vsBaseNow == null && (
                  <span className="pcard-delta no" title="Too early to compare against baseline pace">too early</span>
                )}
                {has && !hasBase && (
                  <span className="pcard-delta nobase" title={`No ${baselineKey} data for this precinct`}>
                    no comp
                  </span>
                )}
                {!has && hasBase && <span className="pcard-delta no">no entry</span>}
                {!has && !hasBase && <span className="pcard-delta no">awaiting</span>}
              </div>
            </div>
            <div className="pcard-name">{r.p.location}</div>
            <div className="pcard-city">{r.p.city}</div>
            <div className="pcard-stats">
              <div className="pcard-num">{has ? fmtNum(r.count) : '—'}</div>
              <div className="pcard-vs">
                <div className="pcard-vs-lbl">{baselineKey} total turnout</div>
                <div className="pcard-vs-val">{hasBase ? fmtNum(r.baseEday) : 'n/a'}</div>
              </div>
            </div>
            <div className="pcard-bar" aria-hidden="true">
              <div className="pcard-bar-fill" style={{ width: pctReg + '%' }}></div>
            </div>
            <div className="pcard-history" aria-label="Historical Election Day turnout">
              <div className={'phist ' + (baselineKey === '2022' ? 'active' : '') + (r.eday22 == null ? ' missing' : '')}>
                <span className="phist-yr">'22</span>
                <span className="phist-val">{r.eday22 != null ? fmtNum(r.eday22) : 'no race'}</span>
              </div>
              <div className={'phist ' + (baselineKey === '2023' ? 'active' : '') + (r.eday23 == null ? ' missing' : '')}>
                <span className="phist-yr">'23</span>
                <span className="phist-val">{r.eday23 != null ? fmtNum(r.eday23) : 'no race'}</span>
              </div>
            </div>
            <div className="pcard-foot">
              <span>{r.reg ? fmtPct(r.pctReg, 1) : '—'} of {fmtNum(r.reg)} reg</span>
              <span className="muted">{r.ts ? fmtTime(r.ts) : ''}</span>
            </div>
          </button>
        );
      })}
    </div>
  );
}

function tempClass(vsBase, tweaks) {
  if (vsBase == null) return null;
  const pct = vsBase * 100;
  if (pct <= tweaks.lowFlagPct) return 't-low';
  if (pct < -5) return 't-belowmid';
  if (pct < 5) return 't-mid';
  if (pct < tweaks.highFlagPct) return 't-abovemid';
  return 't-high';
}

function PrecinctGrid({ rows, tweaks, onClick, baselineKey }) {
  return (
    <div className="precinct-grid">
      {rows.map(r => {
        const has = r.count != null;
        const hasBase = !!r.base;
        const tooEarly = has && hasBase && r.vsBaseNow == null;
        const cls = tempClass(r.vsBaseNow, tweaks);
        const stateClass = !has
          ? (!hasBase ? 'no-data t-nobase-empty' : 'no-data')
          : !hasBase ? 'has-data t-nobase'
          : tooEarly ? 'has-data t-tooearly'
          : 'has-data ' + (cls || 't-mid');
        const footText = !has
          ? (!hasBase ? `no '${baselineKey.slice(2)}` : 'no data')
          : tooEarly ? 'too early'
          : !hasBase ? `no '${baselineKey.slice(2)}`
          : fmtSignedPct(r.vsBaseNow, 0);
        return (
          <div
            key={r.p.id}
            className={'pcell ' + stateClass}
            onClick={() => onClick(r.p)}
            title={`${r.p.id} · ${r.p.location}${!hasBase ? ` · no ${baselineKey} race here` : ''}`}
          >
            <div className="pcell-id">{r.p.id}</div>
            {has ? (
              <>
                <div className="pcell-pct">{fmtNum(r.count)}</div>
                <div className="pcell-foot">{footText}</div>
              </>
            ) : (
              <div className="pcell-foot">{footText}</div>
            )}
          </div>
        );
      })}
    </div>
  );
}

function TurnoutTable({ rows, sortBy, sortDir, sortClick, onClick, baselineKey }) {
  const arrow = (key) => sortBy === key ? (sortDir === 'asc' ? ' ↑' : ' ↓') : '';
  return (
    <div style={{overflowX:'auto'}}>
      <table className="tbl">
        <thead>
          <tr>
            <th onClick={()=>sortClick('id')}>Precinct{arrow('id')}</th>
            <th onClick={()=>sortClick('location')}>Location{arrow('location')}</th>
            <th className="num" onClick={()=>sortClick('reg')}>Reg{arrow('reg')}</th>
            <th className="num" onClick={()=>sortClick('count')}>Now{arrow('count')}</th>
            <th className="num" onClick={()=>sortClick('expected')}>Expected{arrow('expected')}</th>
            <th className="num" onClick={()=>sortClick('vs')}>vs Pace{arrow('vs')}</th>
            <th className="num" onClick={()=>sortClick('proj')}>Proj EOD{arrow('proj')}</th>
            <th className="num" onClick={()=>sortClick('baseline')}>{baselineKey} EDay{arrow('baseline')}</th>
            <th onClick={()=>sortClick('ts')}>Last Update{arrow('ts')}</th>
          </tr>
        </thead>
        <tbody>
          {rows.map(r => (
            <tr key={r.p.id} onClick={()=>onClick(r.p)} style={{cursor:'pointer'}}>
              <td><b>{r.p.id}</b></td>
              <td>{r.p.location}<br/><span className="muted" style={{fontSize:11}}>{r.p.city}</span></td>
              <td className="num">{fmtNum(r.reg)}</td>
              <td className="num"><b>{r.count != null ? fmtNum(r.count) : '—'}</b></td>
              <td className="num muted">{fmtNum(r.expectedNow)}</td>
              <td className={'num ' + (r.vsBaseNow == null ? 'delta-zero' : r.vsBaseNow > 0 ? 'delta-pos' : 'delta-neg')}>
                {r.vsBaseNow != null ? fmtSignedPct(r.vsBaseNow, 0) : '—'}
              </td>
              <td className="num">{fmtNum(r.projTotal)}</td>
              <td className="num muted">{fmtNum(r.baseEday)}</td>
              <td className="muted">{r.ts ? fmtTime(r.ts) : '—'}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

Object.assign(window, { TurnoutPage });
