// app-modals.jsx - top-level modal and overlay mounting for App.
// Receives state and handlers from app.jsx; does not create repositories or read storage.

function selectNewestCmjReportSession(sessions) {
  if (!Array.isArray(sessions) || sessions.length === 0) return null;
  return sessions[0] || null;
}

function selectCmjReportSession(sessions, sessionId) {
  if (!Array.isArray(sessions) || sessions.length === 0) return null;
  if (sessionId) {
    const exact = sessions.find(session => session && session.id === sessionId);
    if (exact) return exact;
  }
  return selectNewestCmjReportSession(sessions);
}

function formatReviewedEvidenceDate(value) {
  if (!value) return '—';
  return String(value).slice(0, 10) || '—';
}

function formatReviewedEvidenceTime(value) {
  if (!value) return 'Never saved';
  const date = new Date(value);
  if (Number.isNaN(date.getTime())) return String(value);
  return date.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}

function reviewedEvidenceSignal(row) {
  const text = [
    row.primaryLabel,
    row.secondaryLabel,
    row.noteExcerpt,
    row.testContextLabel,
  ].filter(Boolean).join(' ').toLowerCase();
  const hasAlert = !row.sourceAvailable || row.status === 'stale' || /⚠|alert|flag|missing|stale|gap/.test(text);
  if (/↓|down|drop|decrease|lower|decline|reduced|-\d+(\.\d+)?%/.test(text)) {
    return { text: hasAlert ? '↓ ⚠' : '↓', label: 'Change down', tone: 'var(--neg)' };
  }
  if (/↑|up|increase|higher|improve|improved|\+\d+(\.\d+)?%/.test(text)) {
    return { text: hasAlert ? '↑ ⚠' : '↑', label: 'Change up', tone: 'var(--pos)' };
  }
  if (/stable|maintain|maintained|unchanged|no change/.test(text)) {
    return { text: hasAlert ? 'stable ⚠' : 'stable', label: 'Stable', tone: 'var(--muted)' };
  }
  return { text: hasAlert ? 'context ⚠' : 'context', label: 'Context needed', tone: hasAlert ? 'var(--neg)' : 'var(--muted)' };
}

function reviewedEvidenceHint(row, signal) {
  if (!row.sourceAvailable) return 'Source context gap; this is not a performance conclusion.';
  if (signal.text.includes('↓') || signal.text.includes('↑')) {
    return 'Change noted; compare with adjacent reviewed results before concluding.';
  }
  if (signal.text.includes('stable')) {
    return 'Stable reviewed result; useful as comparison context.';
  }
  return 'Use with nearby reviewed results for context.';
}

function reviewedEvidenceSignalPriority(row) {
  const signal = reviewedEvidenceSignal(row);
  if (signal.text.includes('⚠')) return 4;
  if (signal.text.includes('↓') || signal.text.includes('↑')) return 3;
  if (signal.text.includes('stable')) return 1;
  return 2;
}

function reviewedEvidenceTimestamp(row) {
  const time = Date.parse(row.timestamp || '');
  return Number.isFinite(time) ? time : 0;
}

function decisionReadyEvidenceRows(rows) {
  const latestByAthlete = new Map();
  (Array.isArray(rows) ? rows : []).forEach(row => {
    const athleteKey = row.sourceRef?.athleteId || row.athleteName || row.id;
    const current = latestByAthlete.get(athleteKey);
    if (!current || reviewedEvidenceTimestamp(row) > reviewedEvidenceTimestamp(current)) {
      latestByAthlete.set(athleteKey, row);
    }
  });
  return Array.from(latestByAthlete.values())
    .sort((a, b) => {
      const signalDiff = reviewedEvidenceSignalPriority(b) - reviewedEvidenceSignalPriority(a);
      if (signalDiff) return signalDiff;
      return reviewedEvidenceTimestamp(b) - reviewedEvidenceTimestamp(a);
    })
    .slice(0, 5);
}

function renderEvidenceRow(row, expanded, onToggleExpanded, onAction) {
  const signal = reviewedEvidenceSignal(row);
  const hint = reviewedEvidenceHint(row, signal);
  const signalIsStable = signal.text.includes('stable');
  const signalHasAlert = signal.text.includes('⚠');
  const action = (type) => (event) => {
    event.stopPropagation();
    if (typeof onAction === 'function') onAction(row, type);
  };
  return (
    <div
      className="evidence-row"
      data-evidence-ui-row={row.sourceType}
      key={row.id}
      onClick={onToggleExpanded}
      style={{
        padding: '10px 12px',
        borderBottom: '1px solid var(--line)',
        display: 'grid',
        gap: 7,
        cursor: 'pointer',
        background: signalHasAlert ? 'rgba(239, 68, 68, .08)' : 'transparent',
        opacity: signalIsStable ? .62 : 1,
      }}
    >
      <div style={{ display: 'grid', gap: 3 }}>
        <div style={{ display: 'grid', gap: 4 }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'baseline' }}>
            <div style={{ fontSize: 13, fontWeight: 850, color: 'var(--ink)' }}>{row.athleteName || 'Unknown athlete'}</div>
            <div style={{ fontSize: 13, fontWeight: 900, color: signal.tone, whiteSpace: 'nowrap' }} aria-label={signal.label}>{signal.text}</div>
          </div>
          <div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'baseline' }}>
            <div style={{ fontSize: 10, color: 'var(--muted)' }}>{row.evidenceTypeLabel || row.sourceType}</div>
            <div style={{ fontSize: 9.5, color: 'var(--muted)' }}>{formatReviewedEvidenceDate(row.timestamp)}</div>
          </div>
        </div>
        {expanded && (
          <>
            <div style={{ fontSize: 12, fontWeight: 750, color: 'var(--ink)' }}>{row.primaryLabel || '—'}</div>
            <div style={{ fontSize: 10.5, color: 'var(--muted)' }}>{row.testContextLabel || 'Review context'} · {row.secondaryLabel || '—'}</div>
            <div style={{ fontSize: 10.5, color: 'var(--muted)' }}>{hint}</div>
            {row.noteExcerpt && (
              <div style={{ fontSize: 10.5, color: 'var(--muted)' }}>{row.noteExcerpt}</div>
            )}
          </>
        )}
      </div>

      {expanded && (
        <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
          {(row.actions.openReview && row.actions.openReport) ? (
            <button type="button" className="preset on" onClick={action('report')}>Open Review / Report</button>
          ) : (
            <>
              <button type="button" className="preset on" disabled={!row.actions.openReview} onClick={action('review')}>Open Review</button>
              <button type="button" className="preset" disabled={!row.actions.openReport} onClick={action('report')}>Open Report</button>
            </>
          )}
          <button type="button" className="preset on" disabled={!row.actions.openSource} onClick={action('source')}>Open Source</button>
        </div>
      )}

      {expanded && !row.sourceAvailable && (
        <span style={{ fontSize: 10, color: 'var(--neg)' }}>Source missing</span>
      )}
    </div>
  );
}

