// app-view-renderers.jsx - page render payloads for the app shell.
// These functions receive already-composed props from app.jsx and do not own state,
// storage, repositories, route setup, or sports science algorithms.

function AppSettingsRenderer({ settingsProps }) {
  return <SettingsModal asPage open {...settingsProps} />;
}

// FORCE-WS-1a (2026-07-10): getForceLabContext + ForceLabFrame (the retired
// "Force Lab / 力量实验室" frame) were removed — the 测力台工作区 shell
// (window.ForceWorkspace, force-workspace.jsx) now owns the force chrome.

function AppForceRenderer({
  view,
  setView,
  selectedId,
  athletes,
  athletesWithOverall,
  cmjAthleteId,
  setCmjAthleteId,
  sjAthleteId,
  setSjAthleteId,
  imtpAthleteId,
  setImtpAthleteId,
  cmjStore,
  sjStore,
  imtpStore,
  saveCMJSession,
  saveSJSession,
  saveIMTPSession,
  deleteCMJSession,
  deleteSJSession,
  deleteIMTPSession,
  cmjResult,
  setCmjResult,
  sjResult,
  setSjResult,
  imtpResult,
  setImtpResult,
  forceCompareType,
  cmjSessionId,
  setCmjSessionId,
  reviewSessionId,
  setReviewSessionId,
  setCmjReportAthleteId,
  setCmjReportOpen,
  // FORCE-WS-3a (2026-07-12): 采集/分析 capture-mode split + save→分析 jump cue.
  forceCaptureMode,
  setForceCaptureMode,
  forceFreshSession,
  setForceFreshSession,
  // FORCE-TRACE M3-A: read-only trace accessor + read model for the analysis face.
  forceTraceRead,
  forceTraceReadModel,
  forceTargetAdapter,
  transitionForceContext,
  forceSourceFileRead,
}) {
  // I1-C: raw File objects deliberately live only in this mounted Force renderer.
  // Queue rows are plain metadata; leaving the workspace drops the browser handles.
  const [forceBatchItems, setForceBatchItems] = React.useState([]);
  const [forceQueuedFile, setForceQueuedFile] = React.useState(null);
  const [forceReanalysisFile, setForceReanalysisFile] = React.useState(null);
  const forceBatchFilesRef = React.useRef(new Map());
  const forceBatchOrdinalRef = React.useRef(0);

  React.useEffect(() => {
    const activeAthleteId = ({ cmj: cmjAthleteId, sj: sjAthleteId, imtp: imtpAthleteId })[view] || selectedId;
    setForceQueuedFile(current => current && current.type === view && current.athleteId === activeAthleteId ? current : null);
    setForceReanalysisFile(current => current && current.type === view
      && current.target?.athleteId === activeAthleteId ? current : null);
  }, [view, selectedId, cmjAthleteId, sjAthleteId, imtpAthleteId]);

  const addForceBatchFiles = React.useCallback(files => {
    const planner = window.ForceBatchIntakePlanner;
    if (!planner || !files.length) return;
    const offset = forceBatchOrdinalRef.current;
    forceBatchOrdinalRef.current += files.length;
    const metadata = files.map((file, index) => ({
      name: file.name, size: file.size, lastModified: file.lastModified, ordinal: offset + index,
    }));
    const added = planner.planFiles(metadata, athletesWithOverall || athletes || []);
    added.forEach((item, index) => forceBatchFilesRef.current.set(item.id, files[index]));
    setForceBatchItems(previous => previous.concat(added));
  }, [athletes, athletesWithOverall]);

  const updateForceBatchItem = React.useCallback((id, patch) => {
    const planner = window.ForceBatchIntakePlanner;
    if (!planner) return;
    setForceBatchItems(items => items.map(item => item.id === id
      ? planner.updateItem(item, patch, athletesWithOverall || athletes || []) : item));
  }, [athletes, athletesWithOverall]);

  const openForceBatchItem = React.useCallback(id => {
    const item = forceBatchItems.find(row => row.id === id);
    const file = forceBatchFilesRef.current.get(id);
    if (!item || !file || item.status === 'blocked' || item.status === 'completed') return;
    if (!transitionForceContext({ athleteId: item.athleteId, testType: item.type, view: item.type, captureMode: 'collect' }).ok) return;
    setForceBatchItems(rows => rows.map(row => row.id === id ? { ...row, status: 'processing', issues: [] } : row));
    setForceQueuedFile({ itemId: id, file, type: item.type, athleteId: item.athleteId });
  }, [forceBatchItems, transitionForceContext]);

  const markForceBatchProcessed = React.useCallback((itemId, outcome) => {
    setForceBatchItems(rows => rows.map(row => row.id === itemId ? {
      ...row,
      status: outcome && outcome.ok ? 'review_required' : 'error',
      issues: outcome && outcome.ok ? [] : [{ severity: 'blocker', code: 'processing_error', message: (outcome && outcome.error) || '文件读取失败' }],
    } : row));
  }, []);

  const markForceBatchSaved = React.useCallback((type, athleteId) => {
    const active = forceQueuedFile;
    if (!active || active.type !== type) return false;
    setForceBatchItems(rows => rows.map(row => row.id === active.itemId ? { ...row, athleteId: athleteId || row.athleteId, status: 'completed', issues: [] } : row));
    forceBatchFilesRef.current.delete(active.itemId);
    setForceQueuedFile(null);
    return true;
  }, [forceQueuedFile]);

  const removeForceBatchItem = React.useCallback(id => {
    forceBatchFilesRef.current.delete(id);
    setForceBatchItems(rows => rows.filter(row => row.id !== id));
    setForceQueuedFile(active => active && active.itemId === id ? null : active);
  }, []);

  const clearForceBatch = React.useCallback(() => {
    forceBatchFilesRef.current.clear();
    forceBatchOrdinalRef.current = 0;
    setForceBatchItems([]);
    setForceQueuedFile(null);
  }, []);

  // FORCE-WS-1a (2026-07-10): the Force Lab frame + mode subnav is superseded by
  // the 测力台工作区 shell (window.ForceWorkspace, force-workspace.jsx). The shell
  // derives 类型 + 模式 from `view` (the 10 legacy force view ids are the redirect
  // layer) and mounts the EXISTING surfaces unchanged via ForceViewDispatch below.
  return (
    <div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
      <ForceWorkspace
        view={view}
        setView={setView}
        selectedId={selectedId}
        transitionForceContext={transitionForceContext}
        cmjStore={cmjStore}
        sjStore={sjStore}
        imtpStore={imtpStore}
        cmjAthleteId={cmjAthleteId}
        sjAthleteId={sjAthleteId}
        imtpAthleteId={imtpAthleteId}
        captureMode={forceCaptureMode}
        setCaptureMode={setForceCaptureMode}
      >
        <ForceViewDispatch
          view={view}
          selectedId={selectedId}
          forceCaptureMode={forceCaptureMode}
          setForceCaptureMode={setForceCaptureMode}
          forceFreshSession={forceFreshSession}
          setForceFreshSession={setForceFreshSession}
          athletes={athletes}
          athletesWithOverall={athletesWithOverall}
          cmjAthleteId={cmjAthleteId}
          setCmjAthleteId={setCmjAthleteId}
          sjAthleteId={sjAthleteId}
          setSjAthleteId={setSjAthleteId}
          imtpAthleteId={imtpAthleteId}
          setImtpAthleteId={setImtpAthleteId}
          cmjStore={cmjStore}
          sjStore={sjStore}
          imtpStore={imtpStore}
          saveCMJSession={saveCMJSession}
          saveSJSession={saveSJSession}
          saveIMTPSession={saveIMTPSession}
          deleteCMJSession={deleteCMJSession}
          deleteSJSession={deleteSJSession}
          deleteIMTPSession={deleteIMTPSession}
          cmjResult={cmjResult}
          setCmjResult={setCmjResult}
          sjResult={sjResult}
          setSjResult={setSjResult}
          imtpResult={imtpResult}
          setImtpResult={setImtpResult}
          forceCompareType={forceCompareType}
          cmjSessionId={cmjSessionId}
          setCmjSessionId={setCmjSessionId}
          reviewSessionId={reviewSessionId}
          setReviewSessionId={setReviewSessionId}
          setView={setView}
          setCmjReportAthleteId={setCmjReportAthleteId}
          setCmjReportOpen={setCmjReportOpen}
          forceTraceRead={forceTraceRead}
          forceTraceReadModel={forceTraceReadModel}
          forceTargetAdapter={forceTargetAdapter}
          transitionForceContext={transitionForceContext}
          forceSourceFileRead={forceSourceFileRead}
          forceReanalysisFile={forceReanalysisFile}
          setForceReanalysisFile={setForceReanalysisFile}
          forceBatchItems={forceBatchItems}
          forceQueuedFile={forceQueuedFile}
          onForceBatchAdd={addForceBatchFiles}
          onForceBatchUpdate={updateForceBatchItem}
          onForceBatchOpen={openForceBatchItem}
          onForceBatchRemove={removeForceBatchItem}
          onForceBatchClear={clearForceBatch}
          onForceBatchProcessed={markForceBatchProcessed}
          onForceBatchSaved={markForceBatchSaved}
        />
      </ForceWorkspace>
    </div>
  );
}

function ForceViewDispatch({
  view,
  selectedId,
  forceCaptureMode,
  setForceCaptureMode,
  forceFreshSession,
  setForceFreshSession,
  athletes,
  athletesWithOverall,
  cmjAthleteId,
  setCmjAthleteId,
  sjAthleteId,
  setSjAthleteId,
  imtpAthleteId,
  setImtpAthleteId,
  cmjStore,
  sjStore,
  imtpStore,
  saveCMJSession,
  saveSJSession,
  saveIMTPSession,
  deleteCMJSession,
  deleteSJSession,
  deleteIMTPSession,
  cmjResult,
  setCmjResult,
  sjResult,
  setSjResult,
  imtpResult,
  setImtpResult,
  forceCompareType,
  cmjSessionId,
  setCmjSessionId,
  reviewSessionId,
  setReviewSessionId,
  setView,
  setCmjReportAthleteId,
  setCmjReportOpen,
  forceTraceRead,
  forceTraceReadModel,
  forceTargetAdapter,
  transitionForceContext,
  forceSourceFileRead,
  forceReanalysisFile,
  setForceReanalysisFile,
  forceBatchItems,
  forceQueuedFile,
  onForceBatchAdd,
  onForceBatchUpdate,
  onForceBatchOpen,
  onForceBatchRemove,
  onForceBatchClear,
  onForceBatchProcessed,
  onForceBatchSaved,
}) {
  const activeForceRef = React.useRef(null);
  React.useEffect(() => () => { activeForceRef.current = null; }, []);
  activeForceRef.current = {
    view,
    selectedId,
    athleteIds: { cmj: cmjAthleteId, sj: sjAthleteId, imtp: imtpAthleteId },
    stores: { cmj: cmjStore, sj: sjStore, imtp: imtpStore },
  };
  const captureOperation = (type, athleteId, session) => {
    return forceTargetAdapter.captureEffective({
      athleteId, testType: type, session, sessionSource: window.ForceSessionSource,
    }).ticket;
  };
  const operationStillCurrent = (ticket) => {
    const current = activeForceRef.current;
    if (!current) return false;
    const athleteId = current.athleteIds[ticket.testType] || current.selectedId;
    return forceTargetAdapter.validate(ticket, {
      ...ticket,
      athleteId,
      testType: current.view,
    }).ok;
  };
  if (view === 'cmj' || view === 'sj' || view === 'imtp') {
    // FORCE-WS-1a (2026-07-10): the CMJ/SJ/IMTP type-tab strip is retired — the
    // 测力台工作区 header pills (force-workspace.jsx) are now the single type switcher.
    //
    // FORCE-WS-3a (2026-07-12): 采集/分析 split (F3). 分析 mode mounts the NEW
    // ForceAnalysisFace (force-workspace.jsx — reads STORED sessions, reuses the
    // window-exposed cmj.jsx charts; keyed by view so type-switch resets its per-type
    // module/session state). 采集 mode keeps the EXISTING upload panels byte-unchanged;
    // their save callback is only OBSERVED (a thin wrapper) to jump to 分析 with the
    // just-saved session + a 刚保存 cue — the panel save LOGIC is untouched.
    if (forceCaptureMode === 'analyze') {
      return (
        <ForceAnalysisFace
          key={'faf-' + view}
          typeId={view}
          athleteId={selectedId}
          roster={athletesWithOverall}
          stores={{ cmj: cmjStore, sj: sjStore, imtp: imtpStore }}
          fresh={forceFreshSession}
          onConsumeFresh={() => setForceFreshSession(null)}
          onBackToCollect={() => setForceCaptureMode('collect')}
          onOpenReport={(athleteId) => { setCmjReportAthleteId(athleteId); setCmjReportOpen(true); }}
          traceRead={forceTraceRead}
          traceReadModel={forceTraceReadModel}
          onDeleteSession={({ cmj: deleteCMJSession, sj: deleteSJSession, imtp: deleteIMTPSession })[view]}
          onReanalyzeSession={view === 'cmj' && forceSourceFileRead ? async (session) => {
            const currentAtStart = activeForceRef.current;
            const targetAthleteId = currentAtStart.athleteIds.cmj || currentAtStart.selectedId;
            const ticket = captureOperation('cmj', targetAthleteId, session);
            const file = await forceSourceFileRead.toFile(session.id);
            if (!file) return { ok: false, reason: 'missing' };
            const current = activeForceRef.current;
            const currentSession = (current.stores.cmj[current.athleteIds.cmj || current.selectedId] || [])
              .find(item => item.id === ticket.sessionId);
            if (!operationStillCurrent(ticket)
              || !forceTargetAdapter.validateSession(ticket, currentSession ? { ...currentSession, testType: 'cmj' } : null).ok) {
              return { ok: false, reason: 'target_changed' };
            }
            setForceReanalysisFile({ target: ticket, sessionId: session.id, file, type: 'cmj' });
            setForceCaptureMode('collect');
            return { ok: true };
          } : null}
        />
      );
    }
    const afterSave = (ticket, session) => {
      if (!operationStillCurrent(ticket)) return false;
      setForceCaptureMode('analyze');
      setForceFreshSession({ type: ticket.testType, id: (session && session.id != null) ? session.id : null, target: ticket });
      return true;
    };
    return (
      <div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
        <ForceIntakeQueue
          items={forceBatchItems}
          athletes={athletesWithOverall}
          onAddFiles={onForceBatchAdd}
          onUpdate={onForceBatchUpdate}
          onOpen={onForceBatchOpen}
          onRemove={onForceBatchRemove}
          onClear={onForceBatchClear}
        />
        <div style={{ flex: 1, overflow: 'auto' }}>
          {view === 'cmj' && <CMJPanel
            athletes={athletesWithOverall}
            defaultAthleteId={cmjAthleteId}
            cmjStore={cmjStore}
            onSaveSession={(athleteId, session, built, sourceFile) => {
              const ticket = captureOperation('cmj', athleteId, session);
              return saveCMJSession(athleteId, session, built, sourceFile, ticket).then(r => {
                if (r && r.persisted && !onForceBatchSaved('cmj', athleteId)) afterSave(ticket, r.session || session);
                return r;
              });
            }}
            result={cmjResult}
            onResultChange={setCmjResult}
            queuedFile={forceQueuedFile}
            reanalyzeFile={forceReanalysisFile}
            onReanalyzeFileProcessed={() => setForceReanalysisFile(null)}
            onQueuedFileProcessed={onForceBatchProcessed}
            onExportReport={(athleteId) => { setCmjReportAthleteId(athleteId); setCmjReportOpen(true); }}
          />}
          {view === 'sj' && <SJPanel
            athletes={athletesWithOverall}
            sjStore={sjStore}
            cmjStore={cmjStore}
            onSaveSession={(athleteId, session) => {
              const ticket = captureOperation('sj', athleteId, session);
              return Promise.resolve(saveSJSession(athleteId, session, ticket)).then(r => {
                if (r && r.persisted && !onForceBatchSaved('sj', athleteId)) afterSave(ticket, r.session || session);
                return r;
              });
            }}
            defaultAthleteId={sjAthleteId}
            result={sjResult}
            onResultChange={setSjResult}
            queuedFile={forceQueuedFile}
            onQueuedFileProcessed={onForceBatchProcessed}
          />}
          {view === 'imtp' && <IMTPPanel
            athletes={athletesWithOverall}
            imtpStore={imtpStore}
            onSaveSession={(athleteId, session) => {
              const ticket = captureOperation('imtp', athleteId, session);
              return Promise.resolve(saveIMTPSession(athleteId, session, ticket)).then(r => {
                if (r && r.persisted && !onForceBatchSaved('imtp', athleteId)) afterSave(ticket, r.session || session);
                return r;
              });
            }}
            defaultAthleteId={imtpAthleteId}
            result={imtpResult}
            onResultChange={setImtpResult}
            queuedFile={forceQueuedFile}
            onQueuedFileProcessed={onForceBatchProcessed}
          />}
        </div>
      </div>
    );
  }

  if (view === 'force-compare') {
    return (
      <ForceCompareView
        athletes={athletesWithOverall}
        initialTestType={forceCompareType}
        cmjStore={cmjStore} sjStore={sjStore} imtpStore={imtpStore}
        onDeleteSession={{ cmj: deleteCMJSession, sj: deleteSJSession, imtp: deleteIMTPSession }}
        onNavigate={(type, aid) => {
          transitionForceContext({ athleteId: aid, testType: type, view: type });
        }}
      />
    );
  }

  if (view === 'cmj-session') {
    return (
      <CMJSessionDetail
        athlete={athletes.find(a => a.id === cmjAthleteId)}
        session={(cmjStore[cmjAthleteId] || []).find(s => s.id === cmjSessionId)}
        onBack={() => setView('individual')}
      />
    );
  }

  // FORCE-WS-2a (2026-07-11): the three per-type longitudinal mounts are superseded
  // by ONE workspace-owned <ForceLongitudinalBoard> (window global, force-workspace.jsx)
  // — a 纵向多卡仪表盘 (级联指标卡 grid + 横向/趋势 双视图 + 页级共享对比运动员 + 阈值红点
  // + 布局持久化), replacing the WS-1b single-chart view. The 3 legacy view ids still
  // land here (redirects intact); (type, current-athlete) is derived per id and threaded
  // as the board's entry type + 本人. CMJ/SJ/IMTPLongitudinalView stay defined +
  // window-exposed in their files (untouched) as future-cleanup candidates.
  if (view === 'cmj-longitudinal' || view === 'sj-longitudinal' || view === 'imtp-longitudinal') {
    const typeId = view === 'cmj-longitudinal' ? 'cmj' : view === 'sj-longitudinal' ? 'sj' : 'imtp';
    // FORCE-WS-2b (2026-07-11, ruling A): the board's 本人 follows the GLOBAL selected
    // athlete (NAV-2 context bar), not the force-panel-local cmj/sj/imtpAthleteId — so the
    // longitudinal board's self matches who you have selected app-wide.
    return (
      <ForceLongitudinalBoard
        typeId={typeId}
        athleteId={selectedId}
        roster={athletesWithOverall}
        stores={{ cmj: cmjStore, sj: sjStore, imtp: imtpStore }}
        traceReadModel={forceTraceReadModel}
      />
    );
  }

  if (view === 'dsi') {
    return (
      <DSIPanel
        athletes={athletesWithOverall}
        cmjStore={cmjStore}
        sjStore={sjStore}
        imtpStore={imtpStore}
        onBack={() => setView('individual')}
      />
    );
  }

  return null;
}

function AppCalendarRenderer(props) {
  return <CalendarPage {...props} />;
}

function AppFieldTestRenderer(props) {
  return <FieldTestView {...props} />;
}

function AppReviewDatabaseRenderer(props) {
  return <ReviewDatabasePage {...props} />;
}

function AppTrainingRenderer(props) {
  return <TrainingView {...props} />;
}

function AppInjuryRenderer({
  view,
  setView,
  athlete,
  groups,
  seasons,
  patchAthleteMetrics,
  injuryCtx,
  setInjuryCtx,
}) {
  const routeCopy = view === 'rehab'
    ? ['康复训练', '把处方、现场执行、进度评估与病例证据放在同一条工作流。']
    : view === 'movement-screen'
      ? ['动作筛查', '记录动作质量与人工观察，保留可追溯的筛查上下文。']
      : ['损伤筛查', '登记病例事实、阶段和评估记录，不在界面中自动作医学判断。'];
  return (
    <div className="adaptation-workbench-shell">
      <header className="adaptation-workbench-head">
        <div className="adaptation-workbench-title">
          <span>ADAPTATION WORKSPACE</span>
          <h1>{routeCopy[0]}</h1>
          <p>{routeCopy[1]}</p>
        </div>
        <nav data-injury-workflow-nav className="adaptation-workflow-tabs" aria-label="损伤与康复工作流">
          {[
            { to: 'injury', label: '损伤筛查' },
            { to: 'rehab', label: '康复训练' },
            { to: 'movement-screen', label: '动作筛查' },
          ].map(t => {
            const on = view === t.to;
            return <button key={t.to} className={on ? 'on' : ''} aria-current={on ? 'page' : undefined} onClick={() => { if (t.to === 'injury') setInjuryCtx(null); setView(t.to); }}>{t.label}</button>;
          })}
        </nav>
      </header>
      <div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
        {view === 'injury' || view === 'rehab' ? (
          <InjuryView
            athlete={athlete}
            groups={groups}
            seasons={seasons}
            patchAthleteMetrics={patchAthleteMetrics}
            initialInjuryId={injuryCtx}
            workflowMode={view === 'rehab' ? 'rehab' : 'screening'}
            onBack={() => { setInjuryCtx(null); setView('individual'); }}
          />
        ) : (
          <MovementScreenView
            athlete={athlete}
            groups={groups}
            seasons={seasons}
            onBack={() => setView('individual')}
          />
        )}
      </div>
    </div>
  );
}

function AppNormsRenderer(props) {
  return <NormsView {...props} />;
}

function AppKnowledgeRenderer(props) {
  const KnowledgePage = typeof window !== 'undefined' ? window.KnowledgeLibraryPage : null;
  return KnowledgePage ? <KnowledgePage {...props} /> : null;
}