function ReviewedEvidenceBrowser({ open, rows, onToggle, onAction }) {
  const evidenceRows = Array.isArray(rows) ? rows : [];
  const decisionReadyRows = React.useMemo(() => decisionReadyEvidenceRows(evidenceRows), [evidenceRows]);
  const [expandedRowId, setExpandedRowId] = React.useState(null);
  const count = evidenceRows.length;
  return (
    <div style={{ position: 'fixed', right: 18, bottom: 18, zIndex: 160, width: open ? 390 : 'auto', maxWidth: 'calc(100vw - 36px)' }}>
      <button
        type="button"
        onClick={onToggle}
        style={{
          width: open ? '100%' : 'auto',
          border: '1px solid var(--line)',
          borderRadius: 8,
          background: 'var(--paper)',
          color: 'var(--ink)',
          padding: '9px 12px',
          fontSize: 12,
          fontWeight: 650,
          boxShadow: '0 10px 30px rgba(15,23,42,.12)',
          display: 'flex',
          justifyContent: 'space-between',
          gap: 12,
        }}
      >
        <span>Reviewed Results</span>
        <span style={{ color: 'var(--muted)', fontWeight: 600 }}>{count}</span>
      </button>
      {open && (
        <div style={{
          marginTop: 8,
          border: '1px solid var(--line)',
          borderRadius: 8,
          background: 'var(--paper)',
          boxShadow: '0 16px 36px rgba(15,23,42,.16)',
          overflow: 'hidden',
        }}>
          <div style={{ padding: '10px 12px', borderBottom: '1px solid var(--line)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
            <div>
              <div style={{ fontSize: 11, fontWeight: 700, color: 'var(--ink)' }}>Reviewed Results</div>
              <div style={{ fontSize: 10, color: 'var(--muted)' }}>Find reviewed results and reopen their source context.</div>
            </div>
            <button type="button" onClick={onToggle} style={{ border: 0, background: 'transparent', color: 'var(--muted)', cursor: 'pointer' }}>✕</button>
          </div>
          <div style={{ maxHeight: 360, overflow: 'auto' }}>
            {count === 0 ? (
              <div style={{ padding: 14, fontSize: 11, color: 'var(--muted)' }}>No reviewed sessions yet.</div>
            ) : (
              <>
              <div data-weekly-coach-reviewed-evidence-section="true" style={{ padding: '8px 12px', borderBottom: '1px solid var(--line)', display: 'grid', gap: 3 }}>
                <div style={{ fontSize: 10, fontWeight: 750, color: 'var(--ink)', textTransform: 'uppercase', letterSpacing: '.06em' }}>Reviewed Results to Check</div>
                <div style={{ fontSize: 10, color: 'var(--muted)' }}>Daily scan · top 5 athletes by recent change signal. Display safety limit, not priority or ranking.</div>
              </div>
              {decisionReadyRows.map(row => renderEvidenceRow(
                row,
                expandedRowId === row.id,
                () => setExpandedRowId(expandedRowId === row.id ? null : row.id),
                onAction,
              ))}
              </>
            )}
          </div>
        </div>
      )}
    </div>
  );
}

// FORCE-4 (2026-07-10): resolveNormDatasetReference removed — its only caller was
// the retired CMJReportModal norm-dataset effect (see the review-machinery note in
// AppModals). Norm knowledge/dataset rendering retired with the modal.

// ── RD-1: report bundle configurator ─────────────────────────────────────
// Operator ticks which reports to include for the current athlete, then one
// click exports them all as a single multi-page print/PDF job (window.doPrintBundle).
// RD-1 is the export MECHANISM only — report bodies render exactly as today.
// Cold `--*` tokens for this chrome (the report bodies keep their warm --paper).
function ReportBundleConfigurator({ reports, onExport, onOpenReport, forceBlocked }) {
  const available = reports.filter(r => r.available);
  const [selected, setSelected] = React.useState(() => new Set(
    available.filter(r => r.defaultOn).map(r => r.id)
  ));
  // Report editing is the primary task. Keep the multi-report export queue docked
  // as a narrow rail until the user explicitly opens it.
  const [compact, setCompact] = React.useState(true);
  React.useEffect(() => {
    const shell = document.querySelector('.app.route-report');
    if (!shell) return undefined;
    shell.classList.toggle('report-bundle-expanded', !compact);
    return () => shell.classList.remove('report-bundle-expanded');
  }, [compact]);
  React.useEffect(() => {
    // Drop any selection that became unavailable (e.g. athlete/session changed).
    setSelected(prev => {
      const next = new Set([...prev].filter(id => available.some(r => r.id === id)));
      return next.size === prev.size ? prev : next;
    });
  }, [reports]); // eslint-disable-line
  const toggle = (id) => setSelected(s => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
  const selCount = selected.size;
  const openReport = (id) => {
    setCompact(true);
    onOpenReport?.(id);
  };
  if (compact) {
    return (
      <button
        type="button"
        className="no-print report-bundle-compact"
        onClick={() => setCompact(false)}
        aria-expanded="false"
        aria-label="展开报告套件"
      >
        <span>报告套件</span><strong>{selCount}</strong><span>展开</span>
      </button>
    );
  }

  return (
    <aside className="no-print report-bundle-configurator" aria-label="报告套件">
      <div className="report-bundle-head">
        <div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)' }}>报告套件 · 一键导出</div>
        <button type="button" onClick={() => setCompact(true)} aria-label="缩略报告套件">缩略</button>
      </div>
      <div style={{ fontSize: 11.5, color: 'var(--muted)', marginBottom: 10, lineHeight: 1.5 }}>
        勾选要合并的报告，一次导出为多页 PDF / 单次打印任务。
      </div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
        {reports.map(r => {
          const on = selected.has(r.id);
          return (
            <div key={r.id} className={`report-bundle-row${on ? ' selected' : ''}${r.available ? '' : ' unavailable'}`} title={r.available ? '' : r.hint} style={{
              display: 'flex', alignItems: 'center', gap: 9, padding: '7px 10px', borderRadius: 7,
              border: `1px solid ${on ? 'var(--accent)' : 'var(--border)'}`,
              background: on ? 'var(--accent-weak, rgba(59,130,246,.08))' : 'var(--panel-2)',
              opacity: r.available ? 1 : 0.5,
            }}>
              <input type="checkbox" disabled={!r.available} checked={on}
                onChange={() => r.available && toggle(r.id)}
                aria-label={`${r.label} 加入导出套件`}
                style={{ accentColor: 'var(--accent)', width: 15, height: 15, flexShrink: 0 }}/>
              <button type="button" className="report-bundle-open" disabled={!r.available} onClick={() => r.available && openReport(r.id)}>
                <span style={{ fontSize: 12.5, color: 'var(--text)' }}>{r.label}</span>
                <span style={{ fontSize: 10.5, color: 'var(--muted)' }}>
                  {r.available ? r.hint : (r.hint || '暂无源报告')}
                </span>
              </button>
              <span className="report-bundle-edit">编辑 ↗</span>
            </div>
          );
        })}
      </div>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, marginTop: 12 }}>
        <span style={{ fontSize: 11, color: 'var(--muted)' }}>
          已选 {selCount} 份 · 约 {selCount} 页
        </span>
        {(() => {
          // FORCE-TRACE M3-B: the button is gated only when the selection actually INCLUDES the
          // force report and its raw curves are still loading — individual/team-only exports are
          // unaffected. handleBundleExport re-checks regardless (the button is not the guard).
          const forceGated = !!forceBlocked && selected.has('force');
          const blocked = selCount === 0 || forceGated;
          return (
            <button
              disabled={blocked}
              onClick={() => !blocked && onExport([...selected])}
              style={{
                fontSize: 12.5, fontWeight: 600, padding: '8px 16px', borderRadius: 8, border: 'none',
                background: blocked ? 'var(--panel-2)' : 'var(--accent)',
                color: blocked ? 'var(--muted-2)' : '#fff',
                cursor: blocked ? 'default' : 'pointer',
              }}>
              {forceGated ? '测力台原图加载中…' : '一键导出报告套件'}
            </button>
          );
        })()}
      </div>
    </aside>
  );
}

function AppModals({
  t,
  setTweak,
  // FORCE-TRACE M3-B: read-only trace accessor + read model for the report surfaces.
  forceTraceRead,
  forceTraceReadModel,
  forceTargetAdapter,
  transitionForceContext,
  settingsOpen,
  setSettingsOpen,
  settingsProps,
  entryOpen,
  setEntryOpen,
  entryDefaultDate,
  setEntryDefaultDate,
  athletes,
  setAthletesViaRepository,
  groups,
  seasons,
  setSeasons,
  selectedId,
  exportOpen,
  setExportOpen,
  athlete,
  season,
  scores,
  overallScore,
  view,
  transitionContext,
  openSettings,
  closeIndividualReportModal,
  openReportPage,
  reportOpen,
  trainingReportData = {},
  batchReportContexts = [],
  individualReportDraftAdapter,
  historySettling = false,
  squadStatsAll,
  cmjReportOpen,
  setCmjReportOpen,
  athletesWithOverall,
  cmjReportAthleteId,
  setCmjReportAthleteId,
  cmjReportSessionId,
  setCmjReportSessionId,
  cmjAthleteId,
  cmjStore,
  sjStore,
  imtpStore,
  setSjAthleteId,
  setImtpAthleteId,
  setReviewSessionId,
  forceSessionReviewRepo,
  assessmentReviewRepo,
  manualMetricReviewRepo,
  reviewedEvidenceSummary,
  sjReviewedEvidenceSummary,
  imtpReviewedEvidenceSummary,
  fieldTestReviewedEvidenceSummary,
  individualReportEvidenceSummary,
  coachEvidenceSummary,
  evidenceUIContract,
  weeklyCoachReviewedEvidenceSummary,
  reviewedEvidenceRows,
  onReviewSaved,
  onOpenFieldTestSource,
  knowledgeRegistry,
  knowledgeRuleValidator,
  teamReportOpen,
  setTeamReportOpen,
  squadStats,
  algo,
  D,
  cmdOpen,
  setCmdOpen,
  setSelectedId,
  setView,
  setCompareMode,
  setAddAthleteOpen,
  setShortHelpOpen,
  cycleVibe,
  shortHelpOpen,
  addAthleteOpen,
  booting,
}) {
  const cmjReportAthleteRef = cmjReportAthleteId || cmjAthleteId;
  const cmjReportAth = athletesWithOverall.find(a => a.id === cmjReportAthleteRef);
  const cmjReportSessions = cmjStore[cmjReportAthleteRef] || [];
  const cmjReportSession = selectCmjReportSession(cmjReportSessions, cmjReportSessionId);

  // ── FORCE-4 (2026-07-10) unified 测力台报告 · REAL report-surface entry ─────────
  // The interim window.__openForceReport dev entry (FORCE-2) is retired. All the
  // former CMJ-source-report link-outs (individual report evidence callout +
  // inspector source card, reviewed-evidence browser, review-database, story
  // evidence, team reviewed-evidence report) already drive the cmjReport* state
  // (athlete + optional target session). That same state now opens the unified
  // ForceReportWorkbench at the CMJ tab (session pre-selected when targeted).
  const forceReportInputs = cmjReportAth
    ? { cmj: cmjStore[cmjReportAth.id] || [], sj: sjStore[cmjReportAth.id] || [], imtp: imtpStore[cmjReportAth.id] || [] }
    : null;
  // FORCE-5c (2026-07-10): thread the full roster + a per-athlete inputs accessor so
  // the workbench's 个体间对比 module can resolve ANOTHER athlete's sessions via
  // ForceSessionSource. The accessor localizes the per-athlete store shape here (the
  // exact { cmj, sj, imtp } arrays forceReportInputs builds for one athlete) so the
  // module only calls forceInputsForAthlete(id) → ForceSessionSource — smallest
  // threading surface. Roster items carry { id, name, jersey } (athletesWithOverall).
  const forceInputsForAthlete = (id) => ({ cmj: cmjStore[id] || [], sj: sjStore[id] || [], imtp: imtpStore[id] || [] });

  const [reviewedEvidenceOpen, setReviewedEvidenceOpen] = React.useState(false);
  const [reviewedEvidenceRefresh, setReviewedEvidenceRefresh] = React.useState(0);
  const [fieldReviewedEvidenceSources, setFieldReviewedEvidenceSources] = React.useState({ plans: [], records: [], testDefs: [] });
  React.useEffect(() => {
    let alive = true;
    const store = window.FieldDataStore;
    if (!store || !assessmentReviewRepo || !fieldTestReviewedEvidenceSummary?.createFieldTestReviewedEvidenceSummaryList || !reviewedEvidenceRows?.loadFieldReviewedEvidenceSources) {
      setFieldReviewedEvidenceSources({ plans: [], records: [], testDefs: [] });
      return () => { alive = false; };
    }
    reviewedEvidenceRows.loadFieldReviewedEvidenceSources({ fieldDataStore: store }).then(next => {
      if (alive) setFieldReviewedEvidenceSources(next);
    }).catch(() => {
      if (alive) setFieldReviewedEvidenceSources({ plans: [], records: [], testDefs: [] });
    });
    return () => { alive = false; };
  }, [assessmentReviewRepo, fieldTestReviewedEvidenceSummary, reviewedEvidenceRows, reviewedEvidenceRefresh, reviewedEvidenceOpen]);
  const reviewedEvidenceModel = React.useMemo(() => {
    if (!reviewedEvidenceRows?.createReviewedEvidenceViewModel) {
      return {
        cmjReviewedEvidence: [],
        sjReviewedEvidence: [],
        imtpReviewedEvidence: [],
        fieldReviewedEvidence: [],
        cmjCoachEvidence: [],
        weeklyRows: [],
      };
    }
    return reviewedEvidenceRows.createReviewedEvidenceViewModel({
      forceSessionReviewRepo,
      assessmentReviewRepo,
      reviewedEvidenceSummary,
      sjReviewedEvidenceSummary,
      imtpReviewedEvidenceSummary,
      fieldTestReviewedEvidenceSummary,
      coachEvidenceSummary,
      evidenceUIContract,
      weeklyCoachReviewedEvidenceSummary,
      cmjStore,
      sjStore,
      imtpStore,
      fieldReviewedEvidenceSources,
      athletes: athletesWithOverall,
      weeklyLimit: 50,
      requireAudited: true,
    });
  }, [
    reviewedEvidenceRows,
    forceSessionReviewRepo,
    assessmentReviewRepo,
    reviewedEvidenceSummary,
    sjReviewedEvidenceSummary,
    imtpReviewedEvidenceSummary,
    fieldTestReviewedEvidenceSummary,
    coachEvidenceSummary,
    evidenceUIContract,
    weeklyCoachReviewedEvidenceSummary,
    cmjStore,
    sjStore,
    imtpStore,
    fieldReviewedEvidenceSources,
    athletesWithOverall,
    reviewedEvidenceRefresh,
    cmjReportOpen,
  ]);
  const reviewedEvidenceSummaries = reviewedEvidenceModel.cmjReviewedEvidence;
  const weeklyCoachReviewedEvidenceSummaries = reviewedEvidenceModel.weeklyRows;
  const openReviewedEvidence = (summary) => {
    if (!summary || !summary.sessionExists) return;
    setCmjReportAthleteId?.(summary.athleteId);
    setCmjReportSessionId(summary.sessionId);
    setCmjReportOpen(true);
    setReviewedEvidenceOpen(false);
  };
  const openSJReviewedEvidenceSource = (summary) => {
    if (!summary || !summary.sourceAvailable) return;
    if (transitionForceContext?.({ athleteId: summary.athleteId, testType: 'sj', sessionId: summary.sessionId, view: 'sj-longitudinal' }).ok) {
      setReviewedEvidenceOpen(false);
    }
  };
  const openIMTPReviewedEvidenceSource = (summary) => {
    if (!summary || !summary.sourceAvailable) return;
    if (transitionForceContext?.({ athleteId: summary.athleteId, testType: 'imtp', sessionId: summary.sessionId, view: 'imtp-longitudinal' }).ok) {
      setReviewedEvidenceOpen(false);
    }
  };
  const openFieldTestReviewedEvidenceSource = (summary) => {
    if (!summary || !summary.sourceAvailable) return;
    const ref = summary.sourceRef || summary.sourceSummary?.sourceAssessmentRef || {
      id: summary.assessmentId,
      assessmentId: summary.assessmentId,
      planId: summary.planId,
      athleteId: summary.athleteId,
      testDefId: summary.testDefId,
    };
    if (typeof onOpenFieldTestSource === 'function') onOpenFieldTestSource(ref);
    else setView?.('field-test');
    setReviewedEvidenceOpen(false);
  };
  const evidenceRowToSourceSummary = (row) => {
    const ref = row?.sourceRef || {};
    return {
      type: row?.sourceType || '',
      athleteId: ref.athleteId || '',
      sessionId: ref.type === 'session' ? ref.id : '',
      assessmentId: ref.assessmentId || (ref.type === 'assessment' ? ref.id : ''),
      planId: ref.planId || '',
      testDefId: ref.testDefId || '',
      sessionExists: !!row?.sourceAvailable,
      sourceAvailable: !!row?.sourceAvailable,
      sourceRef: ref,
    };
  };
  const openReviewedEvidenceRowAction = (row, action) => {
    const summary = evidenceRowToSourceSummary(row);
    const actionHandlers = {
      cmj: { review: openReviewedEvidence, report: openReviewedEvidence },
      sj: { source: openSJReviewedEvidenceSource },
      imtp: { source: openIMTPReviewedEvidenceSource },
      field: { source: openFieldTestReviewedEvidenceSource },
    };
    const handler = actionHandlers[row?.sourceType]?.[action];
    if (handler) handler(summary);
  };
  const individualReportEvidence = React.useMemo(() => {
    if (!individualReportEvidenceSummary?.latestIndividualReportEvidenceSummary || !athlete) return null;
    return individualReportEvidenceSummary.latestIndividualReportEvidenceSummary({
      reviewedEvidence: reviewedEvidenceSummaries,
      sessionsByAthlete: cmjStore,
      athletes: athletesWithOverall,
      athleteId: athlete.id,
      type: 'cmj',
    });
  }, [individualReportEvidenceSummary, reviewedEvidenceSummaries, cmjStore, athletesWithOverall, athlete?.id]);
  // REV-R2: SJ / IMTP / manual confirmed-conclusion evidence for the Individual
  // report. Reuses the already-derived SJ/IMTP reviewed-evidence rows from the
  // shared view model; manual reviews are read directly (no source session).
  const sjReportEvidence = React.useMemo(() => {
    if (!individualReportEvidenceSummary?.buildIndividualReportEvidenceSummary || !athlete) return null;
    return individualReportEvidenceSummary.buildIndividualReportEvidenceSummary({
      reviewedEvidence: reviewedEvidenceModel.sjReviewedEvidence,
      athleteId: athlete.id,
      type: 'sj',
    });
  }, [individualReportEvidenceSummary, reviewedEvidenceModel.sjReviewedEvidence, athlete?.id]);
  const imtpReportEvidence = React.useMemo(() => {
    if (!individualReportEvidenceSummary?.buildIndividualReportEvidenceSummary || !athlete) return null;
    return individualReportEvidenceSummary.buildIndividualReportEvidenceSummary({
      reviewedEvidence: reviewedEvidenceModel.imtpReviewedEvidence,
      athleteId: athlete.id,
      type: 'imtp',
    });
  }, [individualReportEvidenceSummary, reviewedEvidenceModel.imtpReviewedEvidence, athlete?.id]);
  const fieldReportEvidence = React.useMemo(() => {
    if (!individualReportEvidenceSummary?.buildIndividualReportEvidenceSummary || !athlete) return null;
    return individualReportEvidenceSummary.buildIndividualReportEvidenceSummary({
      reviewedEvidence: reviewedEvidenceModel.fieldReviewedEvidence,
      athleteId: athlete.id,
      type: 'field',
    });
  }, [individualReportEvidenceSummary, reviewedEvidenceModel.fieldReviewedEvidence, athlete?.id]);
  const manualReportEvidence = React.useMemo(() => {
    if (!individualReportEvidenceSummary?.buildIndividualReportEvidenceSummary || !athlete || !manualMetricReviewRepo?.list) return null;
    const manualReviews = manualMetricReviewRepo.list({ status: 'reviewed' })
      .filter(review => review?.workflowState === 'confirmed' && review?.sourceSnapshot?.signature)
      .filter(review => review && review.athleteId === athlete.id);
    return individualReportEvidenceSummary.buildIndividualReportEvidenceSummary({
      reviewedEvidence: manualReviews,
      athleteId: athlete.id,
      type: 'manual',
    });
  }, [individualReportEvidenceSummary, manualMetricReviewRepo, athlete?.id, reviewedEvidenceRefresh]);

  // FORCE-4 (2026-07-10): the CMJ force-session-review machinery (cmjForceReview
  // fetch + cmjKnowledge*/cmjTrainingKnowledge*/cmjNormKnowledge* memos +
  // cmjNormDatasetStatus resolve + saveCmjForceReview) was removed with the
  // CMJReportModal it fed. Those props rendered reviewer-workflow classification /
  // knowledge-rule / norm-reference metadata inside the modal's Force Session Review
  // summary — a review/classification surface the unified 测力台报告 deliberately
  // omits (red line: objective raw measured data only, no scoring or inference). Norm data was
  // reviewer-entered knowledge-rule text, NOT a chart norm overlay, so it is
  // retired-with-the-modal (not threaded into cmj-norm). Existing saved reviews
  // still render via reviewedEvidence; new CMJ review authoring is out of scope
  // for this surface. resolveNormDatasetReference (its only caller) also removed.

  // RD-1: team report scores — same derivation the TeamReportModal call uses,
  // lifted to a memo so the bundle staging body reuses it (no recompute).
  const teamReportAthletesWithScores = React.useMemo(() => (
    athletesWithOverall.map(a => ({
      ...a,
      overall: a.overall || 0,
      groupScores: groups.reduce((acc, g) => {
        acc[g.id] = D.computeGroupScore(a.seasons[season] || {}, g, squadStats, algo);
        return acc;
      }, {}),
    }))
  ), [athletesWithOverall, groups, season, squadStats, algo, D]);

  // ── TEAM-3 (2026-07-09): TeamModules-shaped input for the team report (modal +
  // RD-1 bundle staging body). Mirrors TeamWorkbenchView.moduleInput prep in
  // team.jsx — same async preload pattern; TeamModules.js + team.jsx stay
  // zero-diff. Gated to when the team report surface is reachable (modal open or
  // #/report page) so the field-store reads don't run on every individual view.
  const teamDataNeeded = teamReportOpen || view === 'report';
  const teamSortedSeasons = React.useMemo(
    () => [...(seasons || [])].sort((a, b) => +new Date(a) - +new Date(b)), [seasons]);
  const teamWeekRange = React.useMemo(() => {
    const end = season || teamSortedSeasons[teamSortedSeasons.length - 1];
    const d = end ? new Date(end) : null;
    if (!d || Number.isNaN(+d)) return { fromDate: null, toDate: null };
    const start = new Date(d.getFullYear(), d.getMonth(), d.getDate() - 6);
    const iso = (x) => `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, '0')}-${String(x.getDate()).padStart(2, '0')}`;
    return { fromDate: iso(start), toDate: iso(d) };
  }, [season, teamSortedSeasons]);

  const [teamWeekLogsByAthlete, setTeamWeekLogsByAthlete] = React.useState({});
  const [teamPrograms, setTeamPrograms] = React.useState([]);
  const [teamInjuriesByAthlete, setTeamInjuriesByAthlete] = React.useState({});
  const [teamScreensByAthlete, setTeamScreensByAthlete] = React.useState({});

  React.useEffect(() => {
    let alive = true;
    const FS = window.FieldDataStore;
    if (!teamDataNeeded || !FS || typeof FS.listTrainingLogsByAthlete !== 'function' || !teamWeekRange.fromDate) {
      setTeamWeekLogsByAthlete({});
      return () => { alive = false; };
    }
    (async () => {
      try {
        const entries = await Promise.all((athletes || []).map(async (a) => {
          try { return [a.id, await FS.listTrainingLogsByAthlete(a.id, teamWeekRange.fromDate, teamWeekRange.toDate)]; }
          catch { return [a.id, []]; }
        }));
        if (alive) setTeamWeekLogsByAthlete(Object.fromEntries(entries));
      } catch { if (alive) setTeamWeekLogsByAthlete({}); }
    })();
    return () => { alive = false; };
  }, [teamDataNeeded, athletes, teamWeekRange]);

  React.useEffect(() => {
    let alive = true;
    const FS = window.FieldDataStore;
    if (!teamDataNeeded || !FS || typeof FS.listPrograms !== 'function') { setTeamPrograms([]); return () => { alive = false; }; }
    FS.listPrograms().then(p => { if (alive) setTeamPrograms(Array.isArray(p) ? p : []); }).catch(() => { if (alive) setTeamPrograms([]); });
    return () => { alive = false; };
  }, [teamDataNeeded]);

  React.useEffect(() => {
    let alive = true;
    const FS = window.FieldDataStore;
    if (!teamDataNeeded || !FS || typeof FS.listInjuriesByAthlete !== 'function') {
      setTeamInjuriesByAthlete({}); setTeamScreensByAthlete({});
      return () => { alive = false; };
    }
    (async () => {
      try {
        const ok = await FS.healthCheck();
        if (!ok || !alive) return;
        await FS.init('default');
        const inj = {}, scr = {};
        await Promise.all((athletes || []).map(async (a) => {
          try { inj[a.id] = await FS.listInjuriesByAthlete(a.id); } catch { inj[a.id] = []; }
          try { scr[a.id] = await FS.listScreenResultsByAthlete(a.id); } catch { scr[a.id] = []; }
        }));
        if (alive) { setTeamInjuriesByAthlete(inj); setTeamScreensByAthlete(scr); }
      } catch { if (alive) { setTeamInjuriesByAthlete({}); setTeamScreensByAthlete({}); } }
    })();
    return () => { alive = false; };
  }, [teamDataNeeded, athletes]);

  // Per-athlete SWC rows — reuses window.buildSWCRows (swc.jsx) verbatim, same as
  // the individual SWC panel and TeamWorkbenchView; no new stat.
  const teamSwcByAthlete = React.useMemo(() => {
    const build = window.buildSWCRows;
    if (typeof build !== 'function' || !groups.length || !(seasons || []).length) return {};
    const out = {};
    (athletes || []).forEach((a) => {
      try { out[a.id] = (build(a, groups, seasons, squadStatsAll) || {}).rows || []; }
      catch { out[a.id] = []; }
    });
    return out;
  }, [athletes, groups, seasons, squadStatsAll]);

  const teamModuleInput = React.useMemo(() => ({
    athletes: athletesWithOverall,
    reviewedRows: weeklyCoachReviewedEvidenceSummaries,
    trainingLogsByAthlete: teamWeekLogsByAthlete,
    weekRange: teamWeekRange,
    programs: teamPrograms,
    injuriesByAthlete: teamInjuriesByAthlete,
    screensByAthlete: teamScreensByAthlete,
    swcByAthlete: teamSwcByAthlete,
    metricCatalog: window.AthleteMetricCatalog || null,
    selectedMetricId: null,
    cmjStore, sjStore, imtpStore,
    activeSeasonDate: season || teamSortedSeasons[teamSortedSeasons.length - 1] || null,
    fieldDataStore: window.FieldDataStore || null,
  }), [athletesWithOverall, weeklyCoachReviewedEvidenceSummaries, teamWeekLogsByAthlete, teamWeekRange,
       teamPrograms, teamInjuriesByAthlete, teamScreensByAthlete, teamSwcByAthlete, cmjStore, sjStore, imtpStore, season, teamSortedSeasons]);

  // Bundle staging model: default 主教练 preset over the full roster (objective
  // KPI + paper modules, no composite). computeTeamReportModel / presets are owned
  // by report.jsx (window globals) so the modal and the bundle stay in one place.
  const teamBundleModel = React.useMemo(
    () => (window.computeTeamReportModel
      ? window.computeTeamReportModel({ ...teamModuleInput, athletes: teamReportAthletesWithScores })
      : { moduleData: {}, kpi: {} }),
    [teamModuleInput, teamReportAthletesWithScores]);
  const teamBundlePreset = (window.TEAM_REPORT_PRESETS || [])[0] || { sections: ['narrative', 'swc-movers', 'metric-compare'], narrTpl: 'coach' };
  const teamBundleNarrative = window.buildTeamNarrative ? window.buildTeamNarrative(teamBundlePreset.narrTpl || 'coach', teamBundleModel) : '';

  // FORCE-4 (2026-07-10): bundle force source = the report's CURRENT athlete's force
  // sessions across ALL types (CMJ/SJ/IMTP). The three per-type entries (CMJ/SJ/IMTP
  // 源报告) collapse to ONE 测力台报告 entry (direction §2.4). Staging (below) renders
  // one ForceReportPrintBody per active tab (综合 + each type with data) with a
  // distinct bodyId so doPrintBundle clones each as its own page (§2.3 all-tab mode).
  const bundleForceAth = athlete || null;
  const bundleForceInputs = bundleForceAth
    ? { cmj: cmjStore[bundleForceAth.id] || [], sj: sjStore[bundleForceAth.id] || [], imtp: imtpStore[bundleForceAth.id] || [] }
    : null;
  const bundleForceTabs = (bundleForceInputs && window.ForceReportModules)
    ? window.ForceReportModules.availableTabs(bundleForceInputs) : [];
  const bundleForceSelection = (bundleForceInputs && window.ForceSessionSource)
    ? window.ForceSessionSource.defaultSelection(bundleForceInputs) : {};
  const bundleForceEnabled = window.ForceReportModules ? window.ForceReportModules.defaultEnabled() : {};
  // Which test types have sessions → configurator hint.
  const bundleForceTypesWithData = (bundleForceInputs && window.ForceSessionSource)
    ? window.ForceSessionSource.listTestTypes()
        .filter(ty => window.ForceSessionSource.listSessions(bundleForceInputs, ty.id).length)
        .map(ty => ty.label)
    : [];
  const bundleForceAvailable = bundleForceTypesWithData.length > 0;

  // RD-1: report types the picker offers. FORCE-4: single 测力台报告 entry (available
  // when any force type has data; hint lists which types have sessions).
  const bundleReports = [
    { id: 'individual', label: '个人报告 · 沟通件', available: !!athlete, defaultOn: true, hint: athlete ? `${athlete.name} · ${window.formatDate ? window.formatDate(season, 'short') : season}` : '无运动员' },
    { id: 'force', label: '测力台报告', available: bundleForceAvailable, defaultOn: true, hint: bundleForceAvailable ? `${bundleForceTypesWithData.join(' · ')} · 各 tab 一页` : '暂无测力台会话' },
    { id: 'team', label: '团队报告', available: teamReportAthletesWithScores.length > 0, defaultOn: false, hint: `First Team · ${teamReportAthletesWithScores.length} 名运动员` },
  ];

  // ── FORCE-TRACE M3-B fail-closed bundle contract (GPT audit): the staged cmj body reports
  // {forKey, availability, state}; the export computes the key it EXPECTS synchronously and
  // blocks the 'force' report while that key is absent / stale / idle / loading. storage-error
  // unblocks (the honest placeholder prints); legacy 'none' loads nothing; and ONLY a bundle that
  // actually includes 'force' is gated — individual/team-only exports are unaffected. ──
  const bundleCmjTraceState = React.useRef(null);
  const setBundleTraceTick = React.useState(0)[1];
  const noteBundleCmjTraceState = (s) => { bundleCmjTraceState.current = s; setBundleTraceTick((t) => t + 1); };
  const bundleCmjSession = bundleForceSelection ? bundleForceSelection.cmj : null;
  const bundleCmjAvailability = (bundleCmjSession && forceTraceReadModel) ? forceTraceReadModel.deriveAvailability(bundleCmjSession)
    : (bundleCmjSession && Array.isArray(bundleCmjSession.traceRefs) && bundleCmjSession.traceRefs.length ? 'unknown' : 'none');
  const bundleCmjKey = bundleCmjSession
    ? bundleCmjSession.id + '§' + (Array.isArray(bundleCmjSession.traceRefs)
        ? bundleCmjSession.traceRefs.map((r) => (r ? r.id + '@' + r.status + '@' + r.schemaVersion : 'x')).join('|') : '')
    : null;
  const bundleForceCmjBlocked = (() => {
    if (!bundleCmjKey || bundleCmjAvailability === 'none') return false;
    if (!bundleForceTabs.some((ty) => ty.id === 'cmj')) return false;
    const st = bundleCmjTraceState.current;
    if (!st) return true;                                   // never reported → fail-closed
    if (st.forKey !== bundleCmjKey) return true;            // stale report
    return st.state !== 'ready' && st.state !== 'storage-error';
  })();

  // RD-1: collect the (already-rendered) report-body DOM nodes for the selected
  // reports, in a stable order, and hand them to the single-print bundle kernel.
  // The guard lives HERE (not only on the button) so no caller can bypass it.
  const handleBundleExport = (ids) => {
    if (ids && ids.includes('force') && bundleForceCmjBlocked) {
      if (typeof window.alert === 'function') window.alert('测力台报告的原始曲线仍在加载 · 请稍候再导出。');
      return;
    }
    const set = new Set(ids);
    const order = ['individual', 'force', 'team'];
    const nodes = [];
    order.filter(id => set.has(id)).forEach(id => {
      if (id === 'individual') { const n = document.getElementById('report-body'); if (n) nodes.push(n); }
      else if (id === 'force') {
        // FORCE-4: one page per active force tab (综合 + each type with data).
        bundleForceTabs.forEach(ty => { const n = document.getElementById('bundle-force-' + ty.id); if (n) nodes.push(n); });
      }
      else if (id === 'team') { const n = document.getElementById('bundle-team-report-body')?.querySelector('#team-report-body'); if (n) nodes.push(n); }
    });
    if (!nodes.length) return;
    window.doPrintBundle?.(nodes, { title: `${athlete?.name || 'Athlete'} · Report Bundle` });
  };
  const handleBundleOpenReport = (id) => {
    if (id === 'individual') {
      document.querySelector('.report-page-host')?.scrollTo({ top: 0, behavior: 'smooth' });
      document.getElementById('report-modal-root')?.focus?.();
      return;
    }
    if (id === 'force' && athlete) {
      setCmjReportAthleteId(athlete.id);
      setCmjReportSessionId(null);
      setCmjReportOpen(true);
      return;
    }
    if (id === 'team') setTeamReportOpen(true);
  };

  // FORCE-4 (2026-07-10): the CMJ-source-report entries now open the unified
  // ForceReportWorkbench (测力台报告) at the CMJ tab. Driven by the same cmjReport*
  // state every former link-out already sets; when a specific session was targeted
  // (cmjReportSession) it is pre-selected via initialSelection. Session is optional
  // (the force-card 报告 action passes athlete only → default latest session).
  const forceReportInitialTab = cmjReportSession || forceReportInputs?.cmj?.length
    ? 'cmj'
    : (forceReportInputs?.sj?.length ? 'sj' : (forceReportInputs?.imtp?.length ? 'imtp' : 'summary'));
  const cmjReportNode = cmjReportOpen && cmjReportAth && window.ForceReportWorkbench ? (
    <div
      onClick={() => { setCmjReportOpen(false); setCmjReportSessionId(null); }}
      style={{ position: 'fixed', inset: 0, background: 'rgba(5,8,12,.78)', backdropFilter: 'blur(5px)', zIndex: 200, display: 'grid', placeItems: 'center', padding: 24, animation: 'fade .2s ease both' }}
    >
      <window.ForceReportWorkbench
        key={cmjReportAth.id + '·' + (cmjReportSession?.id || '')}
        athlete={cmjReportAth}
        inputs={forceReportInputs}
        roster={athletesWithOverall}
        inputsForAthlete={forceInputsForAthlete}
        initialTab={forceReportInitialTab}
        initialSelection={cmjReportSession ? { cmj: cmjReportSession } : null}
        onClose={() => { setCmjReportOpen(false); setCmjReportSessionId(null); }}
        traceRead={forceTraceRead}
        traceReadModel={forceTraceReadModel}
        forceTargetAdapter={forceTargetAdapter}
      />
    </div>
  ) : null;

  return (
    <>
      <TweaksPanel title="Tweaks">
        <TweakSection label="Layout"/>
        <TweakRadio label="Orientation" value={t.layout} options={['horizontal','vertical']}
                    onChange={(v) => setTweak('layout', v)}/>
        <TweakRadio label="Density" value={t.density} options={['compact','regular']}
                    onChange={(v) => setTweak('density', v)}/>
        <TweakToggle label="Squad band/avg overlay" value={t.showSquadBand}
                     onChange={(v) => setTweak('showSquadBand', v)}/>
        <TweakSection label="Accent"/>
        <TweakColor label="Primary" value={t.accent}
                    options={['#3b82f6','#22d3ee','#34d399','#f59e0b','#a78bfa']}
                    onChange={(v) => setTweak('accent', v)}/>
        <TweakSection label="Vibe"/>
        <TweakRadio label="Tech intensity" value={t.vibe || 'mid'} options={['subtle','mid','bold']}
                    onChange={(v) => setTweak('vibe', v)}/>
        <TweakRadio label="Motion" value={t.motion || 'on'} options={['off','on']}
                    onChange={(v) => setTweak('motion', v)}/>
      </TweaksPanel>

      <SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} {...settingsProps} />

      <DataEntryModal
        open={entryOpen}
        onClose={() => { setEntryOpen(false); setEntryDefaultDate(null); }}
        athletes={athletes} onAthletesChange={setAthletesViaRepository}
        groups={groups}
        seasons={seasons} onSeasonsChange={setSeasons}
        defaultAthleteId={selectedId}
        defaultDate={entryDefaultDate}
      />

      {exportOpen && (
        <ExportModal
          athlete={athlete} season={season}
          scores={scores} overallScore={overallScore}
          onClose={() => setExportOpen(false)}
          onOpenDataExport={() => { setExportOpen(false); openSettings('data'); }}
          reportLabel={view === 'team' ? '团队报告' : '运动员报告'}
          onOpenReport={() => { setExportOpen(false); if (view === 'team') setTeamReportOpen(true); else openReportPage(); }}
        />
      )}

      {reportOpen && view !== 'report' && (
        <ReportModal
          open={reportOpen}
          onClose={() => closeIndividualReportModal?.()}
          athlete={athlete}
          season={season}
          seasons={seasons}
          groups={groups}
          scores={scores}
          squadStatsAll={squadStatsAll}
          reviewedEvidence={individualReportEvidence}
          sjReviewedEvidence={sjReportEvidence}
          imtpReviewedEvidence={imtpReportEvidence}
          fieldReviewedEvidence={fieldReportEvidence}
          manualReviewedEvidence={manualReportEvidence}
          trainingReportData={trainingReportData}
          draftAdapter={individualReportDraftAdapter}
          historySettling={historySettling}
          cmjStore={cmjStore}
          sjStore={sjStore}
          imtpStore={imtpStore}
          batchContexts={batchReportContexts}
          onOpenReviewedEvidence={(summary) => closeIndividualReportModal?.(() => openReviewedEvidence(summary))}
          onOpenReviewedEvidenceReport={(summary) => closeIndividualReportModal?.(() => openReviewedEvidence(summary))}
        />
      )}

      {cmjReportNode}

      {/* RD-1 + FORCE-4: report bundle — configurator on the report surface + hidden
          staging print bodies (测力台报告 per-tab / Team) so their DOM exists for
          doPrintBundle to clone. Individual's #report-body is the live page. */}
      {view === 'report' && (
        <>
          <div style={{ position: 'fixed', top: 0, left: 0, width: 794, visibility: 'hidden', pointerEvents: 'none', zIndex: -1, overflow: 'hidden', height: 0 }} aria-hidden="true">
            {/* FORCE-4: one staged force print body per active tab (综合 + each type
                with data). handleBundleExport('force') collects them in tab order. */}
            {bundleForceAth && bundleForceTabs.map(ty => (
              <ForceReportPrintBody
                key={ty.id}
                bodyId={'bundle-force-' + ty.id}
                athlete={bundleForceAth}
                activeTab={ty.id}
                enabled={bundleForceEnabled}
                selection={bundleForceSelection}
                inputs={bundleForceInputs}
                traceRead={forceTraceRead}
                traceReadModel={forceTraceReadModel}
                onCmjTraceState={ty.id === 'cmj' ? noteBundleCmjTraceState : undefined}
              />
            ))}
            {teamReportAthletesWithScores.length > 0 && (
              <div id="bundle-team-report-body" className="rpt">
                <TeamReportPrintBody
                  squad="First Team"
                  season={season}
                  roster={teamReportAthletesWithScores}
                  moduleData={teamBundleModel.moduleData}
                  sections={teamBundlePreset.sections}
                  narrative={teamBundleNarrative}
                  kpi={teamBundleModel.kpi}
                  paperBg="warm"
                />
              </div>
            )}
          </div>
          <ReportBundleConfigurator reports={bundleReports} onExport={handleBundleExport} onOpenReport={handleBundleOpenReport} forceBlocked={bundleForceCmjBlocked} />
        </>
      )}

      {(view === 'cmj' || view === 'sj' || view === 'imtp' || cmjReportOpen) && (
        <ReviewedEvidenceBrowser
          open={reviewedEvidenceOpen}
          rows={weeklyCoachReviewedEvidenceSummaries}
          onToggle={() => setReviewedEvidenceOpen(v => !v)}
          onAction={openReviewedEvidenceRowAction}
        />
      )}

      {teamReportOpen && (
        <TeamReportModal
          open={teamReportOpen}
          onClose={() => setTeamReportOpen(false)}
          squad="First Team"
          season={season}
          groups={groups}
          athletesWithScores={teamReportAthletesWithScores}
          moduleInput={teamModuleInput}
        />
      )}

      <CommandPalette
        open={cmdOpen} onClose={() => setCmdOpen(false)}
        athletes={athletes}
        onPickAthlete={(id) => transitionContext({ selectedId: id, view: 'individual' })}
        onPickView={(v) => setView(v)}
        onAction={(id) => {
          if      (id === 'compare-toggle') setCompareMode(m => !m);
          else if (id === 'add-athlete')    setAddAthleteOpen(true);
          else if (id === 'open-settings')  openSettings('metrics');
          else if (id === 'open-entry')     setEntryOpen(true);
          else if (id === 'open-export')    setExportOpen(true);
          else if (id === 'toggle-vibe')    cycleVibe();
          else if (id === 'shortcuts')      setShortHelpOpen(true);
        }}
      />
      <ShortcutHelp open={shortHelpOpen} onClose={() => setShortHelpOpen(false)}/>
      <AddAthleteModal
        open={addAthleteOpen}
        onClose={() => setAddAthleteOpen(false)}
        onAdd={(newA) => {
          setAthletesViaRepository(prev => [...prev, newA]);
          setSelectedId(newA.id);
        }}
        athletes={athletes}
        seasons={seasons}
      />
      <FirstPaintBoot on={booting}/>
    </>
  );
}