function AppTeamRenderer(props) {
  // TEAM-4 (2026-07-09): legacy TeamDashboard deleted (ruling §6③) — stale
  // fallback reference removed; null is the honest fallback if the workbench
  // global is ever missing.
  const TeamWorkbench = (typeof window !== 'undefined' && window.TeamWorkbench) || null;
  return TeamWorkbench ? <TeamWorkbench {...props} /> : null;
}

function AppReportRenderer(props) {
  return <ReportModal open mode="page" {...props} />;
}

// MB-3: catalog-backed metric picker for the Trend module (max 2). Selected
// metrics render as origin-badged chips (力板 badge for force) with a × to remove
// (kept ≥1); a ＋ opens the shared window.MetricCatalogPicker to add/swap. Reuses
// the exact picker MB-2 built so the trends picker and the status-board picker
// are visually identical. `metrics` carries per-slot {id,label,origin,color}.
function AthleteStoryTrendPicker({ entries, metrics, selectedIds, onAdd, onRemove, disableRemove }) {
  const [open, setOpen] = React.useState(false);
  const anchorRef = React.useRef(null);
  const Picker = window.MetricCatalogPicker;
  const atMax = selectedIds.length >= 2;
  const forceBadge = (origin) => origin && origin !== 'seasons' ? (
    <span style={{ fontSize: 7.5, fontWeight: 700, background: 'var(--force-bg, #ecebfb)', color: 'var(--force-fg, #4f46c9)', borderRadius: 4, padding: '1px 4px' }}>力板</span>
  ) : null;
  return (
    <div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
      {metrics.map((m) => (
        <span key={m.id} style={{
          fontSize: 11, padding: '3px 8px', borderRadius: 6,
          border: '1px solid var(--border)', color: 'var(--text-2)', background: 'var(--panel)',
          display: 'inline-flex', alignItems: 'center', gap: 5,
        }}>
          <span style={{ width: 8, height: 8, borderRadius: 2, background: m.color }}/>
          {m.label}
          {forceBadge(m.origin)}
          {!disableRemove && selectedIds.length > 1 && (
            <span onClick={() => onRemove(m.id)} title="移除" style={{ cursor: 'pointer', color: 'var(--muted)', marginLeft: 2, fontSize: 12, lineHeight: 1 }}>×</span>
          )}
        </span>
      ))}
      {!disableRemove && (
        <span ref={anchorRef} style={{ position: 'relative', display: 'inline-flex' }}>
          <span onClick={() => { if (!atMax) setOpen(o => !o); }} title={atMax ? '已达 2 条上限' : '添加指标'} style={{
            fontSize: 11, padding: '3px 9px', borderRadius: 6,
            border: '1px solid var(--border)', background: 'var(--panel)',
            color: atMax ? 'var(--muted-2)' : 'var(--muted)',
            cursor: atMax ? 'default' : 'pointer', opacity: atMax ? 0.55 : 1,
          }}>＋</span>
          {open && !atMax && Picker && (
            <Picker entries={entries} current={null} excludeIds={selectedIds} align="right"
              onPick={(id) => { onAdd(id); setOpen(false); }} onClose={() => setOpen(false)} anchorRef={anchorRef}/>
          )}
        </span>
      )}
    </div>
  );
}

const ATHLETE_STORY_RANGE_OPTIONS = [
  { id: '90d', label: '90天', days: 90 },
  { id: '180d', label: '180天', days: 180 },
  { id: '1y', label: '1年', days: 365 },
  { id: 'all', label: '全部', days: null },
];

function AthleteStoryRangeToggle({ value, onChange }) {
  return (
    <div data-l1-range-toggle style={{ display: 'inline-flex', padding: 2, border: '1px solid var(--border)', borderRadius: 7, background: 'var(--panel-2)' }}>
      {ATHLETE_STORY_RANGE_OPTIONS.map(option => (
        <button key={option.id} type="button" onClick={() => onChange(option.id)} style={{
          border: 0, borderRadius: 5, padding: '4px 7px', fontSize: 10, cursor: 'pointer',
          color: value === option.id ? 'var(--text)' : 'var(--muted)',
          background: value === option.id ? 'var(--panel)' : 'transparent',
          fontWeight: value === option.id ? 700 : 500,
        }}>{option.label}</button>
      ))}
    </div>
  );
}

function AthleteStoryLongitudinalRecords({ series }) {
  const sourceNames = {
    legacy_import: 'Excel 导入', field_test: '现场测试', force_plate_upload: '测力台上传',
    manual: '手动录入', historical: '历史记录',
  };
  const stateNames = { confirmed: '已确认', stored: '已保存', historical: '历史记录' };
  const detailNames = {
    location: '源单元格', format: '文件格式', planId: '测试计划', recordId: '记录 ID',
    confirmationRevision: '确认版本', confirmedAt: '确认时间', sessionId: '会话 ID',
    trialIndex: 'Trial', fileName: '文件', mode: '导入模式', algorithmVersion: '算法版本',
    sampleRate: '采样率', samplingStatus: '采样状态', savedAt: '保存时间',
  };
  const records = (series || []).flatMap(metric => (metric.values || []).map(point => ({
    ...point, metricLabel: metric.label, unit: metric.unit, color: metric.color,
  }))).sort((a, b) => +new Date(b.x) - +new Date(a.x)).slice(0, 6);

  if (!records.length) return null;
  return (
    <div data-l1-source-records style={{ marginTop: 14, borderTop: '1px solid var(--border)', paddingTop: 12 }}>
      <div style={{ fontSize: 11, fontWeight: 750, color: 'var(--text)', marginBottom: 7 }}>最近记录与来源</div>
      <div style={{ display: 'grid', gap: 6 }}>
        {records.map((record, index) => {
          const details = Object.entries(record.sourceDetail || {}).filter(([, value]) => value !== null && value !== undefined && value !== '');
          const sourceName = sourceNames[record.sourceType] || record.sourceLabel || '已保存记录';
          const stateName = stateNames[record.sourceState] || '已保存';
          return (
            <details key={`${record.metricLabel}-${record.x}-${index}`} style={{ border: '1px solid var(--border)', borderRadius: 7, background: 'var(--panel-2)', padding: '7px 9px' }}>
              <summary style={{ cursor: details.length ? 'pointer' : 'default', listStyle: 'none', display: 'grid', gridTemplateColumns: '82px minmax(120px,1fr) auto auto', gap: 8, alignItems: 'center', fontSize: 10.5 }}>
                <span style={{ color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>{record.x}</span>
                <span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                  <i style={{ display: 'inline-block', width: 7, height: 7, borderRadius: 2, background: record.color, marginRight: 6 }}/>{record.metricLabel}
                </span>
                <b style={{ fontFamily: 'var(--font-mono)', fontVariantNumeric: 'tabular-nums' }}>{record.y}{record.unit ? ` ${record.unit}` : ''}</b>
                <span style={{ color: record.sourceState === 'confirmed' ? 'var(--positive)' : 'var(--muted)' }}>{sourceName} · {stateName}</span>
              </summary>
              {details.length > 0 && (
                <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(150px,1fr))', gap: '5px 14px', marginTop: 8, paddingTop: 8, borderTop: '1px solid var(--border)', fontSize: 10, color: 'var(--muted)' }}>
                  {details.map(([key, value]) => <span key={key}><b style={{ color: 'var(--text)', fontWeight: 650 }}>{detailNames[key] || key}：</b>{String(value)}</span>)}
                </div>
              )}
            </details>
          );
        })}
      </div>
    </div>
  );
}

function AthleteGuardianConsent({ athlete, onConfirm }) {
  const profile = window.AthleteProfile;
  if (!profile?.isMinor(athlete)) return null;
  const confirmed = athlete?.guardianConsent?.status === 'confirmed';
  const confirmCopy = '请确认：您已获得该未成年运动员监护人对本系统内资料记录、训练监控与报告使用的有效许可。此确认仅记录工作流状态，不替代所在地区适用的书面授权、隐私告知或其他法律要求。';
  const confirmConsent = () => {
    if (confirmed || !window.confirm(confirmCopy)) return;
    onConfirm?.({
      status: 'confirmed',
      confirmedAt: Date.now(),
      statementVersion: 1,
    });
  };
  return (
    <div data-athlete-guardian-consent data-status={confirmed ? 'confirmed' : 'pending'} className="athlete-guardian-consent">
      <div>
        <strong>{confirmed ? '监护人许可已确认' : '未成年人资料授权待确认'}</strong>
        <p>
          {confirmed
            ? `确认时间：${window.formatDate ? window.formatDate(athlete.guardianConsent.confirmedAt, 'short') : athlete.guardianConsent.confirmedAt}`
            : '在记录、分析或导出该运动员资料前，请核对监护人许可及适用的隐私要求。'}
        </p>
      </div>
      <button
        type="button"
        className="btn athlete-guardian-consent-action"
        aria-pressed={confirmed}
        disabled={confirmed}
        onClick={confirmConsent}
      >
        {confirmed ? '✓ 已确认监护人许可' : '确认监护人许可'}
      </button>
    </div>
  );
}

function AppIndividualRenderer({
  D,
  compareColors,
  isHoriz,
  t,
  athlete,
  athletes,
  athletesWithOverall,
  groups,
  seasons,
  season,
  setSeason,
  selectedId,
  setSelectedId,
  compareAthletes,
  compareMode,
  compareIds,
  toggleCompare,
  setCompareMode,
  setExportOpen,
  openSettings,
  setEntryOpen,
  openReportPage,
  algo,
  lang,
  toggleLang,
  overallScore,
  posAvgScore,
  athleteRisk,
  tilesByAthlete,
  setTilesByAthlete,
  trendMetricIds,
  addTrendMetric,
  removeTrendMetric,
  squadStatsAll,
  squadAvgScores,
  prevScores,
  scoresFor,
  trendData,
  trendGrouping,
  setTrendGrouping,
  setEntryDefaultDate,
  cmjStore,
  sjStore,
  imtpStore,
  cmjResult,
  setCmjAthleteId,
  setSjAthleteId,
  setImtpAthleteId,
  storyReviewedEvidence,
  confirmedResultRows,
  onOpenStoryReview,
  onOpenStoryReport,
  onOpenStoryTraining,
  setView,
  setInjuryCtx,
  setAthletesViaRepository,
}) {
  // ── ST-1 bento assembly layer ────────────────────────────────────────────
  // Pure orchestration: the movable Story modules below are the SAME existing
  // components/JSX as before — this layer only wraps + orders + shows/hides
  // them per the 'story' prefs domain and toggles a Customize editing state.
  // No module component body, data flow, or handler is changed here.
  const storyRegistry = (typeof window !== 'undefined' && window.StoryModules) || null;
  const storyPrefsRepo = (typeof window !== 'undefined' && window.VizPanelPrefsRepo) || null;
  const defaultStoryOrder = React.useMemo(
    () => (storyRegistry ? storyRegistry.DEFAULT_MODULE_ORDER.map(m => ({ ...m })) : []),
    [storyRegistry]
  );
  const [storyPrefs, setStoryPrefs] = React.useState(
    () => (storyPrefsRepo ? storyPrefsRepo.load('story') : { modules: defaultStoryOrder })
  );
  const [customizing, setCustomizing] = React.useState(false);
  const [longitudinalRange, setLongitudinalRange] = React.useState('all');

  // commitStoryPrefs takes an UPDATER (prevModules) => nextModules and computes via
  // functional setState so rapid same-tick calls each build on the real current
  // state, not a stale `activeModules` closure snapshot (fixes silent-loss bug where
  // back-to-back add/hide/move/resize kept only the last call). Persistence side
  // effect preserved; save() is idempotent so a StrictMode double-invoke is harmless.
  const commitStoryPrefs = React.useCallback((updater) => {
    setStoryPrefs(prev => {
      const prevModules = Array.isArray(prev?.modules) ? prev.modules : defaultStoryOrder;
      const next = { modules: updater(prevModules) };
      return storyPrefsRepo ? storyPrefsRepo.save('story', next) : next;
    });
  }, [storyPrefsRepo]);

  const activeModules = Array.isArray(storyPrefs?.modules) ? storyPrefs.modules : defaultStoryOrder;
  const activeIds = activeModules.map(m => m.id);
  const approvedLayoutIds = defaultStoryOrder.map(m => m.id);
  const useApprovedLayout = !compareMode && !customizing
    && activeIds.length === approvedLayoutIds.length
    && activeIds.every((id, index) => id === approvedLayoutIds[index]);
  const sizeSpan = storyRegistry ? storyRegistry.SIZE_COLUMN_SPAN : { S: 1, M: 2, L: 3 };
  const sizeGrades = storyRegistry ? storyRegistry.SIZES : ['S', 'M', 'L'];

  const moveModule = (index, delta) => {
    commitStoryPrefs(prevModules => {
      const next = prevModules.map(m => ({ ...m }));
      const target = index + delta;
      if (target < 0 || target >= next.length) return prevModules;
      const [item] = next.splice(index, 1);
      next.splice(target, 0, item);
      return next;
    });
  };
  const setModuleSize = (index, size) => {
    commitStoryPrefs(prevModules => prevModules.map((m, i) => (i === index ? { ...m, size } : { ...m })));
  };
  const hideModule = (id) => {
    commitStoryPrefs(prevModules => prevModules.filter(m => m.id !== id).map(m => ({ ...m })));
  };
  const addModule = (id) => {
    const meta = storyRegistry ? storyRegistry.getModule(id) : null;
    const size = meta ? meta.defaultSize : 'M';
    commitStoryPrefs(prevModules => [...prevModules.map(m => ({ ...m })), { id, size }]);
  };

  // Registry entries not currently active and not locked → the module library.
  const libraryModules = storyRegistry
    ? storyRegistry.listModules().filter(m => !m.locked && !activeIds.includes(m.id))
    : [];

  // Module id → render fn for its (unchanged) content. Returns null when the
  // module should not appear in the current mode (mirrors prior compareMode
  // gating exactly: only trend + profile survive compare).
  const moduleContent = {
    'metric-row': () => compareMode ? null : (
      <HeadlineTilesRow
        athlete={athlete} athletes={athletes} groups={groups}
        seasons={seasons} squadStatsAll={squadStatsAll}
        currentSeason={season}
        cmjStore={cmjStore} sjStore={sjStore} imtpStore={imtpStore}
        pinnedMetricIds={tilesByAthlete[athlete.id]}
        onChangePinnedMetricIds={(ids) =>
          setTilesByAthlete(prev => ({ ...prev, [athlete.id]: ids }))}
      />
    ),
    'trend': () => {
      // MB-3: dual-Y overlay of up to 2 metrics (individual mode). Title/subtitle,
      // picker, chart props and legend are all catalog-driven off trendData.metrics.
      const tMetrics = trendData.metrics || [];
      const dual = !!trendData.dual;
      const bandOn = t.showSquadBand && !dual && (trendData.squadRange?.length > 0 || trendData.squadAvg?.length > 0);
      const catalogEntries = (window.AthleteMetricCatalog
        ? window.AthleteMetricCatalog.listCatalog({ athlete }) : []);
      const unitKey = value => String(value || '').replace(/\s+/g, '').toLowerCase();
      const baseUnit = unitKey(tMetrics[0]?.unit);
      const compatibleCatalogEntries = catalogEntries.filter(entry => unitKey(entry.unit) === baseUnit);
      const unitMismatch = tMetrics.length === 2 && unitKey(tMetrics[0].unit) !== unitKey(tMetrics[1].unit);

      const rangeSpec = ATHLETE_STORY_RANGE_OPTIONS.find(option => option.id === longitudinalRange) || ATHLETE_STORY_RANGE_OPTIONS[3];
      const allEpochs = (trendData.series || []).flatMap(s => (s.values || []).map(point => +new Date(point.x))).filter(Number.isFinite);
      const latestEpoch = allEpochs.length ? Math.max(...allEpochs) : null;
      const cutoffEpoch = latestEpoch != null && rangeSpec.days != null ? latestEpoch - rangeSpec.days * 86400000 : null;
      const withinRange = point => cutoffEpoch == null || +new Date(point.x) >= cutoffEpoch;
      const rangedTrendData = {
        ...trendData,
        series: (trendData.series || []).map(s => ({ ...s, values: (s.values || []).filter(withinRange) })),
        squadAvg: (trendData.squadAvg || []).filter(withinRange),
        squadRange: (trendData.squadRange || []).filter(withinRange),
      };
      const sampleNotes = rangedTrendData.series.map(s => {
        const n = s.values.length;
        if (n === 0) return `${s.label}：当前范围无记录`;
        if (n === 1) return `${s.label}：1 条原始记录，不构成趋势`;
        if (n === 2) {
          const difference = s.values[1].y - s.values[0].y;
          const prefix = difference > 0 ? '+' : '';
          return `${s.label}：2 条原始记录差值 ${prefix}${difference.toFixed(2)}${s.unit ? ` ${s.unit}` : ''}，不是趋势`;
        }
        return null;
      }).filter(Boolean);
      if (trendGrouping !== 'day') sampleNotes.push('按周/月聚合后少于 3 个区间时，仅显示散点，不绘制趋势线。');
      // Slot metadata for the picker chips (id/label/origin/color).
      const chipMetrics = (compareMode ? tMetrics.slice(0, 1) : tMetrics).map((m, i) => ({
        id: trendMetricIds[i], label: m.label, origin: m.origin,
        color: i === 0 ? 'var(--accent)' : '#5e5ce6',
      }));
      const grLabel = trendGrouping === 'day' ? 'Daily' : trendGrouping === 'week' ? 'Weekly avg' : 'Monthly avg';
      const unitLine = tMetrics.map(m => m.unit || '—').join(' · ');
      const title = `Trend · ${tMetrics.map(m => m.label).join(' + ') || '—'}`;
      return (
      <Panel
        title={title}
        subtitle={`${rangedTrendData.series[0]?.values.length || 0} measurements · ${unitLine || '—'} · ${grLabel}`}
        rightAction={
          <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
            <GroupingToggle value={trendGrouping} onChange={setTrendGrouping}/>
            <AthleteStoryRangeToggle value={longitudinalRange} onChange={setLongitudinalRange}/>
            <AthleteStoryTrendPicker
              entries={compatibleCatalogEntries}
              metrics={chipMetrics}
              selectedIds={trendMetricIds}
              onAdd={addTrendMetric}
              onRemove={removeTrendMetric}
              disableRemove={compareMode}
            />
          </div>
        }
      >
        <TrendChart
          key={`${athlete.id}-${trendMetricIds.join('+')}-${compareMode ? 'cmp' : 'ind'}-${trendGrouping}`}
          series={rangedTrendData.series}
          squadAvg={bandOn ? rangedTrendData.squadAvg : null}
          squadRange={bandOn ? rangedTrendData.squadRange : null}
          yUnit={tMetrics[0]?.unit}
          direction={tMetrics[0]?.dir}
          grouping={trendGrouping}
          dualAxis={dual}
          minimumLinePoints={3}
          lineMode="linear"
          annotations={!compareMode ? athlete.annotations : null}
        />
        {sampleNotes.length > 0 && (
          <div data-l1-small-sample style={{ marginTop: 8, padding: '8px 10px', borderRadius: 7, background: 'var(--panel-2)', color: 'var(--muted)', fontSize: 10.5 }}>
            {sampleNotes.map(note => <div key={note}>{note}</div>)}
          </div>
        )}
        {unitMismatch && <div data-l1-unit-warning style={{ marginTop: 8, fontSize: 10.5, color: 'var(--warning, #a15c00)' }}>历史双轴选择使用不同单位；已保留显示，但新增指标只提供与第一项单位一致的选项。</div>}
        <div style={{ display:'flex', gap: 16, alignItems:'center', padding:'8px 0 0', fontSize: 11, color:'var(--muted)', flexWrap: 'wrap' }}>
          {rangedTrendData.series.map((s, i) => (
            <span key={i} style={{ display:'inline-flex', alignItems:'center', gap:6 }}>
              <span style={{ width: 14, height: 2, background: s.color, borderRadius: 1 }}/>
              {s.label}
              {dual && <b style={{ color: s.color, fontWeight: 600 }}>· {s.axis === 'left' ? '左轴' : '右轴'} {s.unit || ''}</b>}
            </span>
          ))}
          {bandOn && <>
            <span style={{ display:'inline-flex', alignItems:'center', gap:6 }}>
              <span style={{ width: 14, height: 0, borderTop: '2px dashed var(--muted)' }}/>
              {window.t('Squad average')}
            </span>
            <span style={{ display:'inline-flex', alignItems:'center', gap:6 }}>
              <span style={{ width: 14, height: 8, background: 'rgba(15,23,42,.07)', border: '1px solid rgba(15,23,42,.12)' }}/>
              {window.t('Squad range')}
            </span>
          </>}
          <span style={{ marginLeft: 'auto', fontStyle: 'italic' }}>
            {dual ? 'hover 看某日两轴原值' : window.t('Hover any point for change vs previous')}
          </span>
        </div>
        {!compareMode && (
          <AthleteStoryLongitudinalRecords series={rangedTrendData.series}/>
        )}
      </Panel>
      );
    },
    'force-card': () => compareMode ? null : (
      <AthleteForceTestsCard
        athlete={athlete}
        sessions={cmjStore[athlete.id] || []}
        sjSessions={sjStore[athlete.id] || []}
        imtpSessions={imtpStore[athlete.id] || []}
        reviewedEvidence={storyReviewedEvidence}
        onUpload={() => { transitionForceContext({ athleteId: athlete.id, testType: 'cmj', view: 'cmj', captureMode: 'collect' }); }}
        onOpenReview={onOpenStoryReview}
        onOpenReport={onOpenStoryReport}
        hasCmjResult={!!cmjResult}
        onViewResult={() => transitionForceContext({ athleteId: athlete.id, testType: 'cmj', view: 'cmj' })}
        onViewLongitudinal={(type = 'cmj') => {
          transitionForceContext({ athleteId: athlete.id, testType: type, view: type + '-longitudinal' });
        }}
      />
    ),
    'training-context': () => compareMode ? null : (
      <AthleteStoryTrainingContextCard
        athlete={athlete}
        reviewedEvidence={storyReviewedEvidence}
        onOpenTraining={() => onOpenStoryTraining?.(storyReviewedEvidence)}
      />
    ),
    'coach-brief': () => compareMode ? null : (
      <AthleteStoryCoachBriefCard
        athlete={athlete}
        reviewedEvidence={storyReviewedEvidence}
        onOpenReport={openReportPage}
      />
    ),
    'profile': () => (
      <div style={{
        display: 'grid',
        gridTemplateColumns: isHoriz ? 'minmax(0, 1.6fr) minmax(0, 1fr)' : '1fr',
        gap: t.density === 'compact' ? 12 : 16,
      }}>
        <Panel title="Athletic Profile" subtitle={compareMode ? `${compareAthletes.length} athletes` : athlete.name}>
          <div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0 0' }}>
            <RadarChart
              key={compareMode ? compareAthletes.map(a => a.id).join(',') : athlete.id}
              axes={groups.map(g => ({ id: g.id, label: g.label, accent: g.accent }))}
              data={compareAthletes.map((a, i) => ({
                label: a.name,
                color: compareMode ? compareColors[i % compareColors.length] : 'var(--accent)',
                scores: scoresFor(a),
              }))}
              showSquadAvg={t.showSquadBand}
              squadAvg={squadAvgScores}
              prevScores={prevScores}
              size={360}
            />
          </div>
          <RadarLegend compareAthletes={compareAthletes} compareMode={compareMode} showSquadBand={t.showSquadBand} showPrev={!!prevScores}/>
        </Panel>
        <Panel title="Overall Score" subtitle={`${window.t('Squad ranked')} · ${algoLabel(algo)}`}>
          <Leaderboard athletes={athletesWithOverall} selectedId={selectedId} onSelect={setSelectedId}/>
        </Panel>
      </div>
    ),
    'workload': () => compareMode ? null : (
      <Panel
        title="ACWR · Acute:Chronic Workload Ratio"
        subtitle={`${athlete.name} · 7-day acute / 28-day chronic · sRPE × duration (Foster) · 1.0-1.3 steady load zone · >1.5 monitor workload change`}
      >
        <ACWRChart
          athletes={[athlete]} groups={groups}
          seasons={seasons} squadStatsAll={squadStatsAll} algo={algo}
          height={200}
          annotations={athlete.annotations}
          onPointClick={(date) => { setEntryDefaultDate(date); setEntryOpen(true); }}
        />
        <div style={{ fontSize: 10, color: 'var(--muted-2)', textAlign: 'right', marginTop: 4 }}>
          点击数据点可快速录入该日训练负荷
        </div>
      </Panel>
    ),
    'dsi-mini': () => compareMode ? null : (
      <AthleteDSIMiniPanel
        D={D}
        athlete={athlete}
        cmjStore={cmjStore}
        imtpStore={imtpStore}
        onOpen={() => setView('dsi')}
      />
    ),
    // ── ST-4: training reads (week-training/load-heat/rpe-trend) — each owns
    // its own async FieldDataStore.listTrainingLogsByAthlete fetch (RP-R1 /
    // TrainingVizPanel preload precedent) since the assembly layer's
    // moduleContent renders synchronously. Compute stays in
    // TrainingWeekSummary.js / TrainingVizModules.js — zero changes.
    'week-training': () => compareMode ? null : (
      <AthleteStoryWeekTrainingCard athlete={athlete} />
    ),
    'load-heat': () => compareMode ? null : (
      <AthleteStoryLoadHeatCard athlete={athlete} />
    ),
    'rpe-trend': () => compareMode ? null : (
      <AthleteStoryRpeTrendCard athlete={athlete} />
    ),
    // ── ST-5: final batch — injury-status/movement-latest/coach-notes/
    // calendar-peek/recent-confirmed. All read-only views over already-stored
    // facts (FieldDataStore.injuries/screenResults, report.jsx's coach_notes
    // localStorage key, calendar.jsx's cal_v1 schedules/events, the same
    // confirmedResultRows the identity region's Latest Confirmed area already
    // reads) — zero new compute, states only already-logged facts (no derived
    // likelihood or clearance-status wording).
    'injury-status': () => compareMode ? null : (
      <AthleteStoryInjuryStatusCard athlete={athlete} onOpen={(arg) => { setInjuryCtx(arg === 'list' ? null : (arg || 'new')); setView('injury'); }} />
    ),
    'movement-latest': () => compareMode ? null : (
      <AthleteStoryMovementLatestCard athlete={athlete} onOpen={() => setView('movement-screen')} />
    ),
    'coach-notes': () => compareMode ? null : (
      <AthleteStoryCoachNotesCard athlete={athlete} season={season} onOpenReport={openReportPage} />
    ),
    'calendar-peek': () => compareMode ? null : (
      <AthleteStoryCalendarPeekCard athlete={athlete} />
    ),
    'recent-confirmed': () => compareMode ? null : (
      <AthleteStoryRecentConfirmedCard athlete={athlete} confirmedResultRows={confirmedResultRows} onOpenReport={openReportPage} />
    ),
  };

  const renderStoryModuleCard = (mod, index) => {
    const meta = storyRegistry ? storyRegistry.getModule(mod.id) : null;
    const render = moduleContent[mod.id];
    const content = render ? render() : null;
    // Skip modules with no content in the current mode (e.g. compare) so the
    // grid stays visually equivalent to the prior conditional layout.
    if (content === null || content === undefined) return null;
    const span = sizeSpan[mod.size] || 1;
    return (
      <article
        key={mod.id}
        className={`athlete-story-bento-module${customizing ? ' athlete-story-bento-module-editing' : ''}`}
        data-story-module={mod.id}
        style={{ gridColumn: useApprovedLayout ? undefined : `span ${span}`, minWidth: 0 }}
      >
        {customizing && (
          <div className="athlete-story-bento-tools" role="group" aria-label={`Customize ${meta ? meta.label : mod.id}`}>
            <span className="athlete-story-bento-tools-label">{meta ? meta.label : mod.id}</span>
            <div className="athlete-story-bento-tools-actions">
              <button type="button" title="上移" aria-label="Move up" disabled={index === 0}
                onClick={() => moveModule(index, -1)}>↑</button>
              <button type="button" title="下移" aria-label="Move down" disabled={index === activeModules.length - 1}
                onClick={() => moveModule(index, 1)}>↓</button>
              <span className="athlete-story-bento-size">
                {sizeGrades.map(sz => (
                  <button key={sz} type="button" title={`Size ${sz}`} aria-pressed={mod.size === sz}
                    className={mod.size === sz ? 'is-active' : ''}
                    onClick={() => setModuleSize(index, sz)}>{sz}</button>
                ))}
              </span>
              <button type="button" title="隐藏模块" aria-label="Hide module"
                onClick={() => hideModule(mod.id)}>×</button>
            </div>
          </div>
        )}
        {content}
      </article>
    );
  };

  return (
    <main id="print-root" className="athlete-story-workspace-route" style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
      <div className="athlete-story-workspace-frame" style={{
        flex: 1, minWidth: 0,
        padding: t.density === 'compact' ? '16px 20px 32px' : '20px 24px 40px',
        display: 'flex', flexDirection: 'column',
        gap: t.density === 'compact' ? 12 : 16,
      }}>
        <header className="athlete-story-page-head no-print">
          <div>
            <div className="athlete-story-page-kicker">ATHLETES / 运动员</div>
            <h2>{compareMode ? '多运动员对比' : '运动员工作台'}</h2>
            <p>{compareMode ? `${compareAthletes.length} athletes selected` : '档案、纵向趋势、测试与训练上下文'}</p>
          </div>
          <div className="athlete-story-page-actions">
            {!compareMode && (
              <label className="athlete-story-switcher" title="切换当前运动员">
                <span>队伍运动员</span>
                <select value={selectedId} onChange={event => setSelectedId(event.target.value)}>
                  {athletes.map(item => <option key={item.id} value={item.id}>{item.name}</option>)}
                </select>
              </label>
            )}
            {compareMode && <button type="button" className="btn" onClick={() => setCompareMode(false)}>退出对比</button>}
            <button type="button" className="btn" onClick={() => setEntryOpen(true)}><Icon name="plus" size={13}/> 数据录入</button>
            <button type="button" className="btn" onClick={openReportPage}>报告编辑</button>
            <button type="button" className="btn primary" onClick={() => setExportOpen(true)}><Icon name="download" size={13}/> 导出 / 打印</button>
            <details className="athlete-story-more">
              <summary aria-label="更多运动员页操作">•••</summary>
              <div>
                <button type="button" onClick={() => openSettings('metrics')}>指标设置</button>
                <button type="button" onClick={() => openSettings('data')}>数据导入导出</button>
                <button type="button" onClick={toggleLang}>{lang === 'zh' ? 'Switch to English' : '切换为中文'}</button>
              </div>
            </details>
          </div>
        </header>

        <section className="athlete-story-identity-frame" aria-label="Athlete Story identity and workflow">
          <div className="athlete-story-identity-topline">
            <div className="athlete-story-identity-main athlete-story-identity-dual">
              <AthleteHeader
                key={athlete.id}
                athlete={athlete} season={season}
                overallScore={overallScore} posAvgScore={posAvgScore}
                onPrint={() => setExportOpen(true)}
                riskLevel={athleteRisk}
              />
              <AthleteStoryLatestStatusPanel
                athlete={athlete}
                cmjSessions={cmjStore[athlete.id] || []}
                sjSessions={sjStore[athlete.id] || []}
                imtpSessions={imtpStore[athlete.id] || []}
                reviewedEvidence={storyReviewedEvidence}
                confirmedResultRows={confirmedResultRows}
              />
            </div>
            {!compareMode && (
              <button
                type="button"
                className="btn ghost athlete-story-customize-toggle"
                aria-pressed={customizing}
                onClick={() => setCustomizing(v => !v)}
              >
                {customizing ? '完成 / Done' : '⊞ 自定义页面'}
              </button>
            )}
          </div>
          {!compareMode && (
            <AthleteGuardianConsent
              athlete={athlete}
              onConfirm={guardianConsent => setAthletesViaRepository(previous => previous.map(item => (
                item.id === athlete.id ? { ...item, guardianConsent } : item
              )))}
            />
          )}
        </section>

        {compareMode && (
          <CompareBar
            compareAthletes={compareAthletes}
            compareIds={compareIds}
            onRemove={(id) => toggleCompare(id)}
          />
        )}

        <div className={`athlete-story-bento-shell${customizing ? ' is-customizing' : ''}`}>
          <section
            className="athlete-story-bento"
            data-approved-layout={useApprovedLayout ? 'true' : undefined}
            aria-label="Athlete Story modules"
            style={{
              display: 'grid',
              gridTemplateColumns: isHoriz ? 'repeat(3, minmax(0, 1fr))' : '1fr',
              gridAutoRows: 'auto',
              gap: t.density === 'compact' ? 12 : 16,
              alignItems: 'start',
            }}
          >
            {activeModules.map((mod, index) => renderStoryModuleCard(mod, index))}
          </section>

          {customizing && !compareMode && (
            <aside className="athlete-story-module-library" aria-label="Module library">
              <h3>模块库 / Module Library</h3>
              <p>点击添加未启用的模块。锁定的身份与工作流始终保留。</p>
              <div className="athlete-story-module-library-list">
                {libraryModules.length === 0 && (
                  <div className="athlete-story-module-library-empty">全部模块已启用</div>
                )}
                {libraryModules.map(m => (
                  <div className="athlete-story-module-library-item" key={m.id}>
                    <div>
                      <strong>{m.label}</strong>
                      <span>{m.category}</span>
                    </div>
                    <button type="button" className="athlete-story-module-library-add" title="添加模块"
                      aria-label={`Add ${m.label}`} onClick={() => addModule(m.id)}>+</button>
                  </div>
                ))}
              </div>
            </aside>
          )}
        </div>

        {!compareMode && (
          <AnnotationManager
            athlete={athlete}
            onAthleteChange={(next) => setAthletesViaRepository(athletes.map(a => a.id === next.id ? next : a))}
          />
        )}

        <Footer count={athletes.length}/>
      </div>
    </main>
  );
}


// REV-R4: per-type "latest confirmed" selection over the five reviewed-evidence
// types (cmj/sj/imtp/field/manual). Pure function of the row list so it can be
// unit-tested directly from the assert script without mounting the component.
// Rows come from the same ReviewedEvidenceRows viewmodel used by Training (REV-R3)
// and Weekly Brief — reviewed-only, never fabricated. athleteId is read with a
// fallback to sourceRef.athleteId (sj/imtp/field rows only set it there) and,
// failing that, athleteName — mirrors the attribution discipline already used in
// weekly-brief-preview.jsx's confirmedResults selector.
const ATHLETE_STORY_LATEST_CONFIRMED_TYPES = [
  { id: 'cmj', label: 'CMJ' },
  { id: 'sj', label: 'SJ' },
  { id: 'imtp', label: 'IMTP' },
  { id: 'field', label: 'Field' },
  { id: 'manual', label: 'Manual' },
];

function athleteStoryRowMatchesAthlete(row, athlete) {
  if (!row || !athlete) return false;
  const rowAthleteId = row.athleteId || row.sourceRef?.athleteId || '';
  if (rowAthleteId) return rowAthleteId === athlete.id;
  return !!row.athleteName && !!athlete.name && row.athleteName === athlete.name;
}

function selectLatestConfirmedByType(rows, athlete) {
  const forAthlete = (rows || []).filter(row => athleteStoryRowMatchesAthlete(row, athlete));
  return ATHLETE_STORY_LATEST_CONFIRMED_TYPES.map(({ id, label }) => {
    const latest = forAthlete
      .filter(row => row.sourceType === id)
      .slice()
      .sort((a, b) => String(b.timestamp || '').localeCompare(String(a.timestamp || '')))[0];
    return latest ? { id, label, row: latest } : null;
  }).filter(Boolean);
}

function AthleteStoryLatestStatusPanel({ athlete, cmjSessions = [], sjSessions = [], imtpSessions = [], reviewedEvidence, confirmedResultRows = [] }) {
  const fmt = (value) => value ? (window.formatDate ? window.formatDate(value, 'short') : String(value).slice(0, 10)) : '/';
  const latestOf = (sessions) => (sessions || [])
    .slice()
    .filter(Boolean)
    .sort((a, b) => String(b.date || b.sessionDate || b.createdAt || '').localeCompare(String(a.date || a.sessionDate || a.createdAt || '')))[0];
  const items = [
    { id: 'cmj', label: 'CMJ', session: latestOf(cmjSessions) },
    { id: 'sj', label: 'SJ', session: latestOf(sjSessions) },
    { id: 'imtp', label: 'IMTP', session: latestOf(imtpSessions) },
  ];
  const reviewedLabel = reviewedEvidence
    ? 'Confirmed result / 已确认结果'
    : 'No confirmed result / 暂无确认结果';
  const latestConfirmed = selectLatestConfirmedByType(confirmedResultRows, athlete);
  const truncate = (text, max = 28) => {
    const value = text ? String(text) : '—';
    return value.length > max ? `${value.slice(0, max - 1)}…` : value;
  };
  return (
    <aside className="athlete-story-latest-panel" aria-label="Athlete Story latest test status">
      <div className="athlete-story-latest-heading">
        <span>Latest Status / 最新状态</span>
        <strong>{athlete?.sport || 'Athlete'}</strong>
      </div>
      <div className="athlete-story-latest-review">{reviewedLabel}</div>
      <div className="athlete-story-latest-list">
        {items.map(item => {
          const sourceDate = item.session?.date || item.session?.sessionDate || item.session?.createdAt;
          return (
            <div className="athlete-story-latest-item" key={item.id} title={`${item.label} · ${fmt(sourceDate)}`}>
              <span>{item.label}</span>
              <strong>{fmt(sourceDate)}</strong>
            </div>
          );
        })}
      </div>
      {latestConfirmed.length > 0 && (
        <div className="athlete-story-latest-confirmed" aria-label="Latest confirmed results">
          <div className="athlete-story-latest-confirmed-heading">Latest Confirmed / 最新已确认</div>
          <div className="athlete-story-latest-confirmed-list">
            {latestConfirmed.map(({ id, label, row }) => {
              const fullTitle = [row.primaryLabel, row.secondaryLabel].filter(Boolean).join(' — ');
              return (
                <div className="athlete-story-latest-confirmed-item" key={id} title={fullTitle}>
                  <span className="athlete-story-latest-confirmed-type">{label}</span>
                  <span className="athlete-story-latest-confirmed-label">{truncate(row.primaryLabel)}</span>
                  <strong>{fmt(row.timestamp)}</strong>
                </div>
              );
            })}
          </div>
        </div>
      )}
    </aside>
  );
}


// UI-ST3b: shared hover-first detail disclosure for Story cards, mirroring the
// SE-1 `Note` primitive (settings.jsx) — collapses secondary metadata to a
// hairline caption row; full detail surfaces via hover, or click-to-pin for
// touch/no-hover access. Only descriptive metadata is ever routed through
// this — action buttons and primary conclusions stay on-canvas per §3.9.
function AthleteStoryHoverDetail({ children }) {
  const [pinned, setPinned] = React.useState(false);
  return (
    <div data-se1-note className="athlete-story-hover-detail" style={{ position: 'relative' }}>
      <button
        type="button"
        data-se1-note-trigger
        onClick={() => setPinned(p => !p)}
        className="athlete-story-hover-detail-trigger"
      >
        <span>{window.t ? window.t('Details') : 'Details'} / 详情</span>
        <span className="athlete-story-hover-detail-hint">{pinned ? (window.t ? window.t('hide') : 'hide') : (window.t ? window.t('hover / click') : 'hover / click')}</span>
      </button>
      {pinned && (
        <dl data-se1-note-body className="athlete-story-context-list athlete-story-hover-detail-body">{children}</dl>
      )}
      <dl data-se1-note-hover className="se1-note-hover athlete-story-context-list athlete-story-hover-detail-body">{children}</dl>
    </div>
  );
}

function AthleteStoryTrainingContextCard({ athlete, reviewedEvidence, onOpenTraining }) {
  const focus = reviewedEvidence?.reviewerTrainingFocus || '/';
  const conclusion = reviewedEvidence?.classification || '/';
  const hasContext = !!reviewedEvidence;
  return (
    <section className="athlete-story-context-card" aria-label="Athlete Story training context">
      <div className="athlete-story-context-kicker">Training Context / 训练上下文</div>
      <div className="athlete-story-context-title">{athlete?.name || 'Athlete'}</div>
      <AthleteStoryHoverDetail>
        <div><dt>Sports Scientist Conclusion / 运动科学结论</dt><dd>{conclusion}</dd></div>
        <div><dt>Reviewer Training Focus / 训练关注点</dt><dd>{focus}</dd></div>
      </AthleteStoryHoverDetail>
      <button type="button" className="btn ghost athlete-story-context-action" onClick={onOpenTraining} disabled={!hasContext}>
        Open Training / 打开训练
      </button>
    </section>
  );
}

function AthleteStoryCoachBriefCard({ athlete, reviewedEvidence, onOpenReport }) {
  const note = reviewedEvidence?.reviewerNoteExcerpt || reviewedEvidence?.reviewerNote || reviewedEvidence?.notes || '';
  const excerpt = note ? String(note).slice(0, 120) : 'No coach-facing note yet. / 暂无教练沟通备注';
  const lastConfirmed = reviewedEvidence?.lastReviewedAt
    ? (window.formatDate ? window.formatDate(reviewedEvidence.lastReviewedAt, 'short') : String(reviewedEvidence.lastReviewedAt).slice(0, 10))
    : '/';
  return (
    <section className="athlete-story-context-card athlete-story-context-card-muted" aria-label="Athlete Story coach brief">
      <div className="athlete-story-context-kicker">Coach Brief / 教练沟通摘要</div>
      <div className="athlete-story-context-title">{reviewedEvidence ? 'Ready for discussion / 可用于沟通' : 'No coach brief yet / 暂无教练沟通摘要'}</div>
      <AthleteStoryHoverDetail>
        <div><dt>Athlete / 运动员</dt><dd>{athlete?.name || '/'}</dd></div>
        <div><dt>Last Confirmed / 最近确认</dt><dd>{lastConfirmed}</dd></div>
        <div><dt>Note / 备注</dt><dd>{excerpt}</dd></div>
      </AthleteStoryHoverDetail>
      <button type="button" className="btn ghost athlete-story-context-action" onClick={onOpenReport} disabled={!reviewedEvidence}>
        Open Report / 打开报告
      </button>
    </section>
  );
}

function AthleteDSIMiniPanel({ D, athlete, cmjStore, imtpStore, onOpen }) {
  const forceSource = window.ForceSessionSource;
  const effective = session => forceSource && typeof forceSource.resolveEffectiveSession === 'function'
    ? forceSource.resolveEffectiveSession(session) : session;
  const cmjSessions = (cmjStore[athlete.id] || []).map(effective);
  const imtpSessions = (imtpStore[athlete.id] || []).map(effective);
  const pair = window.nearestSessionPair?.(cmjSessions, imtpSessions, 14, 'peakPropForce', 'peakForce');
  const latestCMJ = pair?.primary;
  const latestIMTP = pair?.secondary;
  if (!latestCMJ || !latestIMTP) return null;
  const dsi = D.computeDSI(latestCMJ.best?.peakPropForce, latestIMTP.best?.peakForce);
  if (dsi == null) return null;
  const dsiHistory = cmjSessions
    .map(c => {
      const cmjDate = +new Date(c.date);
      const near = imtpSessions
        .map(i => ({ s: i, diff: Math.abs(+new Date(i.date) - cmjDate) }))
        .filter(x => x.diff <= 14 * 86400000)
        .sort((a, b) => a.diff - b.diff)[0];
      if (!near) return null;
      const dv = D.computeDSI(c.best?.peakPropForce, near.s.best?.peakForce);
      return dv != null ? { date: c.date, dsi: dv } : null;
    })
    .filter(Boolean)
    .sort((a, b) => a.date.localeCompare(b.date));
  const zone = dsi < 0.6
    ? { label: '力量-爆发缺口', advice: '优先爆发力训练（快速伸缩、弹跳、投掷）', color: 'var(--neg)' }
    : dsi < 0.8
    ? { label: '偏弹射型', advice: '两者兼顾，略侧重爆发力', color: 'var(--warn)' }
    : dsi <= 1.0
    ? { label: '均衡', advice: '维持现有训练结构', color: 'var(--pos)' }
    : { label: '最大力量优先', advice: '优先最大力量训练（IMTP/Squat 类）', color: '#a78bfa' };
  return (
    <Panel>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
        <div>
          <div style={{ fontSize: 10, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.08em', fontWeight: 600, marginBottom: 4 }}>
            力量剖析 · DSI + SSC
          </div>
          <div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
            <span style={{ fontSize: 28, fontWeight: 700, fontFamily: 'var(--font-mono)', color: zone.color }}>{dsi.toFixed(2)}</span>
            <span style={{ padding: '2px 8px', borderRadius: 999, fontSize: 11, fontWeight: 600, background: zone.color + '22', color: zone.color, border: `1px solid ${zone.color}44` }}>
              {zone.label}
            </span>
          </div>
          <div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 4 }}>{zone.advice}</div>
          <div style={{ fontSize: 10, color: 'var(--muted-2)', marginTop: 3 }}>
            CMJ 推进峰力 {latestCMJ.best?.peakPropForce ?? '—'}N · IMTP 峰力 {latestIMTP.best?.peakForce ?? '—'}N
            <span style={{ marginLeft: 8 }}>{latestCMJ.date} / {latestIMTP.date}</span>
          </div>
        </div>
        <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 8 }}>
          {dsiHistory.length >= 2 && (() => {
            const W = 90, H = 30, p = 4;
            const vals = dsiHistory.map(d => d.dsi);
            const vMin = Math.min(...vals), vMax = Math.max(...vals);
            const span = Math.max(0.1, vMax - vMin);
            const xAt = i => p + (i / (vals.length - 1)) * (W - 2 * p);
            const yAt = v => H - p - ((v - vMin) / span) * (H - 2 * p);
            const path = vals.map((v, i) => `${i === 0 ? 'M' : 'L'}${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(' ');
            return (
              <svg width={W} height={H} style={{ flexShrink: 0, opacity: 0.8 }}>
                <path d={path} fill="none" stroke={zone.color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round"/>
                {vals.map((v, i) => (
                  <circle key={i} cx={xAt(i)} cy={yAt(v)} r={i === vals.length - 1 ? 3 : 1.5}
                    fill={i === vals.length - 1 ? zone.color : 'none'}
                    stroke={zone.color} strokeWidth="1"/>
                ))}
              </svg>
            );
          })()}
          <button className="btn ghost" onClick={onOpen} style={{ fontSize: 11, whiteSpace: 'nowrap' }}>
            详细分析 →
          </button>
        </div>
      </div>
    </Panel>
  );
}


// ── ST-4: training-read module async preload ─────────────────────────────────
// week-training / load-heat / rpe-trend each own their local
// FieldDataStore.listTrainingLogsByAthlete fetch (RP-R1'/TrainingVizPanel
// preload precedent — app.jsx's reportTrainingLogs effect, training.jsx's
// TrainingVizPanel) since the assembly layer's moduleContent renders
// synchronously. Local-date range helpers mirror training.jsx's
// trFmtLocalDate/weekMondayToSunday/periodRange exactly (no toISOString —
// UTC+ would shift the day boundary). Compute stays in
// TrainingWeekSummary.js / TrainingVizModules.js — zero changes.
function athleteStoryFmtLocalDate(d) {
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function athleteStoryCurrentWeekRange() {
  const now = new Date();
  const day = now.getDay(); // 0=Sun..6=Sat
  const diffToMonday = day === 0 ? -6 : 1 - day;
  const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate() + diffToMonday);
  const sunday = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + 6);
  return { fromDate: athleteStoryFmtLocalDate(monday), toDate: athleteStoryFmtLocalDate(sunday) };
}
function athleteStoryTrailingRange(days) {
  const now = new Date();
  const toDate = athleteStoryFmtLocalDate(now);
  const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - (days - 1));
  return { fromDate: athleteStoryFmtLocalDate(start), toDate };
}

// Shared fetch hook: loads trainingLogs for [fromDate, toDate] for one
// athlete, alive-guarded against unmount/athlete-switch races.
function useAthleteStoryTrainingLogs(athleteId, fromDate, toDate) {
  const [logs, setLogs] = React.useState([]);
  React.useEffect(() => {
    let alive = true;
    const FS = window.FieldDataStore;
    if (!FS || !athleteId || typeof FS.listTrainingLogsByAthlete !== 'function') {
      setLogs([]);
      return () => { alive = false; };
    }
    FS.listTrainingLogsByAthlete(athleteId, fromDate, toDate).then(result => {
      if (alive) setLogs(Array.isArray(result) ? result : []);
    }).catch(() => { if (alive) setLogs([]); });
    return () => { alive = false; };
  }, [athleteId, fromDate, toDate]);
  return logs;
}

// week-training module: read view of TrainingWeekSummary.summarizeTrainingWeek
// over the current Mon–Sun week's logs. Same 5 fields TrainingWeekSummaryCard
// (training.jsx) already surfaces — sessions/completed/avgRPE/totalDurationMin/sessionLoad.
function AthleteStoryWeekTrainingCard({ athlete }) {
  const { fromDate, toDate } = React.useMemo(() => athleteStoryCurrentWeekRange(), []);
  const logs = useAthleteStoryTrainingLogs(athlete?.id, fromDate, toDate);
  const summary = window.TrainingWeekSummary ? window.TrainingWeekSummary.summarizeTrainingWeek(logs) : null;
  const s = summary || { sessions: null, completed: null, avgRPE: null, totalDurationMin: null, sessionLoad: null };
  const hasAny = s.sessions != null && s.sessions > 0;
  const stat = (label, value, unit) => (
    <div key={label} style={{ minWidth: 64 }}>
      <div style={{ fontSize: 9.5, color: 'var(--muted-2)', textTransform: 'uppercase', letterSpacing: '.06em' }}>{label}</div>
      <div style={{ fontSize: 15, fontWeight: 650, color: 'var(--text)', fontFamily: 'var(--font-mono)' }}>
        {value == null ? '—' : `${value}${unit || ''}`}
      </div>
    </div>
  );
  return (
    <Panel title="Week Training / 周训练汇总" subtitle={`${fromDate} → ${toDate}`}>
      {!hasAny ? (
        <div className="tr-mod-empty">该周期无执行记录 / No executed records in this period</div>
      ) : (
        <div style={{ display: 'flex', gap: 18, flexWrap: 'wrap' }}>
          {stat('Sessions', s.sessions)}
          {stat('Completed', s.completed)}
          {stat('Avg RPE', s.avgRPE)}
          {stat('Duration', s.totalDurationMin, ' min')}
          {stat('Load', s.sessionLoad)}
        </div>
      )}
    </Panel>
  );
}

// load-heat module: read view of TrainingVizModules.computeVizModule('week-load-heat', …)
// — same 7-cell compute + hover-title craft as TrVizHeatModule (training.jsx),
// reused here via the registry rather than re-imported (component itself is
// training.jsx-local/not window-exposed, so the render is rebuilt to the same
// §3.8 spec: pale→deep quartile buckets, hover title carries the day's load).
const ATHLETE_STORY_HEAT_STEPS = ['#d9e9fb', '#aed0f6', '#6faaec', '#2f7fd4'];
function AthleteStoryLoadHeatCard({ athlete }) {
  const { fromDate, toDate } = React.useMemo(() => athleteStoryCurrentWeekRange(), []);
  const logs = useAthleteStoryTrainingLogs(athlete?.id, fromDate, toDate);
  const cells = window.TrainingVizModules
    ? (window.TrainingVizModules.computeVizModule('week-load-heat', { logs, fromDate, toDate }) || [])
    : [];
  const loads = cells.map(c => c.load).filter(v => typeof v === 'number');
  const max = loads.length ? Math.max(...loads) : 0;
  const colorFor = (load) => {
    if (load == null) return 'var(--panel-2, var(--panel))';
    if (max <= 0) return ATHLETE_STORY_HEAT_STEPS[0];
    const q = Math.min(3, Math.floor((load / max) * 4 - 1e-9));
    return ATHLETE_STORY_HEAT_STEPS[Math.max(0, q)];
  };
  return (
    <Panel title="Load Heat / 负荷热力" subtitle={`${fromDate} → ${toDate}`}>
      {cells.length === 0 ? (
        <div className="tr-mod-empty">暂无数据 / No data</div>
      ) : (
        <>
          <div className="tr-heat7">
            {cells.map(c => (
              <div key={c.date} className="tr-heat-cell"
                style={{ background: colorFor(c.load) }}
                title={`${c.date.slice(5)} · ${c.load == null ? '无记录 / No record' : `load ${c.load}`}`} />
            ))}
          </div>
          <div style={{ fontSize: 9.5, color: 'var(--muted)', marginTop: 7, fontFamily: 'var(--font-mono)' }}>session load · 深浅=日负荷</div>
        </>
      )}
    </Panel>
  );
}

// rpe-trend module: read view of TrainingVizModules.computeVizModule('rpe-trend-14d', …)
// — same trailing-14-day day-mean RPE compute + §3.8 smoothed-line craft as
// TrVizRpeModule (training.jsx), rebuilt here to the same spec for the same
// training.jsx-local-component reason as load-heat above.
function AthleteStoryRpeTrendCard({ athlete }) {
  const { fromDate, toDate } = React.useMemo(() => athleteStoryTrailingRange(14), []);
  const logs = useAthleteStoryTrainingLogs(athlete?.id, fromDate, toDate);
  const pts = window.TrainingVizModules
    ? (window.TrainingVizModules.computeVizModule('rpe-trend-14d', { logs }) || [])
    : [];
  const w = 240, h = 70, padX = 10, padT = 8, padB = 6;
  const innerW = w - padX * 2, innerH = h - padT - padB;
  const n = pts.length;
  const xOf = (i) => n === 1 ? w / 2 : padX + (i / (n - 1)) * innerW;
  const yOf = (rpe) => padT + innerH - (Math.max(0, Math.min(10, rpe)) / 10) * innerH;
  const linePoints = pts.map((p, i) => ({ x: xOf(i), y: yOf(p.rpe) }));
  const linePath = typeof window.trSmoothPath === 'function' ? window.trSmoothPath(linePoints) : '';
  return (
    <Panel title="RPE Trend / RPE趋势" subtitle="14 天 · session RPE 日均值">
      {pts.length === 0 ? (
        <div className="tr-mod-empty">暂无数据 / No data</div>
      ) : (
        <>
          <svg viewBox={`0 0 ${w} ${h}`} className="tr-chart" style={{ width: '100%', height: 'auto', display: 'block' }}>
            <line x1="0" y1={h - padB} x2={w} y2={h - padB} stroke="var(--border)" strokeWidth="1" />
            {linePath && (
              <path className="tr-chart-line" d={linePath} fill="none" stroke="var(--accent)"
                strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
            )}
            <g fill="var(--accent)">
              {pts.map((p, i) => (
                <circle key={p.date} className="tr-chart-pt" cx={xOf(i)} cy={yOf(p.rpe)} r="2.25">
                  <title>{`${p.date.slice(5)} · RPE ${p.rpe}`}</title>
                </circle>
              ))}
            </g>
          </svg>
          <div style={{ fontSize: 9.5, color: 'var(--muted)', marginTop: 7, fontFamily: 'var(--font-mono)' }}>仅记录事实 · 不做疲劳推断</div>
        </>
      )}
    </Panel>
  );
}

// ── ST-5: final batch of new module supply ──────────────────────────────────
// injury-status / movement-latest / coach-notes / calendar-peek /
// recent-confirmed — all read-only views over already-stored facts, styled
// with the same cold-toned .athlete-story-context-card + AthleteStoryHoverDetail
// disclosure ST-4's training-context/coach-brief cards established (UI-ST3b).
// None of these introduce new storage, new schema, or a computed likelihood/
// forecast — each states only what is already recorded.

// injury-status: read view of FieldDataStore.listInjuriesByAthlete(athleteId)
// over the FieldDataStore.injuries record shape ({ status, phase, bodyPart, side,
// date }). This card states only the currently-registered phase/status per injury
// — no LSI/trend/expected-return-date/advice (those live on the full Injury view,
// InjuryView) and no likelihood/outlook wording: phase/status are the athlete's
// own logged facts, not a computed estimate.
function AthleteStoryInjuryStatusCard({ athlete, onOpen }) {
  const FS = window.FieldDataStore;
  const [injuries, setInjuries] = React.useState([]);
  React.useEffect(() => {
    let alive = true;
    (async () => {
      if (!FS || !athlete) { setInjuries([]); return; }
      try {
        const ok = await FS.healthCheck();
        if (!ok || !alive) return;
        await FS.init('default');
        const rows = await FS.listInjuriesByAthlete(athlete.id);
        if (alive) setInjuries(Array.isArray(rows) ? rows : []);
      } catch (e) { console.warn('injury-status card load', e); if (alive) setInjuries([]); }
    })();
    return () => { alive = false; };
  }, [FS, athlete?.id]);

  const active = injuries.filter(i => i.status && i.status !== 'available');
  const phaseLabel = (phase) => {
    const IJ = { acute: 'Acute / 急性', subacute: 'Subacute / 亚急性', recovery: 'Recovery / 恢复期', rtt: 'Return to Train / 重返训练', rtp: 'Return to Play / 重返赛场' };
    return IJ[phase] || phase || '/';
  };
  const statusLabel = (status) => {
    const ST = { available: 'Available / 可用', modified: 'Modified / 限制', unavailable: 'Unavailable / 不可用' };
    return ST[status] || status || '/';
  };
  return (
    <section className="athlete-story-context-card" aria-label="Athlete Story injury status">
      <div className="athlete-story-context-kicker">Injury Status / 损伤状态摘要</div>
      <div className="athlete-story-context-title">
        {active.length === 0 ? 'No active injury on record / 无在案伤病' : `${active.length} recorded / 在案 ${active.length} 项`}
      </div>
      {active.length > 0 && (
        <AthleteStoryHoverDetail>
          {active.slice(0, 5).map(inj => (
            <div key={inj.id}>
              <dt>{inj.bodyPart || '/'}{inj.side ? ` (${inj.side})` : ''}</dt>
              <dd>{statusLabel(inj.status)} · {phaseLabel(inj.phase)}</dd>
            </div>
          ))}
        </AthleteStoryHoverDetail>
      )}
      <button type="button" className="btn ghost athlete-story-context-action" onClick={() => onOpen(active[0] ? active[0].id : 'list')}>
        Open Adaptation / 打开损伤康复
      </button>
    </section>
  );
}

// movement-latest: read view of FieldDataStore.listScreenResultsByAthlete(athleteId).
// This module surfaces only the single MOST RECENT screen's per-test pass/fail
// breakdown (results is a { testName: { status, regions[] } } map, per
// FieldDataStore.saveScreenResult) — zero new compute, no trend/derivation.
function AthleteStoryMovementLatestCard({ athlete, onOpen }) {
  const FS = window.FieldDataStore;
  const [history, setHistory] = React.useState([]);
  React.useEffect(() => {
    let alive = true;
    (async () => {
      if (!FS || !athlete) { setHistory([]); return; }
      try {
        const ok = await FS.healthCheck();
        if (!ok || !alive) return;
        await FS.init('default');
        const rows = await FS.listScreenResultsByAthlete(athlete.id);
        if (alive) setHistory(Array.isArray(rows) ? rows : []);
      } catch (e) { console.warn('movement-latest card load', e); if (alive) setHistory([]); }
    })();
    return () => { alive = false; };
  }, [FS, athlete?.id]);

  // listScreenResultsByAthlete already sorts desc by date — [0] is latest.
  const latest = history[0] || null;
  const tests = latest ? Object.entries(latest.results || {}) : [];
  const fails = tests.filter(([, v]) => v && v.status === 'fail').length;
  return (
    <section className="athlete-story-context-card" aria-label="Athlete Story latest movement screen">
      <div className="athlete-story-context-kicker">Movement Screen / 最近动作筛查</div>
      <div className="athlete-story-context-title">
        {!latest ? 'Not yet screened / 尚未筛查' : `${latest.date} · ${fails === 0 ? 'All pass / 全部通过' : `${fails} flagged / ${fails} 项需关注`}`}
      </div>
      {latest && tests.length > 0 && (
        <AthleteStoryHoverDetail>
          {tests.map(([test, v]) => (
            <div key={test}>
              <dt>{test}</dt>
              <dd>{v?.status === 'fail' ? 'Fail / 未通过' : v?.status === 'pass' ? 'Pass / 通过' : '/'}</dd>
            </div>
          ))}
        </AthleteStoryHoverDetail>
      )}
      <button type="button" className="btn ghost athlete-story-context-action" onClick={onOpen}>
        Open Movement Screen / 打开动作筛查
      </button>
    </section>
  );
}

// coach-notes: read view of report.jsx's existing coach_notes_${athleteId}_${season}
// localStorage key via coachNotesLoad(athlete.id, season) — the SAME free-text
// field the Individual Report composer already reads/writes (report.jsx's
// coachNotesLoad/coachNotesSave). This module is read-only: no new textarea,
// no new storage key — editing stays exclusively on the Report page.
function AthleteStoryCoachNotesCard({ athlete, season, onOpenReport }) {
  const [note, setNote] = React.useState('');
  React.useEffect(() => {
    setNote(typeof coachNotesLoad === 'function' && athlete?.id ? coachNotesLoad(athlete.id, season) : '');
  }, [athlete?.id, season]);
  const trimmed = (note || '').trim();
  const excerpt = trimmed ? (trimmed.length > 160 ? `${trimmed.slice(0, 159)}…` : trimmed) : '';
  return (
    <section className="athlete-story-context-card athlete-story-context-card-muted" aria-label="Athlete Story coach notes">
      <div className="athlete-story-context-kicker">Coach Notes / 教练笔记</div>
      <div className="athlete-story-context-title">{trimmed ? 'Note on file / 已有笔记' : 'No note yet / 暂无笔记'}</div>
      {trimmed && (
        <AthleteStoryHoverDetail>
          <div><dt>Season / 赛季</dt><dd>{season || '/'}</dd></div>
          <div><dt>Note / 备注</dt><dd style={{ whiteSpace: 'pre-wrap' }}>{excerpt}</dd></div>
        </AthleteStoryHoverDetail>
      )}
      <button type="button" className="btn ghost athlete-story-context-action" onClick={onOpenReport}>
        Open Report / 打开报告
      </button>
    </section>
  );
}

// calendar-peek: read view of calendar.jsx's existing cal_v1-prefixed
// localStorage keys (_schedules / _manualEvents) via the SAME calGet reader
// calendar.jsx's CalendarPage already uses to hydrate its state — no new
// storage key, no new schedule-expansion logic (recurrence expansion stays
// calendar.jsx-only; this card lists each schedule/event's own recorded
// startDate/date once, not per-occurrence). Filters to items relevant to the
// current athlete: athleteIds === 'all' or athleteIds includes this athlete.
function athleteStoryCalendarRelevant(athleteIds, athleteId) {
  if (athleteIds === 'all') return true;
  return Array.isArray(athleteIds) && athleteIds.includes(athleteId);
}
function AthleteStoryCalendarPeekCard({ athlete }) {
  const items = React.useMemo(() => {
    if (typeof calGet !== 'function' || !athlete?.id) return [];
    const today = athleteStoryFmtLocalDate(new Date());
    const schedules = (calGet('_schedules', []) || [])
      .filter(s => athleteStoryCalendarRelevant(s.athleteIds, athlete.id))
      .filter(s => !s.startDate || s.startDate >= today)
      .map(s => ({ id: s.id, date: s.startDate || today, label: s.name || 'Schedule / 计划' }));
    const events = (calGet('_manualEvents', []) || [])
      .filter(e => athleteStoryCalendarRelevant(e.athleteIds, athlete.id))
      .filter(e => e.date && e.date >= today)
      .map(e => ({ id: e.id, date: e.date, label: e.title || e.notes || 'Event / 事件' }));
    return [...schedules, ...events]
      .sort((a, b) => String(a.date).localeCompare(String(b.date)))
      .slice(0, 6);
  }, [athlete?.id]);
  return (
    <section className="athlete-story-context-card" aria-label="Athlete Story calendar peek">
      <div className="athlete-story-context-kicker">Calendar Peek / 近期日程</div>
      <div className="athlete-story-context-title">
        {items.length === 0 ? 'No upcoming items / 暂无近期日程' : `${items.length} upcoming / 近期 ${items.length} 项`}
      </div>
      {items.length > 0 && (
        <AthleteStoryHoverDetail>
          {items.map(it => (
            <div key={it.id}>
              <dt>{it.date}</dt>
              <dd>{it.label}</dd>
            </div>
          ))}
        </AthleteStoryHoverDetail>
      )}
    </section>
  );
}

// recent-confirmed: read view of the SAME confirmedResultRows array + the SAME
// athleteStoryRowMatchesAthlete attribution helper the identity region's
// "Latest Confirmed" panel (AthleteStoryLatestStatusPanel) already uses — this
// module just widens from "latest per type" to "N most recent rows mixed
// across all five reviewed-evidence types", zero new fetch/compute.
function AthleteStoryRecentConfirmedCard({ athlete, confirmedResultRows = [], onOpenReport }) {
  const fmt = (value) => value ? (window.formatDate ? window.formatDate(value, 'short') : String(value).slice(0, 10)) : '/';
  const rows = React.useMemo(() => {
    return (confirmedResultRows || [])
      .filter(row => athleteStoryRowMatchesAthlete(row, athlete))
      .slice()
      .sort((a, b) => String(b.timestamp || '').localeCompare(String(a.timestamp || '')))
      .slice(0, 8);
  }, [confirmedResultRows, athlete]);
  return (
    <section className="athlete-story-context-card" aria-label="Athlete Story recent confirmed results">
      <div className="athlete-story-context-kicker">Recent Confirmed / 已确认结果列表</div>
      <div className="athlete-story-context-title">
        {rows.length === 0 ? 'No confirmed result yet / 暂无确认结果' : `${rows.length} confirmed / 已确认 ${rows.length} 项`}
      </div>
      {rows.length > 0 && (
        <AthleteStoryHoverDetail>
          {rows.map((row, i) => (
            <div key={`${row.sourceType || 'row'}-${row.timestamp || i}`}>
              <dt>{(row.sourceTypeLabel || row.sourceType || 'Result').toString()} · {fmt(row.timestamp)}</dt>
              <dd>{row.primaryLabel || '/'}</dd>
            </div>
          ))}
        </AthleteStoryHoverDetail>
      )}
      <button type="button" className="btn ghost athlete-story-context-action" onClick={onOpenReport} disabled={rows.length === 0}>
        Open Report / 打开报告
      </button>
    </section>
  );
}
