// app.jsx  v20  —  顶层状态管理 · 视图路由 · 持久化边界 · 多语言 lang state
// 职责：全局 state（athletes / mode / view / riskConfig）、路由分发、CMJ + SJ + IMTP session store
// 依赖：data.js · 所有视图组件（team / dashboard-views / cmj / sj / imtp / calendar / report / settings / interactions）

const { useState: useS, useEffect: useE, useMemo: useM, useRef: useR } = React;

const D = window.DASHBOARD_DATA;
const COMPARE_COLORS = ['#3b82f6', '#34d399', '#f59e0b', '#f472b6'];

const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "layout": "horizontal",
  "density": "regular",
  "showSquadBand": true,
  "accent": "#0a84ff",
  "vibe": "mid",
  "motion": "on"
}/*EDITMODE-END*/;

function reviewDbAsArray(value) {
  return Array.isArray(value) ? value : [];
}

function reviewDbCleanString(value) {
  return value == null ? '' : String(value).trim();
}

function reviewDbSafeDate(value) {
  return reviewDbCleanString(value);
}

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

function reviewDbAthleteName(athletes, athleteId) {
  return athletes.find(a => a.id === athleteId)?.name || athleteId || 'Unknown athlete';
}

function reviewDbMetricLabel(groups, metricId) {
  for (const group of reviewDbAsArray(groups)) {
    const metric = reviewDbAsArray(group.metrics).find(m => m.id === metricId);
    if (metric) return metric.label || metric.name || metric.id;
  }
  return metricId;
}

function reviewDbTrainingLabel(value) {
  const text = reviewDbCleanString(value);
  if (!text) return '';
  return text.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}

function reviewDbMissingReviewRow({ review, sourceType, sourceTypeLabel, athleteId, athletes, sourceRef, sourceLabel, editorKind, lifecycle }) {
  const lifecycleState = lifecycle.deriveLifecycle({ review, sourceSnapshot: null, sourceAvailable: false }).state;
  const isCmj = sourceType === 'cmj';
  return {
    id: `${sourceType}:missing:${review?.id || JSON.stringify(sourceRef)}`,
    sourceType,
    sourceTypeLabel,
    athleteId,
    athleteName: reviewDbAthleteName(athletes, athleteId),
    sessionId: sourceRef?.sessionId || '',
    assessmentId: sourceRef?.assessmentId || '',
    testDefId: sourceRef?.testDefId || '',
    metricId: sourceRef?.metricId || '',
    sourceDate: reviewDbSafeDate(review?.sourceSnapshot?.version?.savedAt || review?.reviewedAt || review?.updatedAt),
    lastEdited: reviewDbSafeDate(review?.updatedAt || review?.reviewedAt),
    sourceLabel: `${sourceLabel || sourceTypeLabel} · source deleted`,
    status: review?.status || 'not-reviewed',
    lifecycleState,
    review,
    reviewRevision: review?.reviewRevision || 0,
    reviewedBy: review?.reviewedBy || null,
    reviewedAt: review?.reviewedAt || '',
    reviewHistory: reviewDbAsArray(review?.reviewHistory),
    conclusion: isCmj ? review?.classificationStatement : review?.sportsScientistConclusion,
    trainingFocus: isCmj ? reviewDbTrainingLabel(review?.trainingImplication) : review?.reviewerTrainingFocus,
    editorTrainingFocus: isCmj ? review?.trainingImplication : review?.reviewerTrainingFocus,
    noteExcerpt: isCmj ? review?.notes : review?.reviewerNote,
    sourceAvailable: false,
    reviewEligible: false,
    reviewEligibilityReason: 'The reviewed source has been deleted. This audit history is read-only.',
    canOpenReview: false,
    canOpenReport: false,
    canEditReview: false,
    canOpenSource: false,
    editorKind: editorKind || 'standard',
    sourceSnapshot: null,
    sourceRef,
    sourceData: null,
  };
}

function reviewDbSessionRows({ type, store, reviews, athletes, lifecycle }) {
  const reviewBySession = new Map(reviewDbAsArray(reviews).map(review => [review.sessionId, review]));
  const typeLabel = type.toUpperCase();
  const rows = Object.entries(store || {}).flatMap(([athleteId, sessions]) => (
    reviewDbAsArray(sessions).map(session => {
      const review = reviewBySession.get(session.id) || null;
      const isCmj = type === 'cmj';
      const sourceSnapshot = lifecycle.createSourceSnapshot({
        kind: 'force-session',
        ref: { sourceType: type, athleteId, sessionId: session.id },
        version: {
          savedAt: session.provenance?.savedAt || session.updatedAt || session.createdAt || session.date || '',
          algorithmVersion: session.algorithmRef?.version || '',
        },
        evidence: {
          trialCount: reviewDbAsArray(session.trials).length,
          representativeTrialIndex: session.representativeTrialIndex ?? session.bestTrialIndex ?? null,
        },
      });
      const lifecycleState = lifecycle.deriveLifecycle({ review, sourceSnapshot, sourceAvailable: true }).state;
      return {
        id: `${type}:${session.id}`,
        sourceType: type,
        sourceTypeLabel: `${typeLabel} Session`,
        athleteId,
        athleteName: reviewDbAthleteName(athletes, athleteId),
        sessionId: session.id,
        sourceDate: reviewDbSafeDate(session.date || session.createdAt || session.provenance?.savedAt),
        lastEdited: reviewDbSafeDate(review?.updatedAt || review?.reviewedAt || session.provenance?.savedAt || session.createdAt || session.date),
        sourceLabel: session.fileName || session.label || session.name || `${typeLabel} session`,
        status: review?.status || 'not-reviewed',
        lifecycleState,
        review,
        reviewRevision: review?.reviewRevision || 0,
        reviewedBy: review?.reviewedBy || null,
        reviewedAt: review?.reviewedAt || '',
        reviewHistory: reviewDbAsArray(review?.reviewHistory),
        conclusion: isCmj ? review?.classificationStatement : review?.sportsScientistConclusion,
        trainingFocus: isCmj ? reviewDbTrainingLabel(review?.trainingImplication) : review?.reviewerTrainingFocus,
        editorTrainingFocus: isCmj ? review?.trainingImplication : review?.reviewerTrainingFocus,
        noteExcerpt: isCmj ? review?.notes : review?.reviewerNote,
        sourceAvailable: true,
        canOpenReview: false,
        canOpenReport: lifecycleState === 'confirmed',
        canEditReview: true,
        canOpenSource: true,
        editorKind: isCmj ? 'cmj' : 'standard',
        sourceSnapshot,
        sourceRef: { sourceType: type, athleteId, sessionId: session.id },
        // Read-side payload for the same-page review evidence surface and the
        // versioned machine-readable detail contract. Never mutated by the page.
        sourceData: session,
      };
    })
  ));
  const liveSessionIds = new Set(rows.map(row => row.sessionId));
  reviewDbAsArray(reviews)
    .filter(review => review?.sessionId && !liveSessionIds.has(review.sessionId))
    .forEach(review => {
      const sourceRef = review.sourceSnapshot?.ref || {
        sourceType: type,
        athleteId: review.athleteId || '',
        sessionId: review.sessionId,
      };
      rows.push(reviewDbMissingReviewRow({
        review,
        sourceType: type,
        sourceTypeLabel: `${typeLabel} Session`,
        athleteId: review.athleteId || sourceRef.athleteId || '',
        athletes,
        sourceRef,
        sourceLabel: `${typeLabel} session`,
        editorKind: type === 'cmj' ? 'cmj' : 'standard',
        lifecycle,
      }));
    });
  return rows;
}

function reviewDbFieldRows({ sources, reviews, athletes, lifecycle }) {
  const plans = reviewDbAsArray(sources?.plans);
  const records = reviewDbAsArray(sources?.records);
  const testDefs = reviewDbAsArray(sources?.testDefs);
  const planById = new Map(plans.map(plan => [plan.id, plan]));
  const testDefById = new Map(testDefs.map(test => [test.id, test]));
  const reviewBySource = new Map(reviewDbAsArray(reviews).map(review => [`${review.assessmentId}::${review.testDefId}`, review]));

  const rows = records.flatMap(record => {
    const results = record?.results && typeof record.results === 'object' ? record.results : {};
    const plan = planById.get(record.planId) || null;
    return Object.entries(results)
      .filter(([, result]) => result && (result.best != null || reviewDbAsArray(result.trials).length))
      .map(([testDefId, result]) => {
        const testDef = testDefById.get(testDefId) || {};
        const review = reviewBySource.get(`${record.id}::${testDefId}`) || null;
        const label = testDef.label || testDef.name || testDef.title || testDefId;
        const intake = record?.meta?.nativeIntake || null;
        const reviewEligible = intake?.state === 'confirmed' && intake?.publishState === 'published';
        const sourceAvailable = !!(result && (result.best != null || reviewDbAsArray(result.trials).length));
        const sourceSnapshot = lifecycle.createSourceSnapshot({
          kind: 'field-test',
          ref: { sourceType: 'field', athleteId: record.athleteId, assessmentId: record.id, testDefId },
          version: { confirmationRevision: intake?.confirmationRevision || 0, confirmedAt: intake?.confirmedAt || '' },
          evidence: intake?.confirmedMetrics?.[testDefId] || { best: result?.best ?? null, unit: result?.unit || '' },
        });
        const lifecycleState = lifecycle.deriveLifecycle({ review, sourceSnapshot, sourceAvailable }).state;
        const testType = reviewDbCleanString(testDef.category || testDef.type || testDef.group || 'field').toLowerCase();
        return {
          id: `field:${record.id}:${testDefId}`,
          sourceType: 'field',
          sourceTypeLabel: 'Field Test',
          athleteId: record.athleteId,
          athleteName: reviewDbAthleteName(athletes, record.athleteId),
          assessmentId: record.id,
          testDefId,
          sourceDate: reviewDbSafeDate(record.date || plan?.date),
          lastEdited: reviewDbSafeDate(review?.updatedAt || review?.reviewedAt || record.updatedAt || record.date || plan?.date),
          sourceLabel: `${label}${result?.best != null ? ` · ${result.best}${result.unit ? ` ${result.unit}` : ''}` : ''}`,
          status: review?.status || 'not-reviewed',
          lifecycleState,
          review,
          reviewRevision: review?.reviewRevision || 0,
          reviewedBy: review?.reviewedBy || null,
          reviewedAt: review?.reviewedAt || '',
          reviewHistory: reviewDbAsArray(review?.reviewHistory),
          conclusion: review?.sportsScientistConclusion,
          trainingFocus: review?.reviewerTrainingFocus,
          noteExcerpt: review?.reviewerNote,
          sourceAvailable,
          reviewEligible,
          reviewEligibilityReason: reviewEligible ? '' : 'Confirm and publish this field record before scientific review.',
          testType,
          canOpenReview: false,
          canOpenReport: lifecycleState === 'confirmed',
          canEditReview: reviewEligible,
          canOpenSource: true,
          editorKind: 'standard',
          sourceSnapshot,
          sourceRef: { sourceType: 'field_test', planId: record.planId, athleteId: record.athleteId, assessmentId: record.id, testDefId },
          sourceData: {
            recordId: record.id,
            planId: record.planId,
            athleteId: record.athleteId,
            date: record.date || plan?.date || '',
            testDefinition: testDef,
            result,
            intake,
          },
        };
      });
  });
  const liveSourceKeys = new Set(rows.map(row => `${row.assessmentId}::${row.testDefId}`));
  reviewDbAsArray(reviews)
    .filter(review => !liveSourceKeys.has(`${review?.assessmentId}::${review?.testDefId}`))
    .forEach(review => {
      const sourceRef = review.sourceSnapshot?.ref || {
        sourceType: 'field_test',
        athleteId: review.athleteId || '',
        assessmentId: review.assessmentId || '',
        testDefId: review.testDefId || '',
      };
      rows.push(reviewDbMissingReviewRow({
        review,
        sourceType: 'field',
        sourceTypeLabel: 'Field Test',
        athleteId: review.athleteId || sourceRef.athleteId || '',
        athletes,
        sourceRef,
        sourceLabel: review.testLabel || 'Field test',
        lifecycle,
      }));
    });
  return rows;
}

function reviewDbManualRows({ athletes, groups, reviews, editable = false, lifecycle }) {
  const rows = [];
  const reviewBySource = new Map(reviewDbAsArray(reviews).map(review => [`${review.athleteId}::${review.date}::${review.metricId}`, review]));
  reviewDbAsArray(athletes).forEach(athlete => {
    const provenance = athlete.provenance || {};
    Object.entries(athlete.seasons || {}).forEach(([date, values]) => {
      Object.entries(values || {}).forEach(([metricId, value]) => {
        if (value == null || value === '') return;
        const source = provenance?.[date]?.[metricId]?.source || '';
        if (source === 'force_plate_upload' || source === 'field_test') return;
        const review = reviewBySource.get(`${athlete.id}::${date}::${metricId}`) || null;
        const metricLabel = reviewDbMetricLabel(groups, metricId);
        const sourceMeta = provenance?.[date]?.[metricId] || {};
        const sourceSnapshot = lifecycle.createSourceSnapshot({
          kind: 'manual-metric',
          ref: { sourceType: 'manual', athleteId: athlete.id, date, metricId },
          version: { savedAt: sourceMeta.savedAt || date, source: sourceMeta.source || 'manual' },
          evidence: { value },
        });
        const lifecycleState = lifecycle.deriveLifecycle({ review, sourceSnapshot, sourceAvailable: true }).state;
        rows.push({
          id: `manual:${athlete.id}:${date}:${metricId}`,
          sourceType: 'manual',
          sourceTypeLabel: 'Manual Entry',
          athleteId: athlete.id,
          athleteName: athlete.name,
          metricId,
          sourceDate: date,
          lastEdited: reviewDbSafeDate(review?.updatedAt || review?.reviewedAt || date),
          sourceLabel: `${metricLabel} · ${value}`,
          metricLabel,
          status: review?.status || 'not-reviewed',
          lifecycleState,
          review,
          reviewRevision: review?.reviewRevision || 0,
          reviewedBy: review?.reviewedBy || null,
          reviewedAt: review?.reviewedAt || '',
          reviewHistory: reviewDbAsArray(review?.reviewHistory),
          conclusion: review?.sportsScientistConclusion,
          trainingFocus: review?.reviewerTrainingFocus,
          noteExcerpt: review?.reviewerNote,
          sourceAvailable: true,
          canOpenReview: false,
          canOpenReport: lifecycleState === 'confirmed',
          canEditReview: !!editable,
          canOpenSource: true,
          editorKind: 'standard',
          sourceSnapshot,
          sourceRef: { sourceType: 'manual', athleteId: athlete.id, date, metricId },
          sourceData: {
            athleteId: athlete.id,
            date,
            metricId,
            metricLabel,
            value,
            provenance: sourceMeta,
          },
        });
      });
    });
  });
  const liveSourceKeys = new Set(rows.map(row => `${row.athleteId}::${row.sourceDate}::${row.metricId}`));
  reviewDbAsArray(reviews)
    .filter(review => !liveSourceKeys.has(`${review?.athleteId}::${review?.date}::${review?.metricId}`))
    .forEach(review => {
      const sourceRef = review.sourceSnapshot?.ref || {
        sourceType: 'manual',
        athleteId: review.athleteId || '',
        date: review.date || '',
        metricId: review.metricId || '',
      };
      rows.push(reviewDbMissingReviewRow({
        review,
        sourceType: 'manual',
        sourceTypeLabel: 'Manual Entry',
        athleteId: review.athleteId || sourceRef.athleteId || '',
        athletes,
        sourceRef,
        sourceLabel: reviewDbMetricLabel(groups, review.metricId || sourceRef.metricId),
        lifecycle,
      }));
    });
  return rows;
}

function reviewDbSortRows(rows) {
  return reviewDbAsArray(rows)
    .slice()
    .sort((a, b) => reviewDbTimestamp(b.lastEdited || b.sourceDate) - reviewDbTimestamp(a.lastEdited || a.sourceDate));
}

function App({ routeTable, persistence, repositories = {} }) {
  const routes = routeTable;
  const appStore = persistence;
  const forceSessionRepo = repositories.forceSessions;
  const forceSessionReviewRepo = repositories.forceSessionReviews;
  const assessmentReviewRepo = repositories.assessmentReviews;
  const manualMetricReviewRepo = repositories.manualMetricReviews;
  const reviewEvidenceLifecycle = repositories.reviewEvidenceLifecycle;
  const individualReportDraftRepo = repositories.individualReportDrafts;
  const contextScope = repositories.contextScope;
  const forceTargetAdapter = repositories.forceTargetAdapter;
  const reportDraftAdapterModule = repositories.reportDraftAdapter;
  const knowledgeRegistry = repositories.knowledgeRegistry;
  const knowledgeRuleValidator = repositories.knowledgeRuleValidator;
  const reviewedEvidenceSummary = repositories.reviewedEvidenceSummary;
  const sjReviewedEvidenceSummary = repositories.sjReviewedEvidenceSummary;
  const imtpReviewedEvidenceSummary = repositories.imtpReviewedEvidenceSummary;
  const fieldTestReviewedEvidenceSummary = repositories.fieldTestReviewedEvidenceSummary;
  const individualReportEvidenceSummary = repositories.individualReportEvidenceSummary;
  const coachEvidenceSummary = repositories.coachEvidenceSummary;
  const evidenceUIContract = repositories.evidenceUIContract;
  const weeklyCoachReviewedEvidenceSummary = repositories.weeklyCoachReviewedEvidenceSummary;
  const reviewedEvidenceRows = repositories.reviewedEvidenceRows;
  const storyEvidenceSummary = repositories.storyEvidenceSummary;
  const trainingContextSeed = repositories.trainingContextSeed;
  const athleteRepo = repositories.athletes;
  const athleteCollection = repositories.athleteCollection;
  const legacyImportPlanner = repositories.legacyImportPlanner;
  const legacyImportRepository = repositories.legacyImportRepository;
  const athleteCascadeDelete = repositories.athleteCascadeDelete;
  const forceTraceRepo = repositories.forceTraces;
  const forceSourceFileRepo = repositories.forceSourceFiles;
  const forceTraceSaga = repositories.forceTraceSaga;
  const forceTraceReadModel = repositories.forceTraceReadModel || null;
  const forceTraceBackup = repositories.forceTraceBackup || null;
  const forceRestoreEngine = repositories.forceRestoreEngine || null;
  const knowledgeDocumentRepo = repositories.knowledgeDocuments || null;
  const knowledgeRetrieval = repositories.knowledgeRetrieval || null;
  const knowledgeReady = repositories.knowledgeReady || null;
  const confirmedActionExecutor = repositories.confirmedActionExecutor || null;
  const knowledgeOwnerId = repositories.knowledgeOwnerId || 'local-workspace-owner';
  // FORCE-TRACE M3-A: the analysis UI gets a READ-ONLY accessor — never the write-capable
  // repository (no saveMany/deleteBySession in UI hands) and no window global.
  const forceTraceRead = useM(() => forceTraceRepo
    ? { get: forceTraceRepo.get, listBySession: forceTraceRepo.listBySession, traceId: forceTraceRepo.traceId }
    : null, [forceTraceRepo]);
  const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);

  // Mutable working copies — loaded through the persistence boundary, fall back to built-in data
  const [groups, setGroups] = useS(() => {
    const raw = appStore.get('_groups', D.METRIC_GROUPS);
    // Merge in any groups/metrics added since this cache was saved (e.g.
    // Training Load, ACWR) without overwriting user customisations.
    const byId = Object.fromEntries(raw.map(g => [g.id, g]));
    const merged = raw.slice();
    D.METRIC_GROUPS.forEach(defGroup => {
      const existing = byId[defGroup.id];
      if (!existing) {
        merged.push(defGroup);
      } else {
        // Append any metrics in defaults that the user doesn't have yet
        const metricIds = new Set(existing.metrics.map(m => m.id));
        defGroup.metrics.forEach(m => {
          if (!metricIds.has(m.id)) existing.metrics.push(m);
        });
      }
    });
    return merged;
  });
  const [athletes, setAthletes] = useS(() => {
    // Migrate cached athletes that pre-date the sport/group schema.
    // Missing fields are defaulted to "Basketball" / "First Team" so the new
    // filter chips always have visible content even on stale persisted data.
    const raw = athleteRepo ? athleteRepo.list({ fallback: D.ATHLETES }) : appStore.get('_athletes', D.ATHLETES);
    // Index built-in defaults by id so we can backfill metrics added in
    // later releases (sRPE/duration/etc.) without wiping user-entered values.
    const defaultById = Object.fromEntries(D.ATHLETES.map(a => [a.id, a]));
    const migrated = raw.map(a => {
      const ref = defaultById[a.id];
      const seasons = { ...(a.seasons || {}) };
      if (ref) {
        Object.keys(ref.seasons || {}).forEach(date => {
          const existing = seasons[date] || {};
          const defaults = ref.seasons[date] || {};
          // Keep every value the user already has; ADD anything the defaults
          // know about that the user didn't have yet (e.g. sRPE, duration_min).
          const merged = { ...existing };
          Object.keys(defaults).forEach(k => {
            if (merged[k] == null) merged[k] = defaults[k];
          });
          seasons[date] = merged;
        });
      }
      return {
        ...a,
        sport: a.sport || 'Basketball',
        group: a.group || 'First Team',
        seasons,
      };
    });
    // Make sure session_load + acwr are derived from whatever sRPE/duration
    // values we now have (either user-entered or backfilled).
    return migrated.map(a => D.recomputeLoadForAthlete(a));
  });

  const persistAthleteCollection = (nextAthletes, prevAthletes = []) => {
    const nextList = Array.isArray(nextAthletes) ? nextAthletes : [];
    if (!athleteCollection) {
      appStore.set('_athletes', nextList);
      return nextList;
    }
    try {
      return athleteCollection.persist(nextList, prevAthletes);
    } catch (err) {
      console.warn('Athlete repository persist failed; falling back to app persistence:', err);
      appStore.set('_athletes', nextList);
    }
    return nextList;
  };

  const setAthletesViaRepository = (updater) => {
    setAthletes(prev => {
      const next = typeof updater === 'function' ? updater(prev) : updater;
      return persistAthleteCollection(next, prev);
    });
  };

  const [seasons, setSeasons]   = useS(() => appStore.get('_seasons',  D.SEASONS));
  const [algo, setAlgo]         = useS(() => appStore.get('_algo', 'range'));
  const [trendGrouping, setTrendGrouping] = useS('day');

  // Auto-recompute each metric's normative range (worst/avg/best) whenever
  // the underlying data changes — replaces the old manual inputs in Settings.
  useE(() => {
    setGroups(g => D.autoComputeMetricRanges(g, athletes, seasons));
  }, [athletes, seasons]);

  const initialRouteRef = useR(undefined);
  if (initialRouteRef.current === undefined) {
    initialRouteRef.current = typeof window !== 'undefined'
      ? routes.routeFromHash(window.location.hash, athletes)
      : null;
  }
  const initialRoute = initialRouteRef.current;
  const [selectedId, commitSelectedId] = useS(initialRoute?.selectedId || athletes[0]?.id || null);
  const [view, commitView] = useS(initialRoute?.view || 'individual');
  // Which stored session id to show in cmj-session view
  const [cmjSessionId, setCmjSessionId] = useS(null);
  // Session id to highlight in SJ/IMTP longitudinal view when navigating from Workbench
  const [reviewSessionId, setReviewSessionId] = useS(null);
  // FORCE-WS-3a (2026-07-12): 测力台 采集/分析 sub-mode (per-type collect↔analyze split,
  // F3). Persisted like _forceView so the workspace re-opens on the last sub-mode.
  const [forceCaptureMode, setForceCaptureMode] = useS(() => appStore.get('_forceCaptureMode', 'collect'));
  // The just-saved session ({ type, id }) driving the save→分析 jump + 刚保存 cue.
  // Transient (not persisted) — consumed once the analysis face is left / re-selected.
  const [forceFreshSession, setForceFreshSession] = useS(null);
  // Item 4: calendar → field-test routing signals (each consumed once on mount of FieldTestView)
  const [fieldAutoBuild, setFieldAutoBuild] = useS(false);
  const [fieldInitialPlanId, setFieldInitialPlanId] = useS(null);
  const [fieldInitialSourceFocus, setFieldInitialSourceFocus] = useS(null);
  // Item 6: individual page → injury case routing ('new' to start logging, or an injuryId)
  const [injuryCtx, setInjuryCtx] = useS(null);
  // Calendar → training: open a specific program in the builder (consumed once on mount)
  const [trainingInitialProgramId, setTrainingInitialProgramId] = useS(null);
  // Mobile: off-canvas roster drawer open state (≤760px)
  const [sidebarOpen, setSidebarOpen] = useS(false);
  // S4: roster collapse (Style A) — persisted. Roster only mounts on team/individual.
  const [rosterCollapsed, setRosterCollapsed] = useS(() => appStore.getRaw('pd_roster_collapsed') === '1');
  const toggleRosterCollapsed = () => setRosterCollapsed(c => {
    const n = !c;
    appStore.setRaw('pd_roster_collapsed', n ? '1' : '0');
    return n;
  });
  // 'lab' (default — full research-grade detail) | 'coach' (decision-first, insight feed)
  const [mode, setMode] = useS(() => appStore.get('_mode', 'lab'));
  useE(() => appStore.set('_mode', mode), [mode]);
  // NAV-2: global time-window, persisted so it survives refresh (mirrors _seasons).
  // Guard against a saved season that is no longer in the list → fall back to first.
  const [season, commitSeason] = useS(() => {
    if (initialRoute?.seasonId && seasons.includes(initialRoute.seasonId)) return initialRoute.seasonId;
    const saved = appStore.get('_season', null);
    return (saved && seasons.includes(saved)) ? saved : seasons[0];
  });
  const reportDraftAdapterRef = useR(null);
  if (!reportDraftAdapterRef.current) {
    reportDraftAdapterRef.current = reportDraftAdapterModule.createReportDraftAdapter({
      repository: individualReportDraftRepo,
      contextScope,
    });
  }
  const selectedIdRef = useR(selectedId);
  const seasonRef = useR(season);
  const viewRef = useR(view);
  const contextHistoryIndexKey = '__axisContextIndex';
  const contextHistoryIndexRef = useR(null);
  const contextHistorySuppressionRef = useR(null);
  const historySettlingRef = useR(null);
  const [historySettling, setHistorySettling] = useS(false);
  const getHistorySettling = React.useCallback(() => historySettlingRef.current, []);
  const beginHistorySettling = React.useCallback((transaction) => {
    if (historySettlingRef.current) return false;
    historySettlingRef.current = transaction || { kind: 'unknown' };
    setHistorySettling(true);
    return true;
  }, []);
  const endHistorySettling = React.useCallback(() => {
    if (!historySettlingRef.current) return false;
    historySettlingRef.current = null;
    setHistorySettling(false);
    return true;
  }, []);
  if (contextHistoryIndexRef.current == null && typeof window !== 'undefined') {
    const storedIndex = history.state?.[contextHistoryIndexKey];
    const initialIndex = Number.isInteger(storedIndex) ? storedIndex : 0;
    contextHistoryIndexRef.current = initialIndex;
    if (!Number.isInteger(storedIndex)) {
      history.replaceState({ ...(history.state || {}), [contextHistoryIndexKey]: initialIndex }, '', window.location.href);
    }
  }
  selectedIdRef.current = selectedId;
  seasonRef.current = season;
  viewRef.current = view;
  const canonicalContextHash = React.useCallback((nextView, nextAthleteId, nextSeason) => (
    routes.hashFromRoute(nextView, nextAthleteId, nextSeason)
  ), [routes]);
  const transitionContextRef = useR(null);
  const transitionContext = React.useCallback((intent = {}, options = {}) => {
    const previous = {
      view: viewRef.current,
      selectedId: selectedIdRef.current,
      season: seasonRef.current,
    };
    const replaceHistoryUrl = (url, index = contextHistoryIndexRef.current) => {
      history.replaceState({ ...(history.state || {}), [contextHistoryIndexKey]: index }, '', url);
    };
    const restorePreviousHistory = () => {
      const restore = canonicalContextHash(previous.view, previous.selectedId, previous.season);
      if (options.history === 'pop' && Number.isInteger(options.targetIndex)) {
        const delta = contextHistoryIndexRef.current - options.targetIndex;
        if (delta) {
          if (!beginHistorySettling({
            kind: 'rollback', targetIndex: contextHistoryIndexRef.current, releaseOnPop: false,
          })) return;
          contextHistorySuppressionRef.current = contextHistoryIndexRef.current;
          history.go(delta);
          return;
        }
      }
      if (restore && restore !== window.location.hash) replaceHistoryUrl(restore);
    };
    if (historySettlingRef.current) return false;
    if (reportDraftAdapterRef.current.hasBlocked() && !options.resolveBlocked) {
      restorePreviousHistory();
      return false;
    }
    const next = {
      view: intent.view == null ? previous.view : intent.view,
      selectedId: intent.selectedId == null ? previous.selectedId : intent.selectedId,
      season: intent.season == null ? previous.season : intent.season,
    };
    if (!routes.isRouteId(next.view)
      || (next.selectedId && !athletes.some(item => item.id === next.selectedId))
      || (next.season && !seasons.includes(next.season))) {
      restorePreviousHistory();
      return false;
    }
    const changed = next.view !== previous.view || next.selectedId !== previous.selectedId || next.season !== previous.season;
    if (changed) {
      const flushed = options.skipFlush ? { ok: true } : reportDraftAdapterRef.current.flush();
      if (!flushed.ok) {
        restorePreviousHistory();
        const replayHistoryTarget = (discard) => {
          if (historySettlingRef.current) return false;
          const settled = discard
            ? reportDraftAdapterRef.current.discard()
            : reportDraftAdapterRef.current.flush();
          if (!settled.ok) return false;
          reportDraftAdapterRef.current.clearBlocked();
          const delta = options.targetIndex - contextHistoryIndexRef.current;
          if (delta) {
            if (!beginHistorySettling({
              kind: 'blocked-replay', targetIndex: options.targetIndex, releaseOnPop: true,
            })) return false;
            history.go(delta);
          }
          return true;
        };
        reportDraftAdapterRef.current.block({
          retry: options.history === 'pop' ? () => replayHistoryTarget(false) : () => {
            if (historySettlingRef.current) return false;
            reportDraftAdapterRef.current.clearBlocked();
            return transitionContextRef.current(intent, { ...options, resolveBlocked: true });
          },
          discard: options.history === 'pop' ? () => replayHistoryTarget(true) : () => {
            if (historySettlingRef.current) return false;
            const discarded = reportDraftAdapterRef.current.discard();
            if (!discarded.ok) return false;
            reportDraftAdapterRef.current.clearBlocked();
            return transitionContextRef.current(intent, { ...options, skipFlush: true, resolveBlocked: true });
          },
          cancel: () => {
            if (historySettlingRef.current) return false;
            reportDraftAdapterRef.current.clearBlocked();
            const current = { view: viewRef.current, selectedId: selectedIdRef.current, season: seasonRef.current };
            const canonical = canonicalContextHash(current.view, current.selectedId, current.season);
            if (canonical && canonical !== window.location.hash) replaceHistoryUrl(canonical);
            return true;
          },
        });
        return false;
      }
      viewRef.current = next.view;
      selectedIdRef.current = next.selectedId;
      seasonRef.current = next.season;
      if (next.selectedId !== previous.selectedId) commitSelectedId(next.selectedId);
      if (next.season !== previous.season) commitSeason(next.season);
      if (next.view !== previous.view) commitView(next.view);
    }
    const canonical = canonicalContextHash(next.view, next.selectedId, next.season);
    if (options.history === 'pop' && Number.isInteger(options.targetIndex)) {
      contextHistoryIndexRef.current = options.targetIndex;
      if (canonical && canonical !== window.location.hash) replaceHistoryUrl(canonical, options.targetIndex);
    } else if (options.history === 'hash') {
      const nextIndex = Number.isInteger(options.targetIndex)
        ? options.targetIndex
        : contextHistoryIndexRef.current + 1;
      contextHistoryIndexRef.current = nextIndex;
      replaceHistoryUrl(canonical || window.location.hash, nextIndex);
    } else if (options.history === 'none' || options.history === 'replace') {
      if (canonical && canonical !== window.location.hash) replaceHistoryUrl(canonical);
    } else if (canonical && canonical !== window.location.hash) {
      const nextIndex = contextHistoryIndexRef.current + 1;
      history.pushState({ ...(history.state || {}), [contextHistoryIndexKey]: nextIndex }, '', canonical);
      contextHistoryIndexRef.current = nextIndex;
    }
    return true;
  }, [athletes, seasons, routes, canonicalContextHash, beginHistorySettling]);
  transitionContextRef.current = transitionContext;
  const setSelectedId = React.useCallback((nextValue) => {
    const previous = selectedIdRef.current;
    const next = typeof nextValue === 'function' ? nextValue(previous) : nextValue;
    return transitionContext({ selectedId: next });
  }, [transitionContext]);
  const setSeason = React.useCallback((nextValue) => {
    const previous = seasonRef.current;
    const next = typeof nextValue === 'function' ? nextValue(previous) : nextValue;
    return transitionContext({ season: next });
  }, [transitionContext]);
  const setView = React.useCallback((nextValue) => {
    const previous = viewRef.current;
    const next = typeof nextValue === 'function' ? nextValue(previous) : nextValue;
    return transitionContext({ view: next });
  }, [transitionContext]);
  const [entryOpen, setEntryOpen] = useS(false);
  const [entryDefaultDate, setEntryDefaultDate] = useS(null);
  const [activeGroup, setActiveGroup] = useS(null);
  // MB-3: Trend module holds up to 2 catalog ids (dual-Y overlay), not a single
  // seasons metric object. Migrate the legacy `_activeMetricId` seasons id into
  // trendMetricIds[0] on first load; default to the first metric of the 2nd group.
  const [trendMetricIds, setTrendMetricIds] = useS(() => {
    const saved = appStore.get('_trendMetricIds', null);
    if (Array.isArray(saved) && saved.length) return saved.slice(0, 2).map(String);
    const legacy = appStore.get('_activeMetricId', null);
    if (legacy) {
      const found = D.METRIC_GROUPS.flatMap(g => g.metrics).find(m => m.id === legacy && !m.computed);
      if (found) return [String(found.id)];
    }
    return [String(D.METRIC_GROUPS[1].metrics[0].id)];
  });
  useE(() => appStore.set('_trendMetricIds', trendMetricIds), [trendMetricIds]);
  // Add (append, honoring the max-2 cap — no-op if full or already present) and
  // remove one id. Never drops below 1 metric.
  const addTrendMetric = (id) => setTrendMetricIds(ids =>
    ids.includes(id) || ids.length >= 2 ? ids : [...ids, String(id)]);
  const removeTrendMetric = (id) => setTrendMetricIds(ids =>
    ids.length <= 1 ? ids : ids.filter(x => x !== id));
  const [compareMode, setCompareMode] = useS(false);
  const [compareIds, setCompareIds] = useS(() => athletes.length >= 2
    ? [athletes[0].id, athletes[1].id]
    : athletes.map(a => a.id));
  const [forceCompareType, setForceCompareType] = useS('cmj');
  // ── 多语言状态 — 驱动整树重渲染 ────────────────────────────────────
  const [lang, setLang] = useS(window.APP_LANG || 'zh');
  const toggleLang = () => {
    const nl = lang === 'zh' ? 'en' : 'zh';
    window.setAppLang(nl);
    setLang(nl);
  };

  const [exportOpen, setExportOpen] = useS(false);
  const [reportOpen, setReportOpen] = useS(false);
  const closeIndividualReportModal = React.useCallback((afterClose) => {
    if (historySettlingRef.current) return false;
    const result = reportDraftAdapterRef.current?.flush?.() || { ok: true };
    if (!result.ok) return false;
    setReportOpen(false);
    if (typeof afterClose === 'function') afterClose();
    return true;
  }, []);
  const [cmjReportOpen, setCmjReportOpen] = useS(false);
  const [cmjReportAthleteId, setCmjReportAthleteId] = useS(null);
  const [cmjReportSessionId, setCmjReportSessionId] = useS(null);
  const [reviewedEvidenceVersion, setReviewedEvidenceVersion] = useS(0);
  const [reviewerName, setReviewerName] = useS(() => appStore.get('_reviewerName', ''));
  const [reportEvidenceSelection, setReportEvidenceSelection] = useS(null);
  const [trainingContext, setTrainingContext] = useS(null);
  const [teamReportOpen, setTeamReportOpen] = useS(false);
  const [settingsOpen, setSettingsOpen] = useS(false);
  const [settingsTab, setSettingsTab] = useS('metrics');
  // Four-level traffic light risk config (P1-E).
  // acwr thresholds: <low = undertrain; low–mod = optimal; mod–high = caution; >high = elevated; >vhigh = red
  // cmjDrop / mRSIDrop: % drop from individual rolling mean that triggers amber/red flags
  const RISK_CONFIG_DEFAULT = {
    enabled: false,
    acwr:     { low: 0.8, mod: 1.3, high: 1.5, vhigh: 1.8 },
    cmjDrop:  { amber: 5,    red: 10   },   // % below 4-session rolling mean
    mRSIDrop: { amber: 0.05, red: 0.10 },   // absolute mRSI units below 4-session mean
  };
  const [riskConfig, setRiskConfig] = useS(() => {
    const saved = appStore.get('_riskConfig', null);
    if (!saved) return RISK_CONFIG_DEFAULT;
    // Migrate old flat format { absThreshold, dropThreshold } → new four-level structure
    if (saved.absThreshold != null || saved.dropThreshold != null) {
      return { ...RISK_CONFIG_DEFAULT, enabled: saved.enabled ?? false };
    }
    return { ...RISK_CONFIG_DEFAULT, ...saved };
  });
  const [trendConfig, setTrendConfig] = useS(() => appStore.get('_trendConfig', { showDataLabels: false, showBenchmarkLine: false }));
  // Which metrics show up as the 4 "headline" tiles on the individual page.
  // Stored per-athlete so different people can have different focus metrics.
  // null/empty → auto-pick (existing behavior: most-recently-changed metrics).
  const [tilesByAthlete, setTilesByAthlete] = useS(() => appStore.get('_tilesByAthlete', {}));
  useE(() => appStore.set('_tilesByAthlete', tilesByAthlete), [tilesByAthlete]);

  // ── Force session stores: { [athleteId]: session[] } ─────────────────────
  // Each session is a compact snapshot: metadata + per-trial metrics + downsampled curve.
  // Stored per-athlete in separate persistence keys to avoid size collisions.
  const loadForceStore = (type) => {
    const allAthletes = athleteRepo ? athleteRepo.list({ fallback: D.ATHLETES }) : appStore.get('_athletes', D.ATHLETES);
    return forceSessionRepo.loadForAthletes(type, allAthletes);
  };
  const [cmjStore,  setCmjStore]  = useS(() => loadForceStore('cmj'));
  const [sjStore,   setSjStore]   = useS(() => loadForceStore('sj'));
  const [imtpStore, setImtpStore] = useS(() => loadForceStore('imtp'));

  // FORCE-TRACE M2c reconciliation (GPT F1/F2/F5): one-shot startup repair of CMJ traces ⇄ sessions.
  // A lifecycle promise (reconcileLock) SERIALIZES it with save/delete — those await it so a save
  // during the scan can never be clobbered, and the scan can never delete a just-saved session's
  // trace. Dangling 'available' refs are downgraded POINTWISE (re-read fresh, rewrite ONLY the
  // owning athlete's key) — never replaceAll, which drops legacy sessions that lack an embedded
  // athleteId. Orphan traces (session gone) are deleted; a visible diagnostic is persisted.
  const reconcileLock = useR(Promise.resolve());
  useE(() => {
    if (!repositories.forceTraces || typeof window === 'undefined' || !window.reconcileForceTraces) return;
    const traceRepo = repositories.forceTraces;
    let cancelled = false;
    reconcileLock.current = (async () => {
      try {
        // FORCE-TRACE M3-D (contract #3): an unfinished restore journal is recovered FIRST —
        // before the M2 reconcile can see (and "repair") a half-switched state.
        if (forceRestoreEngine) {
          const rec = await forceRestoreEngine.recoverIfNeeded();
          if (rec && rec.recovered) {
            console.info('[force-restore recovery]', rec.action);
            if (!cancelled) {
              setCmjStore(loadForceStore('cmj')); setSjStore(loadForceStore('sj')); setImtpStore(loadForceStore('imtp'));
            }
          }
        }
        const allAthletes = athleteRepo ? athleteRepo.list({ fallback: D.ATHLETES }) : appStore.get('_athletes', D.ATHLETES);
        const store = forceSessionRepo.loadForAthletes('cmj', allAthletes);
        // Keep the athlete grouping so a dangling downgrade can rewrite the owning key even when the
        // session body has no athleteId (legacy) — the athleteId comes from the STORE key, not body.
        const athleteOf = new Map();
        Object.entries(store).forEach(([aid, sessions]) => (sessions || []).forEach(s => { if (s && s.id != null) athleteOf.set(s.id, aid); }));
        const liveSessions = Object.values(store).flat();
        const traceMetas = await traceRepo.listMetadata();
        if (cancelled) return;
        const plan = window.reconcileForceTraces({ traceMetas, liveSessions });
        const downgradeFailedAthleteIds = [];
        for (const sid of plan.orphanSessionIds) {
          try { await traceRepo.deleteBySession(sid); } catch (e) { console.warn('force-trace orphan delete failed', sid, e); }
        }
        if (!cancelled && plan.danglingRefs.length) {
          const danglingIds = new Set(plan.danglingRefs.map(d => d.refId));
          const downgrade = (s) => ({
            ...s,
            traceRefs: Array.isArray(s.traceRefs) ? s.traceRefs.map(r => (r && danglingIds.has(r.id)) ? { ...r, status: 'missing' } : r) : s.traceRefs,
            traceRef: (s.traceRef && danglingIds.has(s.traceRef.id)) ? { ...s.traceRef, status: 'missing' } : s.traceRef,
          });
          const affectedAthletes = new Set(plan.danglingRefs.map(d => athleteOf.get(d.sessionId)).filter(v => v != null));
          const targetSessions = new Set(plan.danglingRefs.map(d => d.sessionId));
          // Re-read each affected athlete's CURRENT array (never the stale snapshot) and rewrite
          // ONLY that key with the matching sessions downgraded — every other session verbatim. A
          // write failure is recorded (retried next startup) and NOT counted as repaired (GPT F2).
          const fresh = forceSessionRepo.loadForAthletes('cmj', allAthletes);
          affectedAthletes.forEach(aid => {
            const sessions = (fresh[aid] || []).map(s => (s && targetSessions.has(s.id)) ? downgrade(s) : s);
            const wres = forceSessionRepo.replaceAthleteSessions('cmj', aid, sessions);
            if (!wres || wres.persisted === false) downgradeFailedAthleteIds.push(aid);
          });
          if (!cancelled) setCmjStore(forceSessionRepo.loadForAthletes('cmj', allAthletes));
        }
        const repairedDangling = plan.danglingRefs.filter(d => !downgradeFailedAthleteIds.includes(athleteOf.get(d.sessionId))).length;
        const report = { at: new Date().toISOString(), ...plan.stats, orphanSessionIds: plan.orphanSessionIds, repairedDangling, downgradeFailedAthleteIds };
        try { appStore.set('_forceTraceReconcile', report); } catch {}
        if (plan.stats.orphanTraces || plan.stats.danglingAvailable) console.info('[force-trace reconcile]', report);
      } catch (e) { console.warn('force-trace reconcile skipped:', e); }
    })();
    return () => { cancelled = true; };
  }, []);   // once on mount

  const notifyForcePersistError = (testType, err) => {
    console.warn(`${testType} session save failed (likely localStorage quota):`, err);
    if (typeof window !== 'undefined' && typeof window.alert === 'function') {
      window.alert(`${testType} session could not be persisted: browser storage quota exceeded.\nOpen Settings → Data I/O → "Force session storage" to free space.`);
    }
  };
  const mirrorSavedForceSession = (prevStore, athleteId, session, limit = 20) => {
    const existing = prevStore[athleteId] || [];
    if (existing.some(s => s.id === session.id)) return prevStore;
    return { ...prevStore, [athleteId]: [session, ...existing].slice(0, limit) };
  };
  const mirrorDeletedForceSession = (prevStore, athleteId, sessionId) => {
    return { ...prevStore, [athleteId]: (prevStore[athleteId] || []).filter(session => session.id !== sessionId) };
  };
  // Every Force PERSISTENCE mutation runs inside this single serialized queue (GPT M3-D P1). It
  // registers the operation as the reconcileLock HOLDER for its FULL async lifecycle (via the
  // engine's runExclusive), so restore ↔ save/delete/clear/import/reset/cascade are mutually
  // exclusive in BOTH directions: a restore waits for an in-flight save, AND a save waits for an
  // in-flight restore. The persistence side-effect MUST run inside fn (NOT deferred into a React
  // state updater, whose run time the lock does not cover) — so SJ/IMTP persist explicitly here and
  // then setState with a pure mirror.
  const withForceLock = (fn) => {
    if (forceRestoreEngine && forceRestoreEngine.runExclusive) return forceRestoreEngine.runExclusive(reconcileLock, fn);
    const prev = reconcileLock.current;
    let release;
    reconcileLock.current = new Promise(r => { release = r; });
    return (async () => { try { await prev; } catch (_) {} try { return await fn(); } finally { release(); } })();
  };
  const persistForceSession = (type, athleteId, session, label) => {
    let persistErr = null;
    const result = forceSessionRepo.saveWithResult({ ...session, type, athleteId }, {
      onPersistError: (err) => { persistErr = err; notifyForcePersistError(label, err); },
    });
    return { ...result, persisted: !!result.persisted && !persistErr };
  };
  const validateForceWrite = (ticket, type, athleteId, session) => {
    if (!forceTargetAdapter || !ticket || !window.ForceSessionSource) return { ok: false, reason: 'invalid_target' };
    let candidate;
    try {
      candidate = forceTargetAdapter.captureEffective({
        athleteId, testType: type, session, sessionSource: window.ForceSessionSource,
      }).ticket;
    } catch (_) { return { ok: false, reason: 'invalid_target' }; }
    const identity = forceTargetAdapter.validate(ticket, candidate);
    if (!identity.ok) return identity;
    const current = forceSessionRepo.get({ type, athleteId, sessionId: session.id });
    return forceTargetAdapter.validateWrite(ticket, current ? { ...current, type, athleteId } : null);
  };

  // ── Force-plate → athlete profile data bridge (P1-B) ────────────────────
  // Writes a patch of { metricId: value } into athlete.seasons[date].
  // If the date is not yet in the seasons list, it is added automatically.
  // Triggers a full load recompute so ACWR stays derived.
  const patchAthleteMetrics = (athleteId, date, patch, sourceMeta = null) => {
    if (!athleteId || !date || !patch || !Object.keys(patch).length) return;
    setAthletesViaRepository(prev => prev.map(a => {
      if (a.id !== athleteId) return a;
      const newSeasons = { ...a.seasons };
      newSeasons[date] = { ...(newSeasons[date] || {}), ...patch };
      let provenance = a.provenance || {};
      if (sourceMeta) {
        const byMetric = { ...(provenance[date] || {}) };
        Object.keys(patch).forEach(metricId => {
          byMetric[metricId] = {
            ...sourceMeta,
            metricId,
            value: patch[metricId],
            savedAt: sourceMeta.savedAt || new Date().toISOString(),
          };
        });
        provenance = { ...provenance, [date]: byMetric };
      }
      return D.recomputeLoadForAthlete({ ...a, seasons: newSeasons, provenance });
    }));
    // Ensure the date appears in the seasons list used for charts / selectors
    setSeasons(prev => prev.includes(date) ? prev : [...prev, date].sort().reverse());
  };

  // FORCE-TRACE M2b/M2c: CMJ save is a trace-first saga. cmj.jsx hands the full-window trace for
  // EVERY valid trial alongside the compact session; the saga writes them to IndexedDB FIRST, then
  // persists the session (recording per-trial traceRefs = the real outcome) and rolls the traces
  // back if the session write fails. The mirror + profile writeback run inside persistSession, only
  // on a confirmed persist. saveWithResult gives the AUTHORITATIVE persisted + evictedSessionIds
  // (the store owns the limit — never re-hardcoded here). The save waits for the startup reconcile
  // so it can never be clobbered by a stale-snapshot writeback.
  const saveCMJSession = (athleteId, session, built = null, sourceFile = null, targetTicket = null) => withForceLock(async () => {
    const writeValidation = validateForceWrite(targetTicket, 'cmj', athleteId, session);
    if (!writeValidation.ok) return { persisted: false, reason: writeValidation.reason };
    const sessionId = session.id;
    let sourceFileRef = null;
    if (sourceFile && forceSourceFileRepo) {
      try { sourceFileRef = await forceSourceFileRepo.save({ sessionId, athleteId, testType: 'cmj', file: sourceFile }); }
      catch (error) { console.warn('CMJ original source file was not saved:', error); }
    }
    const sourceAwareSession = sourceFileRef ? { ...session, sourceFileRef } : session;
    const records = (built && built.records) || [];                 // one per accepted valid trial
    const buildOutcome = (built && built.outcome) || 'none';
    const repTrialIndex = (built && built.representativeTrialIndex) ?? session.representative?.index ?? 0;
    // Every trial that SHOULD have a trace (so a build/write failure records a missing ref for ALL
    // of them, not just the representative — GPT F4).
    const expectedTrialIndices = records.length ? records.map(r => r.trialIndex)
      : ((built && built.expectedTrialIndices && built.expectedTrialIndices.length) ? built.expectedTrialIndices : [repTrialIndex]);

    let authoritativeSession = null;
    const persistSession = (traceStatus) => {
      // SINGLE source of truth for availability = traceRefs[] (GPT F1). traceCapture holds ONLY the
      // build-failure diagnostic (reason / failedTrialIndex) — never a status that could contradict
      // traceRefs after a reconcile downgrade. Session-level availability is derived from traceRefs.
      let sessionToSave = sourceAwareSession;
      if ((traceStatus === 'available' || traceStatus === 'missing') && forceTraceRepo) {
        const refs = expectedTrialIndices.map(ti => forceTraceRepo.traceRef(sessionId, ti, traceStatus));
        const repId = forceTraceRepo.traceId(sessionId, repTrialIndex);
        const repRef = refs.find(t => t.id === repId) || refs[0];
        sessionToSave = { ...sourceAwareSession, traceRefs: refs, traceRef: repRef };
        if (traceStatus === 'missing') {
          const traceCapture = {};
          if (built && built.reason) traceCapture.reason = built.reason;
          if (built && built.failedTrialIndex != null) traceCapture.failedTrialIndex = built.failedTrialIndex;
          if (Object.keys(traceCapture).length) sessionToSave.traceCapture = traceCapture;
        }
      }

      let persistErr = null;
      const res = forceSessionRepo.saveWithResult({ ...sessionToSave, type: 'cmj', athleteId }, {
        onPersistError: (err) => { persistErr = err; notifyForcePersistError('CMJ', err); },
      });
      // Mirror + profile writeback ONLY when the session actually persisted (GPT F2). Mirror the
      // AUTHORITATIVE store slice the repository just wrote (limit already applied) — never a
      // hardcoded .slice(0,20) (GPT F3).
      if (res.persisted && !persistErr) {
        authoritativeSession = res.session;
        const authoritativeList = (res.store && res.store[athleteId]) || null;
        setCmjStore(prev => authoritativeList ? { ...prev, [athleteId]: authoritativeList } : mirrorSavedForceSession(prev, athleteId, res.session));
        if (session.date && session.best) {
          const b = session.best;
          const patch = {};
          Object.assign(patch, window.forceTestConfig?.('cmj')?.profilePatch?.(b) || {});
          patchAthleteMetrics(athleteId, session.date, patch, {
            source: 'force_plate_upload',
            testType: 'cmj',
            sessionId,
            trialIndex: repTrialIndex,
            mode: session.profileSource?.mode || 'manual',
            fileName: session.fileName || session.provenance?.fileName || '',
            savedAt: session.provenance?.savedAt,
          });
        }
      }
      return { persisted: res.persisted && !persistErr, evictedSessionIds: res.evictedSessionIds || [] };
    };

    let report;
    if (forceTraceSaga) report = await forceTraceSaga.commitCmjTrace({ traceRecords: records, sessionId, buildOutcome, persistSession });
    else {
      const persisted = persistSession('none');
      report = { traceStatus: 'none', persisted: !!persisted.persisted, evictedSessionIds: persisted.evictedSessionIds || [] };
    }
    if (!report.persisted && sourceFileRef && forceSourceFileRepo) {
      try { await forceSourceFileRepo.deleteBySession(sessionId); } catch (error) { console.warn('CMJ source-file rollback failed:', error); }
    }
    if (report.persisted && forceSourceFileRepo) {
      for (const evictedSessionId of (report.evictedSessionIds || [])) {
        try { await forceSourceFileRepo.deleteBySession(evictedSessionId); }
        catch (error) { console.warn('Evicted CMJ source-file cleanup failed:', evictedSessionId, error); }
      }
    }
    return { ...report, session: authoritativeSession, sourceFileStatus: sourceFile ? (sourceFileRef ? 'available' : 'missing') : 'none' };
  });

  const deleteCMJSession = (athleteId, sessionId) => withForceLock(async () => {
    // Session-first-then-trace: delete the session AUTHORITATIVELY, and only if that localStorage
    // write actually succeeded do we delete the IndexedDB trace (never orphan the trace of a session
    // that still exists on disk). A trace-cleanup failure is left for the orphan scan. The whole op
    // HOLDS reconcileLock so it can neither race the startup reconcile nor interleave with a restore.
    let deleteErr = null;
    const deleted = forceSessionRepo.delete({ type: 'cmj', athleteId, sessionId }, {
      onPersistError: (err) => { deleteErr = err; },
    });
    if (deleted && !deleteErr) {
      setCmjStore(prev => mirrorDeletedForceSession(prev, athleteId, sessionId));
      setAthletes(prev => prev.map(athlete => {
        if (athlete.id !== athleteId) return athlete;
        const provenance = { ...(athlete.provenance || {}) };
        const seasons = { ...(athlete.seasons || {}) };
        Object.keys(provenance).forEach(date => {
          const byMetric = { ...(provenance[date] || {}) };
          const values = { ...(seasons[date] || {}) };
          let changed = false;
          Object.keys(byMetric).forEach(metricId => {
            if (String(byMetric[metricId]?.sessionId || '') !== String(sessionId)) return;
            delete byMetric[metricId];
            delete values[metricId];
            changed = true;
          });
          if (!changed) return;
          if (Object.keys(byMetric).length) provenance[date] = byMetric; else delete provenance[date];
          if (Object.keys(values).length) seasons[date] = values; else delete seasons[date];
        });
        return D.recomputeLoadForAthlete({ ...athlete, seasons, provenance });
      }));
      if (forceTraceRepo) {
        try { await forceTraceRepo.deleteBySession(sessionId); }
        catch (e) { console.warn('CMJ trace cleanup failed for deleted session (left for orphan scan):', sessionId, e); }
      }
      if (forceSourceFileRepo) {
        try { await forceSourceFileRepo.deleteBySession(sessionId); }
        catch (e) { console.warn('CMJ source-file cleanup failed for deleted session:', sessionId, e); }
      }
      try { forceSessionReviewRepo?.delete({ type: 'cmj', sessionId }); }
      catch (e) { console.warn('CMJ review cleanup failed for deleted session:', sessionId, e); }
      try { window.ForceReportDraftRepo?.save({ athleteId, testType: 'cmj', sessionId }, ''); }
      catch (e) { console.warn('CMJ report note cleanup failed for deleted session:', sessionId, e); }
      return { deleted: true };
    } else {
      console.warn('CMJ session delete did not persist; keeping its trace to avoid orphaning a live session:', sessionId, deleteErr);
      return { deleted: false, error: deleteErr || new Error('session delete did not persist') };
    }
  });

  // SJ/IMTP save/delete run their PERSISTENCE inside withForceLock — the repo write happens in the
  // lock body (not in the React state updater, whose run time the lock does not cover), then a pure
  // mirror updates in-memory state. Same single queue as the CMJ saga and the restore (GPT M3-D P1).
  const deleteSJSession = (athleteId, sessionId) => withForceLock(async () => {
    forceSessionRepo.delete({ type: 'sj', athleteId, sessionId });
    setSjStore(prev => mirrorDeletedForceSession(prev, athleteId, sessionId));
  });

  const deleteIMTPSession = (athleteId, sessionId) => withForceLock(async () => {
    forceSessionRepo.delete({ type: 'imtp', athleteId, sessionId });
    setImtpStore(prev => mirrorDeletedForceSession(prev, athleteId, sessionId));
  });

  const saveSJSession = (athleteId, session, targetTicket = null) => withForceLock(async () => {
    const writeValidation = validateForceWrite(targetTicket, 'sj', athleteId, session);
    if (!writeValidation.ok) return { persisted: false, reason: writeValidation.reason };
    const result = persistForceSession('sj', athleteId, session, 'SJ');
    if (!result.persisted) return { persisted: false };
    const saved = result.session;
    setSjStore(prev => mirrorSavedForceSession(prev, athleteId, saved));
    // Write best-trial JH back to athlete profile
    if (session.date && session.best?.jumpHeight != null) {
      const patch = window.forceTestConfig?.('sj')?.profilePatch?.(session.best) || { sj: session.best.jumpHeight };
      patchAthleteMetrics(athleteId, session.date, patch, {
        source: 'force_plate_upload',
        testType: 'sj',
        sessionId: session.id,
        trialIndex: session.representative?.index,
        mode: session.profileSource?.mode || 'manual',
        fileName: session.fileName || session.provenance?.fileName || '',
        savedAt: session.provenance?.savedAt,
      });
    }
    return { persisted: true, session: saved };
  });

  const saveIMTPSession = (athleteId, session, targetTicket = null) => withForceLock(async () => {
    const writeValidation = validateForceWrite(targetTicket, 'imtp', athleteId, session);
    if (!writeValidation.ok) return { persisted: false, reason: writeValidation.reason };
    const result = persistForceSession('imtp', athleteId, session, 'IMTP');
    if (!result.persisted) return { persisted: false };
    const saved = result.session;
    setImtpStore(prev => mirrorSavedForceSession(prev, athleteId, saved));
    // Write peak force + early-phase force metrics to athlete profile (P1-B, P1-2)
    if (session.date && session.best) {
      const b = session.best;
      const patch = {};
      Object.assign(patch, window.forceTestConfig?.('imtp')?.profilePatch?.(b) || {});
      patchAthleteMetrics(athleteId, session.date, patch, {
        source: 'force_plate_upload',
        testType: 'imtp',
        sessionId: session.id,
        trialIndex: session.representative?.index,
        mode: session.profileSource?.mode || 'manual',
        fileName: session.fileName || session.provenance?.fileName || '',
        savedAt: session.provenance?.savedAt,
      });
    }
    return { persisted: true, session: saved };
  });

  // One-click clear: wipe per-athlete force sessions from both state and persistence. Runs inside
  // withForceLock so a clear issued DURING a restore queues until the replace-all finishes — and a
  // restore issued during a clear waits for it — it can never wipe a half-restored state (GPT M3-D P1).
  const clearForceSessions = (type = null) => withForceLock(async () => {
    const types = type ? [type] : (window.FORCE_TEST_TYPES || ['cmj', 'sj', 'imtp']);
    // SESSION-FIRST-THEN-TRACE (GPT M3-D): clear the sessions as ONE ATOMIC BATCH (a mid-batch
    // failure rolls the whole thing back byte-identical) and drop the IndexedDB traces ONLY on
    // committed. A partial/rolled-back clear must never leave a session claiming an 'available'
    // trace we then permanently delete — on non-commit the traces are kept and the failure surfaced.
    const res = forceSessionRepo.clearTypes(types);
    let cleanupFailed = false;
    if (res.committed && (!type || type === 'cmj') && forceTraceRepo) {
      try { await forceTraceRepo.clearAllProduction(); } catch (e) { cleanupFailed = true; console.warn('clear force traces failed (sessions already cleared; orphans left for the startup reconcile):', e); }
    }
    if (res.committed && forceSourceFileRepo) {
      try {
        if (type) await forceSourceFileRepo.deleteByTestType(type);
        else await forceSourceFileRepo.clearAll();
      } catch (e) {
        cleanupFailed = true;
        console.warn('clear force source files failed (sessions already cleared):', e);
      }
    }
    // Authoritative: reload from persistence so memory MIRRORS disk (committed → empty; rolled back → old data).
    setCmjStore(loadForceStore('cmj')); setSjStore(loadForceStore('sj')); setImtpStore(loadForceStore('imtp'));
    if (res.committed) {
      if (!type || type === 'cmj') setCmjResult(null);
      if (!type || type === 'sj') setSjResult(null);
      if (!type || type === 'imtp') setImtpResult(null);
    }
    if (!res.committed) {
      notifyForcePersistError('Force session clear', new Error(res.rollbackFailed
        ? 'session delete failed AND rollback failed — data may be inconsistent; reload to confirm the current state, and restore from a Force backup if needed'
        : 'session delete failed — all sessions AND their traces were preserved (rolled back)'));
    } else if (cleanupFailed && typeof window !== 'undefined' && window.alert) {
      window.alert('Force sessions cleared, but the raw-curve cleanup did not complete — the orphaned curves will be removed automatically on the next startup.');
    }
    return { committed: res.committed, rolledBack: res.rolledBack, rollbackFailed: res.rollbackFailed, cleanupFailed };
  });

  // Research JSON import (whole-store replace) also goes through the queue — it rewrites the same
  // CMJ/SJ/IMTP localStorage the restore owns, so it must be mutually exclusive with a restore.
  // It carries SESSIONS ONLY (never traces): any 'available' traceRef in the imported data is
  // therefore unbacked, so every ref is downgraded to 'missing' AND the stale IndexedDB traces are
  // dropped — never leaving a dangling-available ref or an orphan trace (GPT M3-D). Use the
  // versioned M3-D backup to restore actual force curves.
  const importForceSessions = (stores = {}) => withForceLock(async () => {
    const downgradeRefs = (session) => {
      if (!session || typeof session !== 'object') return session;
      const next = { ...session };
      if (Array.isArray(session.traceRefs)) next.traceRefs = session.traceRefs.map(r => (r && r.status === 'available') ? { ...r, status: 'missing' } : r);
      if (session.traceRef && session.traceRef.status === 'available') next.traceRef = { ...session.traceRef, status: 'missing' };
      return next;
    };
    const downgradeStore = (store) => Object.fromEntries(Object.entries(store || {}).map(([aid, sessions]) => [aid, (Array.isArray(sessions) ? sessions : []).map(downgradeRefs)]));
    const nextStores = {
      cmj: downgradeStore(stores.cmj),
      sj: downgradeStore(stores.sj),
      imtp: downgradeStore(stores.imtp),
    };
    // AUTHORITATIVE replace as ONE ATOMIC BATCH across CMJ/SJ/IMTP (never per-type commit): any
    // write/delete failure rolls the whole batch back byte-identical, so a failed import keeps BOTH
    // the old sessions AND their traces. Drop the stale traces ONLY on committed (GPT M3-D).
    const res = forceSessionRepo.replaceStores(nextStores);
    let cleanupFailed = false;
    if (res.committed && forceTraceRepo) {
      try { await forceTraceRepo.clearAllProduction(); } catch (e) { cleanupFailed = true; console.warn('clear force traces on legacy import failed (left for orphan scan):', e); }
    }
    if (res.committed && forceSourceFileRepo) {
      try { await forceSourceFileRepo.clearAll(); } catch (e) { cleanupFailed = true; console.warn('clear force source files on legacy import failed:', e); }
    }
    // Authoritative reload: on commit memory = imported data; otherwise memory mirrors the old data
    // still on disk (rolled back; traces untouched).
    setCmjStore(loadForceStore('cmj')); setSjStore(loadForceStore('sj')); setImtpStore(loadForceStore('imtp'));
    if (!res.committed && typeof window !== 'undefined' && window.alert) {
      window.alert(res.rollbackFailed
        ? 'Force session import failed AND rollback failed — data may be inconsistent; reload to confirm the current state, and restore from a Force backup if needed.'
        : 'Force session import failed — the previous sessions and their raw curves were preserved (rolled back).');
    }
    return { committed: res.committed, rolledBack: res.rolledBack, rollbackFailed: res.rollbackFailed, cleanupFailed };
  });

  // Estimate total force-session storage footprint (for the Settings panel readout)
  const forceStorageUsageBytes = useM(() => {
    return forceSessionRepo.estimateUsage();
  }, [cmjStore, sjStore, imtpStore]);

  // Track which athlete the CMJ standalone page is "for" (null = generic)
  const [cmjAthleteId, setCmjAthleteId] = useS(null);
  // Track which athlete the SJ standalone page is "for" (null = generic)
  const [sjAthleteId, setSjAthleteId] = useS(null);
  // Track which athlete the IMTP standalone page is "for" (null = generic)
  const [imtpAthleteId, setImtpAthleteId] = useS(null);

  // Persist the last CMJ analysis result across navigation
  // (allows returning to CMJPanel without re-uploading)
  const [cmjResult, setCmjResult] = useS(null);

  // Persist SJ and IMTP results across navigation (same pattern as CMJ)
  const [sjResult,   setSjResult]   = useS(null);
  const [imtpResult, setImtpResult] = useS(null);

  const forceTransitionAdapter = useM(() => forceTargetAdapter.createTransitionAdapter({
    transitionContext: (intent) => transitionContext({
      view: intent.view,
      selectedId: intent.athleteId,
    }),
    commitForce: (intent) => {
      setCmjResult(null);
      setSjResult(null);
      setImtpResult(null);
      setForceFreshSession(null);
      setReviewSessionId(null);
      if (intent.testType === 'cmj') setCmjAthleteId(intent.athleteId);
      else if (intent.testType === 'sj') setSjAthleteId(intent.athleteId);
      else if (intent.testType === 'imtp') setImtpAthleteId(intent.athleteId);
      if (Object.prototype.hasOwnProperty.call(intent, 'sessionId')) setReviewSessionId(intent.sessionId);
      if (intent.captureMode) setForceCaptureMode(intent.captureMode);
    },
  }), [forceTargetAdapter, transitionContext]);
  const transitionForceContext = React.useCallback((intent) => forceTransitionAdapter.transition(intent), [forceTransitionAdapter]);

  // Unsaved detector results belong to the athlete context in which they were produced.
  // Changing that target invalidates the transient result; a stale result must never follow the
  // save banner to a different athlete. Stored sessions remain available in 分析.
  useE(() => { setCmjResult(null); }, [cmjAthleteId]);
  useE(() => { setSjResult(null); }, [sjAthleteId]);
  useE(() => { setImtpResult(null); }, [imtpAthleteId]);

  // ── Persist through the app persistence boundary on every change ─────────
  useE(() => appStore.set('_groups',     groups),     [groups]);
  useE(() => appStore.set('_seasons',    seasons),    [seasons]);
  useE(() => appStore.set('_season',     season),     [season]);
  useE(() => appStore.set('_algo',       algo),       [algo]);
  useE(() => appStore.set('_riskConfig', riskConfig), [riskConfig]);
  useE(() => appStore.set('_trendConfig',trendConfig),[trendConfig]);
  // FORCE-WS-1a (2026-07-10): remember the last force view id (工作区 类型 + 模式)
  // so the 测力台 rail entry re-opens where the user left off.
  useE(() => { if (routes.isForceView(view)) appStore.set('_forceView', view); }, [view]);
  // FORCE-WS-3a (2026-07-12): remember the last 采集/分析 sub-mode (mirrors _forceView).
  useE(() => { appStore.set('_forceCaptureMode', forceCaptureMode); }, [forceCaptureMode]);

  // ── Orphan cleanup: when an athlete is deleted, prune any state that still
  // references that athlete's id so the UI doesn't break trying to display a
  // ghost selection (selectedId / compareIds / cmjAthleteId).
  useE(() => {
    const validIds = new Set(athletes.map(a => a.id));
    if (!validIds.has(selectedId) && athletes.length > 0) {
      setSelectedId(athletes[0].id);
    }
    if (compareIds.some(id => !validIds.has(id))) {
      const cleaned = compareIds.filter(id => validIds.has(id));
      // Pad back to at least 2 entries using any remaining athletes
      const padded = [...cleaned];
      for (const a of athletes) {
        if (padded.length >= 2) break;
        if (!padded.includes(a.id)) padded.push(a.id);
      }
      setCompareIds(padded);
    }
    if (cmjAthleteId != null && !validIds.has(cmjAthleteId)) {
      setCmjAthleteId(null);
    }
    if (sjAthleteId != null && !validIds.has(sjAthleteId)) {
      setSjAthleteId(null);
    }
    if (imtpAthleteId != null && !validIds.has(imtpAthleteId)) {
      setImtpAthleteId(null);
    }
  }, [athletes]);

  // Reset-to-defaults wipes ALL app data incl. the force stores — serialize it with restores so it
  // can't clear a half-restored state (and a restore waits for an in-flight reset) — GPT M3-D P1.
  const resetToDefaults = () => withForceLock(async () => {
    // SESSION-FIRST-THEN-TRACE (GPT M3-D): clear the force sessions as ONE ATOMIC BATCH FIRST. If it
    // does not commit (any type/athlete failed → rolled back byte-identical, or rollback failed),
    // keep everything (sessions + traces + state) and abort the reset — never a partial CMJ-cleared /
    // SJ-failed state that then deletes the raw curves.
    const forceClear = forceSessionRepo.clearTypes(['cmj', 'sj', 'imtp']);
    if (!forceClear.committed) {
      setCmjStore(loadForceStore('cmj')); setSjStore(loadForceStore('sj')); setImtpStore(loadForceStore('imtp'));
      notifyForcePersistError('Reset to defaults', new Error(forceClear.rollbackFailed
        ? 'force session delete AND rollback failed — reset aborted, data may be inconsistent; reload to confirm the current state, and restore from a Force backup if needed'
        : 'force session delete failed — reset aborted, all data preserved (rolled back)'));
      return { reset: false, committed: false, rolledBack: forceClear.rolledBack, rollbackFailed: forceClear.rollbackFailed };
    }
    let cleanupFailed = false;
    if (forceTraceRepo) {
      try { await forceTraceRepo.clearAllProduction(); } catch (e) { cleanupFailed = true; console.warn('clear force traces on reset failed (sessions already cleared; left for orphan scan):', e); }
    }
    if (forceSourceFileRepo) {
      try { await forceSourceFileRepo.clearAll(); } catch (e) { cleanupFailed = true; console.warn('clear force source files on reset failed:', e); }
    }
    appStore.clearAppData();   // wipe the rest of the app data (force keys already cleared above)
    setGroups(D.METRIC_GROUPS);
    setAthletesViaRepository(D.ATHLETES);
    setSeasons(D.SEASONS);
    setAlgo('range');
    setRiskConfig(RISK_CONFIG_DEFAULT);
    setTrendConfig({ showDataLabels: false, showBenchmarkLine: false });
    setTilesByAthlete({});
    setCmjStore({});
    setSjStore({});
    setImtpStore({});
    setCmjResult(null);
    setSjResult(null);
    setImtpResult(null);
    if (cleanupFailed && typeof window !== 'undefined' && window.alert) window.alert('Reset complete, but the force raw-curve cleanup did not finish — the orphaned curves will be removed on the next startup.');
    return { reset: true, committed: true, cleanupFailed };
  });

  // UX-01 / DELETE-01: hard-delete one athlete and all associated data (cascade).
  // Runs the core cascade (localStorage + IndexedDB), then removes the athlete from the
  // in-memory roster. Removing the athlete triggers the orphan-cleanup effect above, which
  // switches the selection to the first remaining athlete (or empty state). Returns the
  // cascade's partial-failure report so the caller can surface which steps did not complete.
  const countAthleteData = (athleteId) =>
    (athleteCascadeDelete
      ? athleteCascadeDelete.countAthleteData(athleteId)
      : Promise.resolve(null));

  const deleteAthlete = (athleteId) => withForceLock(async () => {
    // The cascade hard-deletes this athlete's force sessions (localStorage) AND traces (IndexedDB);
    // run it inside the queue so it is mutually exclusive with a restore (GPT M3-D P1).
    if (!athleteId) return { ok: false, completed: [], failed: [{ step: 'input', error: 'missing athleteId' }] };
    let report = { athleteId, ok: true, completed: [], failed: [] };
    if (athleteCascadeDelete) {
      report = await athleteCascadeDelete.deleteAthleteCascade(athleteId);
    } else {
      athleteRepo && athleteRepo.delete(athleteId);
    }
    // Prune force-session in-memory stores for the removed athlete.
    const dropAthlete = (store) => {
      if (!store || !(athleteId in store)) return store;
      const next = { ...store };
      delete next[athleteId];
      return next;
    };
    setCmjStore(dropAthlete);
    setSjStore(dropAthlete);
    setImtpStore(dropAthlete);
    setTilesByAthlete(dropAthlete);
    // Remove from roster (repository-backed). Orphan-cleanup effect handles selection.
    setAthletesViaRepository(prev => prev.filter(a => a.id !== athleteId));
    return report;
  });

  // recompute squad stats whenever data changes
  const squadStatsAll = useM(() => D.buildSquadStats(athletes, groups, seasons), [athletes, groups, seasons]);

  // SWC is single-athlete only — if user lands on Squad Dashboard with SWC
  // selected, fall back to Range mapping.
  useE(() => {
    if (view === 'team' && algo === 'swc') setAlgo('range');
  }, [view, algo]);

  // accent override
  useE(() => {
    // COLD-1: 冷色迁移后默认accent为Mac蓝；存量偏好里的旧默认(#3b82f6)视为未设置。
    const acc = (!t.accent || t.accent === '#3b82f6') ? '#0a84ff' : t.accent;
    document.documentElement.style.setProperty('--accent', acc);
    document.documentElement.style.setProperty('--accent-2', shiftLightness(acc, 12));
    document.documentElement.style.setProperty('--accent-soft', hexToRgba(acc, 0.12));
  }, [t.accent]);

  // vibe + motion → data-attrs on <html>
  useE(() => {
    document.documentElement.setAttribute('data-vibe', t.vibe || 'mid');
  }, [t.vibe]);
  useE(() => {
    document.documentElement.setAttribute('data-motion', t.motion === 'off' ? 'off' : 'on');
  }, [t.motion]);

  // keep trend metrics valid even after metric edits: drop any seasons-origin id
  // whose metric no longer exists (force ids resolve via the catalog, untouched).
  // Never drop below 1 metric — fall back to the first group's first metric.
  useE(() => {
    const seasonIds = new Set(groups.flatMap(g => g.metrics.map(m => m.id)));
    const isForce = (id) => String(id).indexOf('force-') === 0;
    const pruned = trendMetricIds.filter(id => isForce(id) || seasonIds.has(id));
    if (pruned.length === trendMetricIds.length) return;
    if (pruned.length) setTrendMetricIds(pruned);
    else if (groups.length && groups[0].metrics.length) setTrendMetricIds([String(groups[0].metrics[0].id)]);
  }, [groups]);

  // ensure selected athlete exists
  useE(() => {
    if (!athletes.some(a => a.id === selectedId) && athletes.length) {
      setSelectedId(athletes[0].id);
    }
  }, [athletes]);

  // NAV-2: keep the persisted season valid — if it falls out of the seasons list
  // (e.g. a season was deleted), fall back to the first so views don't read a ghost.
  useE(() => {
    if (!seasons.includes(season) && seasons.length) setSeason(seasons[0]);
  }, [seasons]);

  // ensure selected is in compare list when toggled on
  useE(() => {
    if (compareMode && !compareIds.includes(selectedId)) {
      setCompareIds([selectedId, ...compareIds].slice(0, 4));
    }
  }, [compareMode, selectedId]);

  const athlete = athletes.find(a => a.id === selectedId) || athletes[0] || null;
  const compareAthletes = compareMode ? athletes.filter(a => compareIds.includes(a.id)) : (athlete ? [athlete] : []);

  const values = athlete.seasons[season] || {};
  const squadStats = squadStatsAll[season] || {};

  const scoresFor = (a) => {
    const v = a.seasons[season] || {};
    const out = {};
    groups.forEach(g => { out[g.id] = D.computeGroupScore(v, g, squadStats, algo); });
    return out;
  };

  const scores = useM(() => scoresFor(athlete), [athlete, season, algo, squadStats, groups]);
  const overallScore = useM(() => D.computeOverallScore(values, groups, squadStats, algo), [values, algo, squadStats, groups]);

  // Previous season group scores for Radar ghost overlay
  const prevScores = useM(() => {
    const sorted = [...seasons].sort((a, b) => +new Date(a) - +new Date(b));
    const idx = sorted.indexOf(season);
    if (idx <= 0 || compareMode) return null;
    const prev = sorted[idx - 1];
    const prevVals = athlete.seasons[prev] || {};
    const prevSS = squadStatsAll[prev] || {};
    const out = {};
    groups.forEach(g => { out[g.id] = D.computeGroupScore(prevVals, g, prevSS, algo); });
    return out;
  }, [athlete, season, seasons, compareMode, squadStatsAll, algo, groups]);

  // Recent metric history for the selected athlete (last 3 sessions, newest first)
  const recentHistory = useM(() => {
    const sorted = [...seasons].sort((a, b) => new Date(b) - new Date(a)).slice(0, 3);
    const out = {};
    groups.forEach(g => g.metrics.forEach(m => {
      out[m.id] = sorted.map(s => (athlete.seasons[s] || {})[m.id]).filter(v => v != null);
    }));
    return out;
  }, [athlete, seasons, groups]);

  // overall scores per athlete (for leaderboard, position avg)
  const overallByAthlete = useM(() => {
    const out = {};
    athletes.forEach(a => {
      out[a.id] = D.computeOverallScore(a.seasons[season] || {}, groups, squadStats, algo);
    });
    return out;
  }, [athletes, season, algo, squadStats, groups]);

  const posAvgScore = useM(() => {
    const same = athletes.filter(a => a.position === athlete.position);
    const sum = same.reduce((s, a) => s + overallByAthlete[a.id], 0);
    return Math.round(sum / same.length);
  }, [athlete.position, overallByAthlete]);

  // P3-B: four-level traffic light risk for selected athlete
  const athleteRisk = useM(
    () => D.computeRiskLevel(athlete, cmjStore[athlete.id] || [], riskConfig),
    [athlete, cmjStore, riskConfig]
  );

  // P3-4: sidebar risk dots — full squad riskMap (null when risk disabled)
  const sidebarRiskMap = useM(() => {
    if (!D || !riskConfig?.enabled) return {};
    const out = {};
    athletes.forEach(a => { out[a.id] = D.computeRiskLevel(a, cmjStore[a.id] || [], riskConfig); });
    return out;
  }, [athletes, cmjStore, riskConfig]);

  const squadAvgScores = useM(() => {
    const out = {};
    groups.forEach(g => {
      const s = athletes.reduce((sum, a) => sum + D.computeGroupScore(a.seasons[season] || {}, g, squadStats, algo), 0);
      out[g.id] = Math.round(s / athletes.length);
    });
    return out;
  }, [season, algo, squadStats, groups, athletes]);

  // MB-3: catalog-driven trend series.
  // • Individual mode: overlay up to 2 METRICS of the same athlete on dual Y-axes
  //   (metric 1 → left/accent, metric 2 → right/purple), each series' values from
  //   the catalog getSeries (raw values, NO normalization). Band/avg shown only
  //   when there is exactly 1 seasons metric (force metrics have no cohort).
  // • Compare mode: unchanged shape — N athlete series on trendMetricIds[0] (a
  //   single metric), shared axis, band as today if that metric is seasons.
  const trendData = useM(() => {
    const Catalog = window.AthleteMetricCatalog;
    const sortedDates = [...seasons].sort((a, b) => +new Date(a) - +new Date(b));
    const catalog = Catalog ? Catalog.listCatalog({ athlete }) : [];
    const metaOf = (id) => catalog.find(m => m.id === id)
      || { id, label: id, unit: '', dir: 'higher', origin: 'seasons' };
    const inputs = { athlete, cmjStore, sjStore, imtpStore };
    // Drop points whose date can't be parsed onto a time axis. Malformed
    // athlete.seasons keys (e.g. "2025/7/5 9") make Math.min/max(dates) return
    // NaN (every point collapses to x=0, data piles at the left) and crash
    // week/month aggregation at new Date(centroid).toISOString(). MB-3's raw
    // getSeries can surface such keys; the pre-MB-3 curated-date path never did.
    const plottable = (pts) => pts.filter(v => !isNaN(+new Date(v.x)));

    if (compareMode) {
      // Compare = athlete overlay on the FIRST selected metric only (single axis).
      const id = trendMetricIds[0];
      const meta = metaOf(id);
      const series = compareAthletes.map((a, i) => ({
        label: a.name,
        color: COMPARE_COLORS[i % COMPARE_COLORS.length],
        unit: meta.unit, dir: meta.dir, axis: 'left',
        values: plottable((Catalog ? Catalog.getSeries({ ...inputs, athlete: a }, id) : [])
          .map(p => ({ x: p.date, y: p.value }))),
      }));
      const bandOn = meta.origin === 'seasons';
      const squadAvg = bandOn ? sortedDates
        .map(s => ({ x: s, y: (squadStatsAll[s] && squadStatsAll[s][id]?.avg) }))
        .filter(v => v.y != null) : [];
      const squadRange = bandOn ? sortedDates
        .map(s => {
          const r = squadStatsAll[s] && squadStatsAll[s][id];
          return r ? { x: s, best: r.best, worst: r.worst } : null;
        }).filter(Boolean) : [];
      return { series, squadAvg, squadRange, metrics: [meta], dual: false };
    }

    // Individual: up to 2 metrics of THIS athlete on independent axes.
    const metrics = trendMetricIds.map(metaOf);
    const series = trendMetricIds.map((id, i) => {
      const meta = metrics[i];
      const rawPoints = Catalog
        ? (typeof Catalog.getSeriesDetails === 'function'
          ? Catalog.getSeriesDetails(inputs, id)
          : Catalog.getSeries(inputs, id))
        : [];
      return {
        id, label: meta.label, unit: meta.unit, dir: meta.dir, origin: meta.origin,
        axis: i === 0 ? 'left' : 'right',
        color: i === 0 ? 'var(--accent)' : '#5e5ce6',
        values: plottable(rawPoints.map(p => ({
          x: p.date, y: p.value,
          sourceType: p.sourceType || 'historical',
          sourceLabel: p.sourceLabel || 'Historical record',
          sourceState: p.state || 'historical',
          sourceDetail: p.detail || {},
        }))),
      };
    });
    const dual = series.length === 2;
    // Band/avg only for a single seasons metric (no cohort for force / 2-metric).
    const bandOn = !dual && metrics[0] && metrics[0].origin === 'seasons';
    const id0 = trendMetricIds[0];
    const squadAvg = bandOn ? sortedDates
      .map(s => ({ x: s, y: (squadStatsAll[s] && squadStatsAll[s][id0]?.avg) }))
      .filter(v => v.y != null) : [];
    const squadRange = bandOn ? sortedDates
      .map(s => {
        const r = squadStatsAll[s] && squadStatsAll[s][id0];
        return r ? { x: s, best: r.best, worst: r.worst } : null;
      }).filter(Boolean) : [];
    return { series, squadAvg, squadRange, metrics, dual };
  }, [trendMetricIds, athlete, compareAthletes, compareMode, season, squadStatsAll, seasons, cmjStore, sjStore, imtpStore]);

  const toggleCompare = (id) => {
    setCompareIds(ids => {
      if (ids.includes(id)) return ids.filter(x => x !== id);
      if (ids.length >= 4) return [...ids.slice(1), id];
      return [...ids, id];
    });
  };

  const openSettings = (tab) => { setSettingsTab(tab); setSettingsOpen(true); };

  // INTERACTIVE LAYER ─────────────────────────────────────────────────
  // Command palette / shortcut help / first-paint boot
  const [cmdOpen, setCmdOpen] = useS(false);
  const [shortHelpOpen, setShortHelpOpen] = useS(false);
  const [addAthleteOpen, setAddAthleteOpen] = useS(false);
  const [booting, setBooting] = useS(true);
  useE(() => { const t = setTimeout(() => setBooting(false), 350); return () => clearTimeout(t); }, []);

  // Hash routing: keep URL in sync with view + selected athlete
  const getCurrentContext = React.useCallback(() => ({
    view: viewRef.current,
    selectedId: selectedIdRef.current,
    season: seasonRef.current,
  }), []);
  const getContextHistoryIndex = React.useCallback(() => contextHistoryIndexRef.current, []);
  const consumeContextHistorySuppression = React.useCallback((targetIndex) => {
    if (contextHistorySuppressionRef.current !== targetIndex) return false;
    contextHistorySuppressionRef.current = null;
    endHistorySettling();
    return true;
  }, [endHistorySettling]);
  useHashRoute({
    athletes,
    routeTable: routes,
    transitionContext,
    getCurrentContext,
    historyIndexKey: contextHistoryIndexKey,
    getHistoryIndex: getContextHistoryIndex,
    consumeHistorySuppression: consumeContextHistorySuppression,
    getHistorySettling,
    beginHistorySettling,
    endHistorySettling,
  });
  // The roster drawer is transient workspace chrome, not route state. Close it
  // for every route change (including browser history and deep links) so an
  // overlay opened in one workspace can never cover the next workspace.
  useE(() => { setSidebarOpen(false); }, [view]);

  // Roster/top-search selection changes the athlete context without changing
  // the active workspace. Explicit "open athlete" actions remain responsible
  // for navigating to the individual page.
  const selectAthleteInPlace = React.useCallback((id, { closeRoster = true } = {}) => {
    if (!id || !athletes.some(a => a.id === id)) return;
    if (!setSelectedId(id)) return;
    setCmjAthleteId(id);
    setSjAthleteId(id);
    setImtpAthleteId(id);
    if (view === 'cmj-session') {
      setCmjSessionId((cmjStore[id] || [])[0]?.id || null);
    }
    if (closeRoster) setSidebarOpen(false);
  }, [athletes, cmjStore, view]);

  // Athlete context navigation helpers (j/k)
  const filteredAthleteIds = useM(() => athletes.map(a => a.id), [athletes]);
  const jumpAthlete = React.useCallback((dir) => {
    const i = filteredAthleteIds.indexOf(selectedId);
    if (i < 0) return;
    const next = (i + dir + filteredAthleteIds.length) % filteredAthleteIds.length;
    selectAthleteInPlace(filteredAthleteIds[next], { closeRoster: false });
  }, [filteredAthleteIds, selectedId, selectAthleteInPlace]);

  // Vibe cycler (subtle → mid → bold → subtle …)
  const cycleVibe = () => {
    const order = ['subtle', 'mid', 'bold'];
    const cur = t.vibe || 'mid';
    const next = order[(order.indexOf(cur) + 1) % order.length];
    setTweak('vibe', next);
  };

  // Wire keyboard handlers
  useGlobalShortcuts({
    openCommandPalette: () => setCmdOpen(true),
    focusSearch: () => {
      const inp = document.querySelector('aside input[placeholder*="Search"]');
      if (inp) { inp.focus(); inp.select && inp.select(); }
    },
    nextAthlete: () => jumpAthlete(+1),
    prevAthlete: () => jumpAthlete(-1),
    setView: (v) => setView(v),
    toggleCompare: () => setCompareMode(m => !m),
    openEntry:    () => setEntryOpen(true),
    openSettings: () => openSettings('metrics'),
    openExport:   () => setExportOpen(true),
    cycleVibe,
    openShortcuts: () => setShortHelpOpen(true),
    closeAllOverlays: () => {
      setCmdOpen(false); setShortHelpOpen(false);
      setEntryOpen(false); setSettingsOpen(false);
      setExportOpen(false);
      if (reportOpen) closeIndividualReportModal();
    },
  });

  const isHoriz = t.layout === 'horizontal';

  // athletes with overall injected for sidebar/leaderboard
  const athletesWithOverall = athletes.map(a => ({ ...a, overall: overallByAthlete[a.id] }));
  const storyEvidenceByAthlete = useM(() => {
    if (!forceSessionReviewRepo || !reviewedEvidenceSummary?.createReviewedEvidenceSummaries || !storyEvidenceSummary?.latestStoryEvidenceSummary) {
      return {};
    }
    const reviewed = reviewedEvidenceSummary.createReviewedEvidenceSummaries({
      reviews: forceSessionReviewRepo.list({ type: 'cmj' }).filter(review => review.workflowState === 'confirmed' && review.sourceSnapshot?.signature),
      sessionsByAthlete: cmjStore,
      athletes: athletesWithOverall,
      testType: 'cmj',
    });
    return Object.fromEntries(athletesWithOverall.map(a => [
      a.id,
      storyEvidenceSummary.latestStoryEvidenceSummary({
        reviewedEvidence: reviewed,
        athleteId: a.id,
        type: 'cmj',
      }),
    ]));
  }, [forceSessionReviewRepo, reviewedEvidenceSummary, storyEvidenceSummary, cmjStore, athletesWithOverall, reviewedEvidenceVersion]);
  const individualReportEvidence = useM(() => {
    if (!forceSessionReviewRepo || !reviewedEvidenceSummary?.createReviewedEvidenceSummaries || !individualReportEvidenceSummary?.latestIndividualReportEvidenceSummary || !athlete) {
      return null;
    }
    const reviewed = reviewedEvidenceSummary.createReviewedEvidenceSummaries({
      reviews: forceSessionReviewRepo.list({ type: 'cmj' }).filter(review => review.workflowState === 'confirmed' && review.sourceSnapshot?.signature),
      sessionsByAthlete: cmjStore,
      athletes: athletesWithOverall,
      testType: 'cmj',
    });
    return individualReportEvidenceSummary.latestIndividualReportEvidenceSummary({
      reviewedEvidence: reviewed,
      sessionsByAthlete: cmjStore,
      athletes: athletesWithOverall,
      athleteId: athlete.id,
      type: 'cmj',
    });
  }, [forceSessionReviewRepo, reviewedEvidenceSummary, individualReportEvidenceSummary, cmjStore, athletesWithOverall, athlete?.id, reviewedEvidenceVersion]);
  // RP-R1': training report data for the report page/modal. trainingLogs come from
  // FieldDataStore (async); the report body renders synchronously, so preload the
  // athlete's last-7-day logs when the report view is active, then run the pure
  // ReportModuleRegistry buildData for each of the three training modules.
  const [reportTrainingLogs, setReportTrainingLogs] = useS([]);
  const [reportTrainingRange, setReportTrainingRange] = useS({ fromDate: null, toDate: null });
  useE(() => {
    let alive = true;
    const FS = window.FieldDataStore;
    const active = view === 'report' || reportOpen;
    if (!active || !FS || !athlete?.id || typeof FS.listTrainingLogsByAthlete !== 'function') {
      setReportTrainingLogs([]);
      setReportTrainingRange({ fromDate: null, toDate: null });
      return () => { alive = false; };
    }
    const end = new Date();
    const start = new Date();
    start.setDate(start.getDate() - 6);
    const iso = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
    const fromDate = iso(start), toDate = iso(end);
    FS.listTrainingLogsByAthlete(athlete.id, fromDate, toDate).then(logs => {
      if (!alive) return;
      setReportTrainingLogs(Array.isArray(logs) ? logs : []);
      setReportTrainingRange({ fromDate, toDate });
    }).catch(() => {
      if (alive) { setReportTrainingLogs([]); setReportTrainingRange({ fromDate, toDate }); }
    });
    return () => { alive = false; };
  }, [view, reportOpen, athlete?.id]);
  const trainingReportData = useM(() => {
    const registry = window.ReportModuleRegistry;
    if (!registry || !athlete?.id) return {};
    const input = {
      athleteId: athlete.id,
      athletes: athletesWithOverall,
      dateRange: reportTrainingRange,
      trainingLogs: reportTrainingLogs,
    };
    return {
      trainingWeek: registry.buildReportModuleData('training-week', input),
      trainingPeriod: registry.buildReportModuleData('training-period', input),
      trainingVolumeChart: registry.buildReportModuleData('training-volume-chart', input),
    };
  }, [athlete?.id, athletesWithOverall, reportTrainingRange, reportTrainingLogs]);
  const openStoryEvidence = (summary) => {
    if (!summary?.sessionExists) return;
    setCmjReportAthleteId(summary.athleteId);
    setCmjReportSessionId(summary.sessionId);
    setCmjReportOpen(true);
  };
  const openTrainingWithStoryEvidence = (summary) => {
    const seed = trainingContextSeed?.createTrainingContextSeed
      ? trainingContextSeed.createTrainingContextSeed({ storyEvidence: summary, athlete })
      : null;
    setTrainingContext(seed);
    setView('training');
  };
  const [teamFieldReviewedEvidenceSources, setTeamFieldReviewedEvidenceSources] = useS({ plans: [], records: [], testDefs: [] });
  useE(() => {
    let alive = true;
    const store = window.FieldDataStore;
    if (!store || !assessmentReviewRepo || !fieldTestReviewedEvidenceSummary?.createFieldTestReviewedEvidenceSummaryList || !reviewedEvidenceRows?.loadFieldReviewedEvidenceSources) {
      setTeamFieldReviewedEvidenceSources({ plans: [], records: [], testDefs: [] });
      return () => { alive = false; };
    }
    reviewedEvidenceRows.loadFieldReviewedEvidenceSources({ fieldDataStore: store }).then(next => {
      if (alive) setTeamFieldReviewedEvidenceSources(next);
    }).catch(() => {
      if (alive) setTeamFieldReviewedEvidenceSources({ plans: [], records: [], testDefs: [] });
    });
    return () => { alive = false; };
  }, [assessmentReviewRepo, fieldTestReviewedEvidenceSummary, reviewedEvidenceRows, reviewedEvidenceVersion]);
  // REV-R5 go-to read-back: programs supply session.linkedEvidenceRefs so the Review
  // Database can count how many training sessions consume each reviewed row.
  const [reviewDbPrograms, setReviewDbPrograms] = useS([]);
  useE(() => {
    let alive = true;
    const store = window.FieldDataStore;
    if (!store || typeof store.listPrograms !== 'function') {
      setReviewDbPrograms([]);
      return () => { alive = false; };
    }
    store.listPrograms().then(next => {
      if (alive) setReviewDbPrograms(Array.isArray(next) ? next : []);
    }).catch(() => {
      if (alive) setReviewDbPrograms([]);
    });
    return () => { alive = false; };
  }, [view, reviewedEvidenceVersion]);
  const linkReviewDatabaseToTraining = (row) => {
    if (!row) return;
    setTrainingContext(null);
    setView('training');
  };
  const reviewedEvidenceViewModel = useM(() => {
    if (!reviewedEvidenceRows?.createReviewedEvidenceViewModel) return {
      cmjReviewedEvidence: [], sjReviewedEvidence: [], imtpReviewedEvidence: [], fieldReviewedEvidence: [], weeklyRows: [],
    };
    return reviewedEvidenceRows.createReviewedEvidenceViewModel({
      forceSessionReviewRepo,
      assessmentReviewRepo,
      manualMetricReviewRepo,
      reviewedEvidenceSummary,
      sjReviewedEvidenceSummary,
      imtpReviewedEvidenceSummary,
      fieldTestReviewedEvidenceSummary,
      coachEvidenceSummary,
      evidenceUIContract,
      weeklyCoachReviewedEvidenceSummary,
      cmjStore,
      sjStore,
      imtpStore,
      fieldReviewedEvidenceSources: teamFieldReviewedEvidenceSources,
      athletes: athletesWithOverall,
      weeklyLimit: 10,
      requireAudited: true,
    });
  }, [
    reviewedEvidenceRows,
    weeklyCoachReviewedEvidenceSummary,
    evidenceUIContract,
    forceSessionReviewRepo,
    reviewedEvidenceSummary,
    coachEvidenceSummary,
    sjReviewedEvidenceSummary,
    imtpReviewedEvidenceSummary,
    assessmentReviewRepo,
    manualMetricReviewRepo,
    fieldTestReviewedEvidenceSummary,
    cmjStore,
    sjStore,
    imtpStore,
    athletesWithOverall,
    teamFieldReviewedEvidenceSources,
    reviewedEvidenceVersion,
  ]);
  const teamReviewedEvidenceRows = reviewedEvidenceViewModel.weeklyRows;
  const activeReportEvidenceSelection = reportEvidenceSelection?.athleteId === athlete?.id
    ? reportEvidenceSelection
    : appStore.get('_individualReportEvidenceSelections', {})[athlete?.id] || null;
  const r1ReportEvidence = useM(() => {
    if (!individualReportEvidenceSummary?.buildIndividualReportEvidenceSummary || !athlete) {
      return { cmj: null, sj: null, imtp: null, field: null, manual: null };
    }
    const build = (type, reviewedEvidence) => individualReportEvidenceSummary.buildIndividualReportEvidenceSummary({
      type,
      reviewedEvidence,
      athleteId: athlete.id,
      athletes: athletesWithOverall,
      sessionsByAthlete: cmjStore,
      evidenceRef: activeReportEvidenceSelection,
      allowSessionFallback: false,
    });
    const manualReviews = manualMetricReviewRepo?.list({ status: 'reviewed' })
      .filter(review => review.workflowState === 'confirmed' && review.sourceSnapshot?.signature && review.athleteId === athlete.id) || [];
    return {
      cmj: build('cmj', reviewedEvidenceViewModel.cmjReviewedEvidence),
      sj: build('sj', reviewedEvidenceViewModel.sjReviewedEvidence),
      imtp: build('imtp', reviewedEvidenceViewModel.imtpReviewedEvidence),
      field: build('field', reviewedEvidenceViewModel.fieldReviewedEvidence),
      manual: build('manual', manualReviews),
    };
  }, [individualReportEvidenceSummary, athlete?.id, athletesWithOverall, cmjStore, manualMetricReviewRepo, activeReportEvidenceSelection, reviewedEvidenceViewModel, reviewedEvidenceVersion]);

  // REPORT-BATCH-1: roster-wide read models for template-locked batch print. This
  // is read-only preparation: it never switches selectedId and never touches an
  // IndividualReportDraftRepository for any athlete.
  const reportBatchRange = useM(() => {
    const end = new Date();
    const start = new Date();
    start.setDate(start.getDate() - 6);
    const iso = date => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
    return { fromDate: iso(start), toDate: iso(end) };
  }, []);
  const [reportBatchLogsByAthlete, setReportBatchLogsByAthlete] = useS({});
  useE(() => {
    let alive = true;
    const store = window.FieldDataStore;
    if (view !== 'report' || !store || typeof store.listTrainingLogsByAthlete !== 'function') {
      return () => { alive = false; };
    }
    Promise.all(athletesWithOverall.map(async item => {
      try {
        const logs = await store.listTrainingLogsByAthlete(item.id, reportBatchRange.fromDate, reportBatchRange.toDate);
        return [item.id, Array.isArray(logs) ? logs : []];
      } catch {
        return [item.id, []];
      }
    })).then(entries => {
      if (alive) setReportBatchLogsByAthlete(Object.fromEntries(entries));
    });
    return () => { alive = false; };
  }, [view, athletesWithOverall, reportBatchRange]);
  const batchReportContexts = useM(() => {
    const summary = individualReportEvidenceSummary;
    const registry = window.ReportModuleRegistry;
    if (!summary) return [];
    const manualReviews = manualMetricReviewRepo?.list?.({ status: 'reviewed' })
      .filter(review => review?.workflowState === 'confirmed' && review?.sourceSnapshot?.signature) || [];
    const evidenceSelections = appStore.get('_individualReportEvidenceSelections', {});
    const build = (type, reviewedEvidence, athleteId, evidenceRef) => summary.buildIndividualReportEvidenceSummary
      ? summary.buildIndividualReportEvidenceSummary({
          reviewedEvidence,
          athleteId,
          type,
          athletes: athletesWithOverall,
          sessionsByAthlete: cmjStore,
          evidenceRef: evidenceRef || null,
          allowSessionFallback: false,
        })
      : null;
    return athletesWithOverall.map(item => {
      const itemValues = item.seasons?.[season] || {};
      const itemScores = {};
      groups.forEach(group => { itemScores[group.id] = D.computeGroupScore(itemValues, group, squadStats, algo); });
      const evidenceRef = evidenceSelections[item.id] || null;
      const cmjEvidence = build('cmj', reviewedEvidenceViewModel.cmjReviewedEvidence, item.id, evidenceRef);
      const trainingInput = {
        athleteId: item.id,
        athletes: athletesWithOverall,
        dateRange: reportBatchRange,
        trainingLogs: reportBatchLogsByAthlete[item.id] || [],
      };
      const trainingData = registry ? {
        trainingWeek: registry.buildReportModuleData('training-week', trainingInput),
        trainingPeriod: registry.buildReportModuleData('training-period', trainingInput),
        trainingVolumeChart: registry.buildReportModuleData('training-volume-chart', trainingInput),
      } : {};
      const sjEvidence = build('sj', reviewedEvidenceViewModel.sjReviewedEvidence, item.id, evidenceRef);
      const imtpEvidence = build('imtp', reviewedEvidenceViewModel.imtpReviewedEvidence, item.id, evidenceRef);
      const fieldEvidence = build('field', reviewedEvidenceViewModel.fieldReviewedEvidence, item.id, evidenceRef);
      const manualEvidence = build('manual', manualReviews.filter(review => review.athleteId === item.id), item.id, evidenceRef);
      const hasConfirmed = [cmjEvidence, sjEvidence, imtpEvidence, fieldEvidence, manualEvidence].some(Boolean);
      return {
        athlete: item,
        scores: itemScores,
        reviewedEvidence: cmjEvidence,
        sjReviewedEvidence: sjEvidence,
        imtpReviewedEvidence: imtpEvidence,
        fieldReviewedEvidence: fieldEvidence,
        manualReviewedEvidence: manualEvidence,
        trainingReportData: trainingData,
        missingSummary: Object.keys(itemValues).length || hasConfirmed ? '' : '当前时段数据较少',
      };
    });
  }, [
    individualReportEvidenceSummary, manualMetricReviewRepo, athletesWithOverall,
    reviewedEvidenceViewModel, cmjStore, reportBatchRange, reportBatchLogsByAthlete,
    season, groups, D, squadStats, algo, reviewedEvidenceVersion, reportEvidenceSelection,
  ]);
  // REV-R3: confirmed-result rows for the training "Why this focus?" link selector.
  // Reuses the same ReviewedEvidenceRows viewmodel (no duplicated derivation) but
  // team-wide and without the 10-row discussion cap, so ProgramExecution can filter
  // to the current athlete. Only reviewed/confirmed rows are ever produced here.
  const confirmedResultLinkRows = useM(() => {
    if (!reviewedEvidenceRows?.createReviewedEvidenceViewModel) return [];
    return reviewedEvidenceRows.createReviewedEvidenceViewModel({
      forceSessionReviewRepo,
      assessmentReviewRepo,
      manualMetricReviewRepo,
      reviewedEvidenceSummary,
      sjReviewedEvidenceSummary,
      imtpReviewedEvidenceSummary,
      fieldTestReviewedEvidenceSummary,
      coachEvidenceSummary,
      evidenceUIContract,
      weeklyCoachReviewedEvidenceSummary,
      cmjStore,
      sjStore,
      imtpStore,
      fieldReviewedEvidenceSources: teamFieldReviewedEvidenceSources,
      athletes: athletesWithOverall,
      weeklyLimit: 500,
      requireAudited: true,
    }).weeklyRows;
  }, [
    reviewedEvidenceRows,
    weeklyCoachReviewedEvidenceSummary,
    evidenceUIContract,
    forceSessionReviewRepo,
    reviewedEvidenceSummary,
    coachEvidenceSummary,
    sjReviewedEvidenceSummary,
    imtpReviewedEvidenceSummary,
    assessmentReviewRepo,
    manualMetricReviewRepo,
    fieldTestReviewedEvidenceSummary,
    cmjStore,
    sjStore,
    imtpStore,
    athletesWithOverall,
    teamFieldReviewedEvidenceSources,
    reviewedEvidenceVersion,
  ]);
  const reviewDatabaseRows = useM(() => {
    const forceReviews = {
      cmj: forceSessionReviewRepo ? forceSessionReviewRepo.list({ type: 'cmj' }) : [],
      sj: forceSessionReviewRepo ? forceSessionReviewRepo.list({ type: 'sj' }) : [],
      imtp: forceSessionReviewRepo ? forceSessionReviewRepo.list({ type: 'imtp' }) : [],
    };
    const fieldReviews = assessmentReviewRepo
      ? assessmentReviewRepo.list({ sourceType: 'field_test' })
      : [];
    const manualReviews = manualMetricReviewRepo ? manualMetricReviewRepo.list() : [];
    const forceRows = [
      ...reviewDbSessionRows({ type: 'cmj', store: cmjStore, reviews: forceReviews.cmj, athletes: athletesWithOverall, lifecycle: reviewEvidenceLifecycle }),
      ...reviewDbSessionRows({ type: 'sj', store: sjStore, reviews: forceReviews.sj, athletes: athletesWithOverall, lifecycle: reviewEvidenceLifecycle }),
      ...reviewDbSessionRows({ type: 'imtp', store: imtpStore, reviews: forceReviews.imtp, athletes: athletesWithOverall, lifecycle: reviewEvidenceLifecycle }),
    ];
    const fieldRows = reviewDbFieldRows({
      sources: teamFieldReviewedEvidenceSources,
      reviews: fieldReviews,
      athletes: athletesWithOverall,
      lifecycle: reviewEvidenceLifecycle,
    });
    const manualRows = reviewDbManualRows({
      athletes: athletesWithOverall,
      groups,
      reviews: manualReviews,
      editable: !!manualMetricReviewRepo,
      lifecycle: reviewEvidenceLifecycle,
    });
    return reviewDbSortRows([...forceRows, ...fieldRows, ...manualRows]);
  }, [
    forceSessionReviewRepo,
    assessmentReviewRepo,
    manualMetricReviewRepo,
    cmjStore,
    sjStore,
    imtpStore,
    teamFieldReviewedEvidenceSources,
    athletesWithOverall,
    groups,
    reviewedEvidenceVersion,
    reviewEvidenceLifecycle,
  ]);
  const aiDomainTools = useM(() => {
    const factory = window.AiDomainReadTools;
    return factory && window.FieldDataStore
      ? factory.create({ store: window.FieldDataStore, rehabModel: window.RehabTrainingModel })
      : null;
  }, []);
  const aiReadModel = useM(() => {
    const factory = window.AiDataReadModel;
    const catalogApi = window.AthleteMetricCatalog;
    const statistics = window.AiStatistics;
    if (!factory || !catalogApi || !statistics) return null;
    return factory.create({
      athletes: athletesWithOverall,
      cmjStore,
      sjStore,
      imtpStore,
      reviewRows: reviewDatabaseRows,
      catalogApi,
      statistics,
      domainTools: aiDomainTools,
    });
  }, [athletesWithOverall, cmjStore, sjStore, imtpStore, reviewDatabaseRows, aiDomainTools]);
  const aiDecisionTools = useM(() => {
    const monitoringFactory = window.AthleteMonitoringContext;
    const decisionFactory = window.DecisionCaseBuilder;
    const catalogApi = window.AthleteMetricCatalog;
    const statistics = window.AiStatistics;
    if (!monitoringFactory || !decisionFactory || !catalogApi || !statistics || !aiDomainTools || !knowledgeRetrieval) return null;
    const monitoringContext = monitoringFactory.createAthleteMonitoringContext({
      athletes: athletesWithOverall,
      catalogApi,
      statistics,
      domainTools: aiDomainTools,
      cmjStore,
      sjStore,
      imtpStore,
    });
    const decisionBuilder = decisionFactory.createDecisionCaseBuilder({
      monitoringContext,
      knowledgeRetrieval,
    });
    return Object.freeze({
      buildCase: decisionBuilder.build,
      athletes: athletesWithOverall.map(row => ({ id:row.id, name:row.name })),
      metrics: catalogApi.listCatalog(),
      currentAthleteId: selectedId,
      currentSeason: season,
      knowledgeDocuments: knowledgeDocumentRepo,
      knowledgeRetrieval,
      actions: confirmedActionExecutor,
      ownerId: knowledgeOwnerId,
      prepareAction: async (decisionCase, option) => {
        const athleteId = decisionCase.scope.athlete.id;
        const evidenceRefs = [
          ...decisionCase.sourceRefs.map(ref => ref.ref || ref),
          ...decisionCase.knowledgeEvidence.map(ref => ref.ref),
        ];
        const common = {
          kind: option.actionKind,
          evidenceRefs,
          sourceDecisionCaseId: decisionCase.id,
          impactSummary: option.actionKind === 'training_plan_draft'
            ? `为 ${decisionCase.scope.athlete.name} 新建一个未发布、可编辑的训练计划草案；不修改历史记录。`
            : `更新 ${decisionCase.scope.athlete.name} 在 ${season} 的个体报告编辑草案；不导出或分享。`,
          idempotencyKey: [decisionCase.id, option.id, athleteId, season].join('|'),
        };
        if (option.actionKind === 'training_plan_draft') {
          const name = `${decisionCase.scope.athlete.name} · AI 监控草案 · ${new Date().toISOString().slice(0,10)}`;
          return {
            ...common,
            target:{ athleteId },
            payload:{
              name,
              program:{
                name,
                mode:'individual',
                athleteId,
                structureType:'daily',
                prescriptionSchemaVersion:1,
                prescriptionFields:{ tempo:false, rest:false, note:true, vbt:false },
                sessions:[],
              },
            },
            expectedTargetRevision:null,
          };
        }
        if (option.actionKind === 'report_draft') {
          const scope = { athleteId, season };
          const existing = individualReportDraftRepo?.load(scope);
          const narrative = [
            decisionCase.question,
            ...decisionCase.observations.map(row => row.statement),
            ...decisionCase.limitations.map(row => `限制：${row.statement}`),
          ].join('\n');
          return {
            ...common,
            target:scope,
            payload:{ config:{ narrOn:true, narrText:narrative } },
            expectedTargetRevision:existing ? String(existing.updatedAt || '') : '',
          };
        }
        throw new Error('所选方案不需要或不支持业务写入。');
      },
    });
  }, [
    athletesWithOverall,
    cmjStore,
    sjStore,
    imtpStore,
    aiDomainTools,
    knowledgeDocumentRepo,
    knowledgeRetrieval,
    confirmedActionExecutor,
    knowledgeOwnerId,
    selectedId,
    season,
    individualReportDraftRepo,
  ]);
  const fieldSourceFocusFromRef = (sourceRef) => {
    if (!sourceRef) return null;
    const assessmentId = sourceRef.assessmentId || sourceRef.id || sourceRef.sourceId || '';
    const planId = sourceRef.planId || (String(assessmentId).includes('__') ? String(assessmentId).split('__')[0] : '');
    if (!planId) return null;
    return {
      planId,
      assessmentId: assessmentId || null,
      athleteId: sourceRef.athleteId || null,
      testDefId: sourceRef.testDefId || null,
    };
  };
  const openFieldTestSourceFocus = (sourceRef) => {
    const focus = fieldSourceFocusFromRef(sourceRef);
    if (focus) {
      setFieldInitialPlanId(focus.planId);
      setFieldInitialSourceFocus(focus);
    } else {
      setFieldInitialSourceFocus(null);
    }
    setView('field-test');
  };
  const openTeamReviewedEvidenceSource = (row) => {
    if (!row?.sourceAvailable || !row?.sourceRef?.id) return;
    // the global athlete must follow the row, exactly as the 'manual' branch of
    // openReviewDatabaseSource already does: without it the longitudinal view opens for
    // another athlete while the page still treats the previously selected one as 本人.
    if (row.sourceType === 'sj') {
      transitionForceContext({ athleteId: row.sourceRef.athleteId, testType: 'sj', sessionId: row.sourceRef.id, view: 'sj-longitudinal' });
      return;
    }
    if (row.sourceType === 'imtp') {
      transitionForceContext({ athleteId: row.sourceRef.athleteId, testType: 'imtp', sessionId: row.sourceRef.id, view: 'imtp-longitudinal' });
      return;
    }
    if (row.sourceType === 'field') {
      openFieldTestSourceFocus(row.sourceRef);
    }
  };
  const openTeamReviewedEvidenceReport = (row) => {
    if (row?.sourceType !== 'cmj' || !row?.sourceRef?.id || !row?.sourceRef?.athleteId) return;
    setCmjReportAthleteId(row.sourceRef.athleteId);
    setCmjReportSessionId(row.sourceRef.id);
    setCmjReportOpen(true);
  };
  const openReviewDatabaseSource = (row) => {
    if (!row) return;
    // same omission as openTeamReviewedEvidenceSource had: the global athlete must follow the
    // row, as the 'manual' branch below already does.
    if (row.sourceType === 'cmj') {
      transitionForceContext({ athleteId: row.athleteId, testType: 'cmj', sessionId: row.sessionId, view: 'cmj-longitudinal' });
      return;
    }
    if (row.sourceType === 'sj') {
      transitionForceContext({ athleteId: row.athleteId, testType: 'sj', sessionId: row.sessionId, view: 'sj-longitudinal' });
      return;
    }
    if (row.sourceType === 'imtp') {
      transitionForceContext({ athleteId: row.athleteId, testType: 'imtp', sessionId: row.sessionId, view: 'imtp-longitudinal' });
      return;
    }
    if (row.sourceType === 'field') {
      openFieldTestSourceFocus(row.sourceRef);
      return;
    }
    if (row.sourceType === 'manual') {
      setSelectedId(row.athleteId);
      setView('individual');
    }
  };
  const openReviewDatabaseReview = (row) => {
    if (!row || row.sourceType !== 'cmj' || !row.sessionId) return;
    setCmjReportAthleteId(row.athleteId);
    setCmjReportSessionId(row.sessionId);
    setCmjReportOpen(true);
  };
  const openReviewDatabaseReport = (row) => {
    if (!row || row.lifecycleState !== 'confirmed') return;
    const selection = {
      ...(row.sourceRef || {}),
      sourceType: row.sourceType,
      reviewId: row.review?.id || '',
    };
    if (!transitionContext({ selectedId: row.athleteId, view: 'report' })) return;
    setReportEvidenceSelection(selection);
    const savedSelections = appStore.get('_individualReportEvidenceSelections', {});
    appStore.set('_individualReportEvidenceSelections', { ...savedSelections, [row.athleteId]: selection });
  };
  const saveReviewDatabaseInlineReview = (row, draft = {}) => {
    if (!row) return null;
    const action = draft.status === 'reviewed' ? 'confirm' : 'save-draft';
    const content = {
      conclusion: reviewDbCleanString(draft.conclusion),
      trainingFocus: reviewDbCleanString(draft.trainingFocus),
      note: reviewDbCleanString(draft.note),
    };
    let lifecyclePatch;
    try {
      lifecyclePatch = reviewEvidenceLifecycle.applyReviewAction({
        review: row.review,
        action,
        actor: { kind: 'local-human', displayName: reviewerName },
        sourceSnapshot: row.sourceSnapshot,
        content,
        now: new Date().toISOString(),
      });
    } catch (error) {
      console.error('Review lifecycle rejected save:', error);
      return null;
    }
    const common = {
      ...lifecyclePatch,
      sportsScientistConclusion: content.conclusion,
      reviewerTrainingFocus: content.trainingFocus,
      reviewerNote: content.note,
      selectedMetricContext: {
        metricLabels: (row.metricLabel || row.sourceLabel) ? [String(row.metricLabel || row.sourceLabel)] : [],
      },
    };
    let saved = null;
    try {
      if (row.sourceType === 'cmj' && forceSessionReviewRepo && row.sessionId) {
        saved = forceSessionReviewRepo.save({
          ...lifecyclePatch,
          type: 'cmj',
          sessionId: row.sessionId,
          athleteId: row.athleteId,
          selectedTrialBasis: row.review?.selectedTrialBasis || row.sourceLabel || '',
          classificationStatement: content.conclusion,
          trainingImplication: content.trainingFocus,
          notes: content.note,
        });
      } else if ((row.sourceType === 'sj' || row.sourceType === 'imtp') && forceSessionReviewRepo && row.sessionId) {
      saved = forceSessionReviewRepo.save({
        ...common,
        type: row.sourceType,
        sessionId: row.sessionId,
        athleteId: row.athleteId,
      });
      } else if (row.sourceType === 'field' && assessmentReviewRepo && row.assessmentId && row.testDefId && row.reviewEligible) {
        saved = assessmentReviewRepo.save({
          ...common,
          sourceType: 'field_test',
          testType: row.testType || 'field',
          assessmentId: row.assessmentId,
          testDefId: row.testDefId,
          athleteId: row.athleteId,
        });
      } else if (row.sourceType === 'manual' && manualMetricReviewRepo && row.athleteId && row.sourceDate && row.metricId) {
        saved = manualMetricReviewRepo.save({
          ...common,
          athleteId: row.athleteId,
          date: row.sourceDate,
          metricId: row.metricId,
        });
      }
    } catch (error) {
      console.error('Review persistence failed:', error);
      saved = null;
    }
    if (saved) setReviewedEvidenceVersion(n => n + 1);
    return saved;
  };

  // S2 合并视图:测力台分析(导入/对比/力量剖析)· 损伤筛查与康复(损伤+筛查)
  const handleShellNav = (id) => {
    // Close synchronously for rail navigation; the view effect above is the
    // invariant backstop for navigation paths that do not pass this handler.
    setSidebarOpen(false);
    if (id === 'injury') setInjuryCtx(null);
    if (id === 'training') setTrainingContext(null);
    // FORCE-WS-1a (2026-07-10): the single 测力台 rail entry uses id 'cmj'; restore
    // the last-visited force view (类型 + 模式) so re-entry lands where you left off
    // (mirrors the established _mode / _season appStore idiom — lighter than a repo).
    if (id === 'cmj') { setView(appStore.get('_forceView', 'cmj')); return; }
    setView(id);
  };
  // S4: roster (花名册) shows on all work pages (collapsible to an avatar strip);
  // only 设置 is full-width (completes S3 设置整页). Athlete-centric pages keep their sidebar.
  const rosterVisible = view !== 'settings' && view !== 'knowledge';
  // 设置 props(弹窗 + 独立页 共用)
  const settingsProps = {
    tab: settingsTab, onTabChange: setSettingsTab,
    groups, onGroupsChange: setGroups,
    athletes, onAthletesChange: setAthletesViaRepository,
    seasons, currentSeason: season,
    algo, onAlgoChange: setAlgo,
    riskConfig, onRiskConfigChange: setRiskConfig,
    trendConfig, onTrendConfigChange: setTrendConfig,
    onResetToDefaults: resetToDefaults,
    onDeleteAthlete: deleteAthlete,
    onCountAthleteData: countAthleteData,
    onClearForceSessions: clearForceSessions,
    forceStorageUsageBytes,
    forceSessionStores: { cmj: cmjStore, sj: sjStore, imtp: imtpStore },
    legacyImportPlanner,
    getLegacyImportMapping: (signature) => appStore.get('_legacyImportMappings', {})[signature] || null,
    onSaveLegacyImportMapping: (signature, mappings) => {
      const current = appStore.get('_legacyImportMappings', {});
      appStore.set('_legacyImportMappings', { ...current, [signature]: mappings });
    },
    onApplyLegacyImport: async (plan) => {
      if (!plan || plan.blockers?.length) return { committed: false, error: '请先解决所有阻塞项。' };
      const recomputed = (plan.nextAthletes || []).map(a => D.recomputeLoadForAthlete(a));
      const prepared = athleteCollection?.prepare ? athleteCollection.prepare(recomputed, athletes) : recomputed;
      const result = legacyImportRepository.commit({
        athletes: prepared,
        seasons: plan.nextSeasons || seasons,
        intakeId: `legacy-${Date.now()}`,
        committedAt: new Date().toISOString(),
        source: plan.source,
        counts: plan.summary,
      });
      if (result.committed) {
        setAthletes(prepared);
        setSeasons(plan.nextSeasons || seasons);
      }
      return result;
    },
    onSeasonsChange: setSeasons,
    // FORCE-TRACE M3-D (D3 contract): the separate Force backup/restore entry. Export gathers the
    // CURRENT force data into the versioned document; import runs the journaled replace-all
    // (failure-rollback + crash-recoverable — NOT a cross-engine atomic transaction) and refreshes
    // the in-memory stores on success. Capacity numbers are ADVISORY only (contract #4).
    onForceBackupExport: async () => {
      if (!forceRestoreEngine || !forceTraceBackup) throw new Error('backup module unavailable');
      try { await reconcileLock.current; } catch (_) {}   // never export mid-recovery/reconcile
      const col = await forceRestoreEngine.collectForExport();   // self-checked: must be restorable
      const doc = await forceTraceBackup.buildBackup(col.sessions, col.traces);
      return { fileName: 'force-backup-' + new Date().toISOString().slice(0, 10) + '.json', json: JSON.stringify(doc) };
    },
    onForceTraceUsage: () => (forceTraceRepo ? forceTraceRepo.estimateUsage() : Promise.resolve(0)),
    onForceBackupImport: async (fileText) => {
      if (!forceRestoreEngine || !forceTraceBackup) throw new Error('backup module unavailable');
      let doc; try { doc = JSON.parse(fileText); } catch (e) { return { ok: false, error: '不是有效的 JSON 文件' }; }
      let parsed;
      try { parsed = await forceTraceBackup.parseAndVerify(doc); }
      catch (e) { return { ok: false, error: '备份校验失败：' + ((e && e.message) || e) }; }
      const advisory = await forceRestoreEngine.capacityAdvisory(fileText.length);
      // Hold reconcileLock across the WHOLE replace-all (not just await it): a save/delete/export
      // issued during the restore reads this pending lock and queues until it finishes, so a
      // concurrent save can never interleave with the wipe+write (GPT M3-D P1).
      const report = await forceRestoreEngine.runExclusive(reconcileLock, () => forceRestoreEngine.performRestore(parsed));
      if (report.committed) {
        setCmjStore(loadForceStore('cmj')); setSjStore(loadForceStore('sj')); setImtpStore(loadForceStore('imtp'));
        return { ok: true, advisory };
      }
      return { ok: false, error: report.error || '恢复失败', rolledBack: !!report.rolledBack, journalRetained: !!report.journalRetained, advisory };
    },
  };

  return (
    <AppShell
      isHoriz={isHoriz}
      sidebarOpen={sidebarOpen}
      rosterVisible={rosterVisible}
      rosterCollapsed={rosterCollapsed}
      view={view}
      lang={lang}
      onNav={handleShellNav}
      onOpenSettings={(tab) => { if (tab) setSettingsTab(tab); setView('settings'); }}
      onMenu={() => setSidebarOpen(o => !o)}
      onCloseSidebar={() => setSidebarOpen(false)}
      compareMode={compareMode}
      compareIds={compareIds}
      athletes={athletes}
      onClearCompare={() => setCompareIds([])}
      onExitCompare={() => setCompareMode(false)}
      onJumpCompare={() => setView('individual')}
      athlete={athlete}
      season={season}
      setSeason={setSeason}
      seasons={seasons}
      onOpenIndividual={() => setView('individual')}
      onSelectAthlete={selectAthleteInPlace}
      aiReadModel={aiReadModel}
      aiDecisionTools={aiDecisionTools}
      sidebar={rosterVisible ? (
        <Sidebar
          athletes={athletesWithOverall}
          selectedId={selectedId}
          compareIds={compareIds}
          onSelect={selectAthleteInPlace}
          onToggleCompare={toggleCompare}
          compareMode={compareMode}
          onCompareModeChange={setCompareMode}
          view={view}
          onViewChange={setView}
          onAddAthlete={() => setAddAthleteOpen(true)}
          riskMap={sidebarRiskMap}
          collapsed={rosterCollapsed}
          onToggleCollapse={toggleRosterCollapsed}
        />
      ) : null}
    >

      <AppViewDispatch
        view={view}
        routes={routes}
        settingsProps={settingsProps}
        forceProps={{
          view, routes, 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,
          forceCaptureMode, setForceCaptureMode, forceFreshSession, setForceFreshSession,
          forceTraceRead, forceTraceReadModel,
          forceTargetAdapter,
          transitionForceContext,
          forceSourceFileRead: forceSourceFileRepo ? { toFile: forceSourceFileRepo.toFile } : null,
        }}
        calendarProps={{
          athletes,
          onOpenLoadEntry: (date) => { setEntryDefaultDate(date); setEntryOpen(true); },
          onGoToTestEntry: () => setView('field-test'),
          onCreateTestPlan: () => { setFieldInitialSourceFocus(null); setFieldAutoBuild(true); setView('field-test'); },
          onOpenTestPlan: (planId) => { setFieldInitialSourceFocus(null); setFieldInitialPlanId(planId); setView('field-test'); },
          onOpenProgram: (programId) => { setTrainingInitialProgramId(programId); setView('training'); },
        }}
        fieldTestProps={{
          athletes, groups, onGroupsChange: setGroups, seasons, patchAthleteMetrics,
          autoBuild: fieldAutoBuild,
          onConsumeAutoBuild: () => setFieldAutoBuild(false),
          initialPlanId: fieldInitialPlanId,
          onConsumeInitialPlan: () => setFieldInitialPlanId(null),
          initialSourceFocus: fieldInitialSourceFocus,
          onConsumeInitialSourceFocus: () => setFieldInitialSourceFocus(null),
        }}
        reviewDatabaseProps={{
          rows: reviewDatabaseRows,
          programs: reviewDbPrograms,
          forceTraceRead,
          forceTraceReadModel,
          reviewKnowledgeReadModel: window.ReviewKnowledgeReadModel || null,
          onOpenReview: openReviewDatabaseReview,
          onOpenReport: openReviewDatabaseReport,
          onOpenSource: openReviewDatabaseSource,
          onSaveReview: saveReviewDatabaseInlineReview,
          onLinkToTraining: linkReviewDatabaseToTraining,
          reviewerName,
          onReviewerNameChange: (value) => {
            const next = reviewDbCleanString(value);
            setReviewerName(next);
            appStore.set('_reviewerName', next);
          },
        }}
        trainingProps={{
          athletes, groups, seasons, patchAthleteMetrics,
          selectedAthleteId: selectedId,
          initialProgramId: trainingInitialProgramId,
          onConsumeInitialProgram: () => setTrainingInitialProgramId(null),
          trainingContextSeed: trainingContext,
          confirmedResultRows: confirmedResultLinkRows,
          onOpenTrainingContextReview: openStoryEvidence,
          onOpenTrainingContextReport: openStoryEvidence,
        }}
        injuryProps={{
          view, setView, athlete, groups, seasons, patchAthleteMetrics,
          injuryCtx, setInjuryCtx,
        }}
        normsProps={{ groups }}
        knowledgeProps={{
          repository: knowledgeDocumentRepo,
          retrieval: knowledgeRetrieval,
          ownerId: knowledgeOwnerId,
          ready: knowledgeReady,
        }}
        teamProps={{
          athletes, groups, seasons, season, onSeasonChange: setSeason,
          squadStats, squadStatsAll, algo,
          onSelectAthlete: (id) => transitionContext({ selectedId: id, view: 'individual' }),
          onOpenRoster: () => setSidebarOpen(true),
          // TEAM-2: deep-link a team row to a specific athlete view (injury /
          // movement-screen); prop-passing only, mirrors onSelectAthlete.
          onOpenAthleteView: (id, nextView) => transitionContext({ selectedId: id, view: nextView || 'individual' }),
          onOpenSettings: openSettings,
          onOpenEntry: () => setEntryOpen(true),
          onPrint: () => setExportOpen(true),
          onOpenTeamReport: () => setTeamReportOpen(true),
          density: t.density,
          riskConfig,
          mode, onModeChange: setMode,
          cmjStore, sjStore, imtpStore, // TEAM-2b: sjStore added for metric-compare
          reviewedEvidenceRows: teamReviewedEvidenceRows,
          onOpenReviewedEvidenceSource: openTeamReviewedEvidenceSource,
          onOpenReviewedEvidenceReview: openTeamReviewedEvidenceReport,
          onOpenReviewedEvidenceReport: openTeamReviewedEvidenceReport,
        }}
        reportProps={{
          athlete, season, seasons, groups, scores, squadStatsAll,
          reviewedEvidence: r1ReportEvidence.cmj,
          sjReviewedEvidence: r1ReportEvidence.sj,
          imtpReviewedEvidence: r1ReportEvidence.imtp,
          fieldReviewedEvidence: r1ReportEvidence.field,
          manualReviewedEvidence: r1ReportEvidence.manual,
          draftAdapter: reportDraftAdapterRef.current,
          historySettling,
          selectedEvidenceType: activeReportEvidenceSelection?.sourceType || '',
          trainingReportData,
          batchContexts: batchReportContexts,
          onClose: () => setView('individual'),
          onOpenReviewedEvidence: openStoryEvidence,
          onOpenReviewedEvidenceReport: openStoryEvidence,
        }}
        individualProps={{
          D, compareColors: COMPARE_COLORS, isHoriz, t,
          athlete, athletes, athletesWithOverall, groups, seasons, season, setSeason,
          selectedId, setSelectedId,
          compareAthletes, compareMode, compareIds, toggleCompare, setCompareMode,
          setExportOpen, openSettings, setEntryOpen,
          openReportPage: () => {
            if (transitionContext({ view: 'report' })) setReportOpen(false);
          },
          setTeamReportOpen,
          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: storyEvidenceByAthlete[athlete.id],
          confirmedResultRows: confirmedResultLinkRows,
          onOpenStoryReview: openStoryEvidence,
          onOpenStoryReport: openStoryEvidence,
          onOpenStoryTraining: openTrainingWithStoryEvidence,
          setView, setInjuryCtx, setAthletesViaRepository,
        }}
      />

      <AppModals
        t={t}
        setTweak={setTweak}
        forceTraceRead={forceTraceRead}
        forceTraceReadModel={forceTraceReadModel}
        forceTargetAdapter={forceTargetAdapter}
        transitionForceContext={transitionForceContext}
        settingsOpen={settingsOpen}
        setSettingsOpen={setSettingsOpen}
        settingsProps={settingsProps}
        entryOpen={entryOpen}
        setEntryOpen={setEntryOpen}
        entryDefaultDate={entryDefaultDate}
        setEntryDefaultDate={setEntryDefaultDate}
        athletes={athletes}
        setAthletesViaRepository={setAthletesViaRepository}
        groups={groups}
        seasons={seasons}
        setSeasons={setSeasons}
        selectedId={selectedId}
        exportOpen={exportOpen}
        setExportOpen={setExportOpen}
        athlete={athlete}
        season={season}
        scores={scores}
        overallScore={overallScore}
        view={view}
        transitionContext={transitionContext}
        openSettings={openSettings}
        closeIndividualReportModal={closeIndividualReportModal}
        openReportPage={() => setView('report')}
        reportOpen={reportOpen}
        trainingReportData={trainingReportData}
        batchReportContexts={batchReportContexts}
        individualReportDraftAdapter={reportDraftAdapterRef.current}
        historySettling={historySettling}
        squadStatsAll={squadStatsAll}
        cmjReportOpen={cmjReportOpen}
        setCmjReportOpen={setCmjReportOpen}
        athletesWithOverall={athletesWithOverall}
        cmjReportAthleteId={cmjReportAthleteId}
        setCmjReportAthleteId={setCmjReportAthleteId}
        cmjReportSessionId={cmjReportSessionId}
        setCmjReportSessionId={setCmjReportSessionId}
        cmjAthleteId={cmjAthleteId}
        cmjStore={cmjStore}
        sjStore={sjStore}
        imtpStore={imtpStore}
        setSjAthleteId={setSjAthleteId}
        setImtpAthleteId={setImtpAthleteId}
        setReviewSessionId={setReviewSessionId}
        forceSessionReviewRepo={forceSessionReviewRepo}
        assessmentReviewRepo={assessmentReviewRepo}
        manualMetricReviewRepo={manualMetricReviewRepo}
        reviewedEvidenceSummary={reviewedEvidenceSummary}
        sjReviewedEvidenceSummary={sjReviewedEvidenceSummary}
        imtpReviewedEvidenceSummary={imtpReviewedEvidenceSummary}
        fieldTestReviewedEvidenceSummary={fieldTestReviewedEvidenceSummary}
        individualReportEvidenceSummary={individualReportEvidenceSummary}
        coachEvidenceSummary={coachEvidenceSummary}
        evidenceUIContract={evidenceUIContract}
        weeklyCoachReviewedEvidenceSummary={weeklyCoachReviewedEvidenceSummary}
        reviewedEvidenceRows={reviewedEvidenceRows}
        onReviewSaved={() => setReviewedEvidenceVersion(n => n + 1)}
        onOpenFieldTestSource={openFieldTestSourceFocus}
        knowledgeRegistry={knowledgeRegistry}
        knowledgeRuleValidator={knowledgeRuleValidator}
        teamReportOpen={teamReportOpen}
        setTeamReportOpen={setTeamReportOpen}
        squadStats={squadStats}
        algo={algo}
        D={D}
        cmdOpen={cmdOpen}
        setCmdOpen={setCmdOpen}
        setSelectedId={setSelectedId}
        setView={setView}
        setCompareMode={setCompareMode}
        setAddAthleteOpen={setAddAthleteOpen}
        setShortHelpOpen={setShortHelpOpen}
        cycleVibe={cycleVibe}
        shortHelpOpen={shortHelpOpen}
        addAthleteOpen={addAthleteOpen}
        booting={booting}
      />
    </AppShell>
  );
}

const algoLabel = (a) => ({
  range: 'Range mapping', zscore: 'Z-score', percentile: 'Percentile', swc: 'SWC',
  modzscore: 'Modified Z (individual baseline)',
}[a] || a);
window.algoLabel = algoLabel;

// ──────────────────────────────────────────────────────────────────────────
function TopBar({ athletes, compareMode, onClearCompare, onPrint, onOpenSettings, onOpenEntry, onOpenReport, onOpenTeamReport, algo, lang, onLangToggle }) {
  return (
    <div style={{
      position: 'sticky', top: 0, zIndex: 10,
      background: 'rgba(255,255,255,.88)',
      backdropFilter: 'blur(12px)',
      borderBottom: '1px solid var(--border)',
      padding: '12px 24px',
      display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap',
    }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 14, minWidth: 0 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--muted)', fontSize: 12 }}>
          <span>{window.t('Performance')}</span>
          <Icon name="chevRight" size={11}/>
          <span style={{ color: 'var(--text)' }}>{compareMode ? 'Compare' : 'Athlete Report'}</span>
        </div>
        {compareMode && (
          <span className="pill" style={{ background: 'var(--accent-soft)', color: 'var(--accent-2)', borderColor: 'rgba(59,130,246,.3)' }}>
            <Icon name="users" size={11}/> {athletes.length} selected
            <button onClick={onClearCompare} style={{ background:'transparent', border:0, color:'inherit', cursor:'pointer', padding:0, display:'flex', alignItems:'center', marginLeft: 4 }}>
              <Icon name="x" size={11}/>
            </button>
          </span>
        )}
      </div>

      <div className="no-print" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
        <button className="btn" onClick={() => onOpenSettings('metrics')} title={window.t('Edit metrics')}>
          <Icon name="sliders" size={13}/> Settings: Metrics
        </button>
        <button className="btn" onClick={() => onOpenSettings('data')} title="Import / Export data">
          <Icon name="download" size={13}/> Settings: Data
        </button>
        <button className="btn" onClick={onOpenEntry} title="Record a new measurement">
          <Icon name="plus" size={13}/> Entry
        </button>
        <button className="btn primary" onClick={onPrint}>
          <Icon name="download" size={13}/> Export / Print
        </button>
        {/* 语言切换按钮 — 显示目标语言（点击后切换） */}
        <button
          className="btn ghost"
          onClick={onLangToggle}
          title={lang === 'zh' ? 'Switch to English' : '切换为中文'}
          style={{ fontFamily: 'var(--font-sans)', fontSize: 12, fontWeight: 600,
                   minWidth: 36, justifyContent: 'center',
                   color: 'var(--text-2)', letterSpacing: '.02em' }}
        >
          {lang === 'zh' ? 'EN' : '中'}
        </button>
      </div>
    </div>
  );
}

function Panel({ title, subtitle, rightAction, children }) {
  return (
    <section className="fade-up" style={{
      background: 'var(--panel)',
      border: '1px solid var(--border)',
      borderRadius: 12,
      padding: '15px 17px',
      display: 'flex', flexDirection: 'column', gap: 11,
      minWidth: 0,
    }}>
      <header style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
        <div>
          <div style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--text)', letterSpacing: '-.01em' }}>{title}</div>
          {subtitle && <div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 3, lineHeight: 1.45 }}>{subtitle}</div>}
        </div>
        {rightAction}
      </header>
      <div style={{ minWidth: 0 }}>{children}</div>
    </section>
  );
}

// ── scard atoms (mockup .pill / .arrow) — shared by the 3 individual-page module cards ──
// Auto-global via Babel top-level fn; used as window.Pill / window.ModuleArrow.
function Pill({ children, tone = 'neutral', title }) {
  const C = {
    neutral: { color: 'var(--text-2)', border: 'var(--border)' },
    pos:     { color: 'var(--pos)',    border: 'rgba(22,163,74,.40)' },
    warn:    { color: 'var(--warn)',   border: 'rgba(217,119,6,.40)' },
    neg:     { color: 'var(--neg)',    border: 'rgba(220,38,38,.40)' },
  }[tone] || {};
  return (
    <span title={title} style={{
      display: 'inline-flex', alignItems: 'center', gap: 6, height: 22, padding: '0 9px',
      borderRadius: 999, background: 'var(--panel-hi)', border: `1px solid ${C.border}`,
      fontSize: 11, fontWeight: 500, color: C.color, fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap',
    }}>{children}</span>
  );
}

// Header entry affordance (mockup .arrow) — Panel rightAction that opens the module.
function ModuleArrow({ onClick, title = '进入模块' }) {
  return (
    <button onClick={onClick} title={title} aria-label={title} className="card-arrow" style={{
      width: 28, height: 28, flexShrink: 0, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
      background: 'transparent', border: 'none', borderRadius: 8, cursor: 'pointer', color: 'var(--muted-2)',
    }}>
      <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14m-6-6 6 6-6 6"/></svg>
    </button>
  );
}

// Collapsible variant — used in Coach Mode for demoted research-grade panels (e.g. full SWC)
function FoldableSection({ title, subtitle, defaultOpen = false, children }) {
  const [open, setOpen] = useS(defaultOpen);
  return (
    <section className="fade-up" style={{
      background: 'var(--panel)',
      border: '1px solid var(--border)',
      borderRadius: 12,
      overflow: 'hidden',
      minWidth: 0,
    }}>
      <button onClick={() => setOpen(o => !o)} style={{
        width: '100%', background: 'transparent', border: 'none', cursor: 'pointer',
        padding: '12px 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
        color: 'var(--text-2)', fontFamily: 'var(--font-sans)', textAlign: 'left',
      }}>
        <div>
          <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-2)' }}>{title}</div>
          {subtitle && <div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 2 }}>{subtitle}</div>}
        </div>
        <span style={{ fontSize: 10, color: 'var(--muted)', transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }}>▼</span>
      </button>
      {open && <div style={{ borderTop: '1px solid var(--border)', padding: '14px 16px' }}>{children}</div>}
    </section>
  );
}

function SummaryTile({ groups, scores, squadAvgScores }) {
  const diffs = groups.map(g => ({
    label: g.label,
    accent: g.accent,
    diff: (scores[g.id] || 0) - (squadAvgScores[g.id] || 0),
  })).sort((a,b) => Math.abs(b.diff) - Math.abs(a.diff));

  return (
    <div style={{
      background: 'linear-gradient(135deg, var(--panel) 0%, var(--panel-2) 100%)',
      border: '1px solid var(--border)',
      borderRadius: 10,
      padding: '14px 16px',
      display: 'flex', flexDirection: 'column', gap: 10, minWidth: 0,
    }}>
      <div style={{ display: 'flex', alignItems:'center', gap: 8 }}>
        <Icon name="bolt" size={13}/>
        <span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: '.1em', color: 'var(--muted)' }}>
          {window.t('Standout vs Squad')}
        </span>
      </div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
        {diffs.slice(0,4).map(d => {
          const isUp = d.diff >= 0;
          return (
            <div key={d.label} style={{ display: 'grid', gridTemplateColumns: '70px 1fr 50px', alignItems: 'center', gap: 8 }}>
              <span style={{ fontSize: 12, color: 'var(--text-2)' }}>{d.label}</span>
              <div style={{ height: 4, background: 'rgba(15,23,42,.06)', borderRadius: 2, position: 'relative' }}>
                <div style={{
                  position: 'absolute', left: '50%', top: 0, bottom: 0,
                  width: `${Math.min(50, Math.abs(d.diff))}%`,
                  background: isUp ? 'var(--pos)' : 'var(--neg)',
                  transform: isUp ? 'translateX(0)' : 'translateX(-100%)',
                  borderRadius: 2,
                }}/>
                <div style={{ position: 'absolute', left: '50%', top: -1, bottom: -1, width: 1, background: 'rgba(15,23,42,.22)' }}/>
              </div>
              <span className="mono" style={{ fontSize: 12, color: isUp ? 'var(--pos)' : 'var(--neg)', textAlign: 'right' }}>
                {isUp ? '+' : ''}{d.diff}
              </span>
            </div>
          );
        })}
      </div>
    </div>
  );
}

function CompareBar({ compareAthletes, compareIds, onRemove }) {
  return (
    <div style={{
      background: 'var(--panel)',
      border: '1px solid var(--border)',
      borderRadius: 8,
      padding: '8px 12px',
      display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap',
    }}>
      <span style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: '.08em', color: 'var(--muted)' }}>{window.t('Comparing')}</span>
      {compareAthletes.map((a, i) => (
        <span key={a.id} style={{
          display: 'inline-flex', alignItems: 'center', gap: 6,
          padding: '3px 8px 3px 4px', borderRadius: 999,
          background: 'var(--panel-hi)', border: '1px solid var(--border)',
        }}>
          <span style={{ width:8, height:8, borderRadius: 99, background: COMPARE_COLORS[i % COMPARE_COLORS.length] }}/>
          <span style={{ fontSize: 12 }}>{a.name}</span>
          <button onClick={() => onRemove(a.id)} style={{ background:'transparent', border:0, color:'var(--muted)', cursor:'pointer', display:'flex', alignItems:'center' }}>
            <Icon name="x" size={11}/>
          </button>
        </span>
      ))}
      <span style={{ fontSize: 11, color: 'var(--muted)', marginLeft: 'auto' }}>
        {window.t('Tap any athlete in the sidebar to add or remove. Up to 4.')}
      </span>
    </div>
  );
}

function BarLegend() {
  return (
    <div style={{ display: 'flex', gap: 18, fontSize: 11, color: 'var(--muted)', paddingTop: 4 }}>
      <span style={{ display:'inline-flex', alignItems:'center', gap:6 }}>
        <span style={{ width:10, height:2, background:'var(--pos)', borderRadius:1, opacity:.7 }}/>
        {window.t('Squad best')}
      </span>
      <span style={{ display:'inline-flex', alignItems:'center', gap:6 }}>
        <span style={{ width:10, height:0, borderTop:'1.5px dashed rgba(15,23,42,.45)' }}/>
        {window.t('Squad average')}
      </span>
      <span style={{ display:'inline-flex', alignItems:'center', gap:6 }}>
        <span style={{ width:10, height:2, background:'var(--neg)', borderRadius:1, opacity:.7 }}/>
        {window.t('Squad worst')}
      </span>
    </div>
  );
}

function RadarLegend({ compareAthletes, compareMode, showSquadBand, showPrev }) {
  return (
    <div style={{ display: 'flex', gap: 14, justifyContent: 'center', paddingTop: 6, flexWrap: 'wrap', fontSize: 11, color: 'var(--muted)' }}>
      {compareAthletes.map((a, i) => (
        <span key={a.id} style={{ display:'inline-flex', alignItems:'center', gap:6 }}>
          <span style={{ width: 10, height: 10, borderRadius: 2, background: compareMode ? COMPARE_COLORS[i % COMPARE_COLORS.length] : 'var(--accent)' }}/>
          {a.name}
        </span>
      ))}
      {showSquadBand && (
        <span style={{ display:'inline-flex', alignItems:'center', gap:6 }}>
          <span style={{ width: 12, height: 0, borderTop: '1.5px dashed var(--muted)' }}/>
          {window.t('Squad average')}
        </span>
      )}
      {showPrev && (
        <span style={{ display:'inline-flex', alignItems:'center', gap:6 }}>
          <span style={{ width: 12, height: 0, borderTop: '1.5px dashed rgba(15,23,42,.45)' }}/>
          {window.t('Previous session')}
        </span>
      )}
    </div>
  );
}

function Footer({ count }) {
  return (
    <div style={{
      paddingTop: 10, borderTop: '1px solid var(--border)',
      display: 'flex', justifyContent: 'space-between', color: 'var(--muted)', fontSize: 11,
    }}>
      <span>{window.t('Axis Performance · Mock data for demonstration')}</span>
      <span className="mono">{window.t('Build 2026.05')} · {count} athletes loaded</span>
    </div>
  );
}

function ExportModal({ athlete, season, scores, overallScore, onClose, onOpenDataExport, onOpenReport, reportLabel }) {
  const [format, setFormat] = useS('pdf-multi');
  const [busy, setBusy] = useS(false);
  const [error, setError] = useS(null);

  const FORMATS = [
    { id: 'pdf-multi',  label: window.t('PDF report'),    desc: 'Browser print dialog \u00b7 ' + window.t('Save as PDF') },
    { id: 'pdf-single', label: 'PDF \u00b7 single page', desc: 'Scaled to fit one A4 page' },
    { id: 'png',        label: window.t('PNG snapshot'), desc: 'Full dashboard captured as an image' },
    ...(onOpenReport ? [{ id: 'report', label: '打开报告编辑页', desc: `${reportLabel || '运动员报告'} · 编排章节 / 训练建议 / 预览后导出` }] : []),
  ];

  const doExport = async () => {
    if (format === 'report') { onClose(); onOpenReport && onOpenReport(); return; }
    setBusy(true);
    setError(null);
    try {
      if (format === 'png') {
        if (!window.htmlToImage) throw new Error('Image library not loaded');
        const node = document.getElementById('print-root');
        if (!node) throw new Error('Capture target not found');
        // Hide modal + overlays during capture, but keep modal mounted so errors can render
        document.body.classList.add('exporting-png');
        // Brief pause so the class-driven CSS can repaint before screenshot
        await new Promise(r => setTimeout(r, 120));
        const dataUrl = await window.htmlToImage.toPng(node, {
          backgroundColor: '#ffffff',
          pixelRatio: Math.min(2, window.devicePixelRatio || 1),
          cacheBust: true,
          filter: (el) => {
            if (el.classList && (
              el.classList.contains('tweaks-panel-host') ||
              el.classList.contains('no-print')
            )) return false;
            return true;
          },
        });
        document.body.classList.remove('exporting-png');
        const a = document.createElement('a');
        a.href = dataUrl;
        a.download = `${slug(athlete.name)}-${season}.png`;
        document.body.appendChild(a); a.click(); a.remove();
        onClose();  // close only after successful download
        return;
      }

      // PDF — use browser's print-to-PDF
      // Close modal so it's not in the printed output, then trigger print
      onClose();
      document.body.classList.add('exporting-pdf');
      if (format === 'pdf-single') document.body.classList.add('single-page');
      // Stash a friendly title so the print filename suggestion is sensible
      const prevTitle = document.title;
      document.title = `${athlete.name} \u00b7 ${season} \u00b7 Performance Report`;
      const cleanup = () => {
        document.body.classList.remove('exporting-pdf', 'single-page');
        document.title = prevTitle;
        window.removeEventListener('afterprint', cleanup);
      };
      window.addEventListener('afterprint', cleanup);
      // Give the layout time to repaint without the modal
      await new Promise(r => setTimeout(r, 150));
      window.print();
      // Fallback cleanup if 'afterprint' never fires (some browsers)
      setTimeout(cleanup, 4000);
    } catch (err) {
      console.error(err);
      document.body.classList.remove('exporting-pdf', 'exporting-png', 'single-page');
      setError(err.message || 'Export failed');
    } finally {
      setBusy(false);
    }
  };

  return (
    <div onClick={busy ? undefined : onClose} data-modal-root="export" style={{
      position: 'fixed', inset: 0, background: 'rgba(5,8,12,.7)', backdropFilter: 'blur(4px)',
      zIndex: 100, display: 'grid', placeItems: 'center', padding: 20,
      animation: 'fade .2s ease both',
    }}>
      <div onClick={(e) => e.stopPropagation()} style={{
        background: 'var(--panel)', border: '1px solid var(--border-strong)',
        borderRadius: 12, padding: 24, width: 480, maxWidth: '100%',
        boxShadow: '0 20px 60px rgba(0,0,0,.5)',
        animation: 'fadeUp .25s ease both',
      }}>
        <div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', marginBottom: 16 }}>
          <div>
            <div style={{ fontSize: 11, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.08em', marginBottom: 2 }}>Output Center</div>
            <h3 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>{athlete.name} · {window.formatDate ? window.formatDate(season, 'short') : season}</h3>
          </div>
          <button onClick={onClose} className="btn ghost" style={{ padding: 6 }}><Icon name="x" size={14}/></button>
        </div>
        <div style={{ display:'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
          {FORMATS.map((f) => (
            <label key={f.id} style={{
              display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px',
              background: format === f.id ? 'var(--accent-soft)' : 'var(--panel-2)',
              border: '1px solid',
              borderColor: format === f.id ? 'var(--accent)' : 'var(--border)',
              borderRadius: 6, cursor: 'pointer', transition: 'all .12s',
            }}>
              <input type="radio" name="exp" checked={format === f.id} onChange={() => setFormat(f.id)} style={{ accentColor: 'var(--accent)' }}/>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{ fontSize: 13, color: 'var(--text)', fontWeight: 500 }}>{f.label}</div>
                <div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 2 }}>{f.desc}</div>
              </div>
            </label>
          ))}
        </div>
        <div style={{
          padding: '10px 12px', background: 'var(--accent-soft)', borderRadius: 6,
          border: '1px solid rgba(59,130,246,.25)',
          fontSize: 12, color: 'var(--text-2)', marginBottom: 16,
        }}>
          Need raw data? Use <button onClick={onOpenDataExport} style={{ color: 'var(--accent-2)', background:'transparent', border:0, padding:0, font:'inherit', cursor:'pointer', textDecoration:'underline' }}>Data Import/Export</button> for .xlsx / .csv.
        </div>
        {error && (
          <div style={{
            padding: '8px 10px', background: 'rgba(239,68,68,.12)',
            border: '1px solid rgba(239,68,68,.3)', borderRadius: 6,
            color: 'var(--neg)', fontSize: 12, marginBottom: 12,
          }}>{error}</div>
        )}
        <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
          <button className="btn" onClick={onClose} disabled={busy}>Cancel</button>
          <button className="btn primary" onClick={doExport} disabled={busy}>
            {busy ? (
              <span style={{ animation: 'pulse 1.2s ease-in-out infinite' }}>Working…</span>
            ) : (
              <>
                <Icon name={format === 'report' ? 'star' : (format === 'png' ? 'download' : 'print')} size={13}/>
                {format === 'report' ? '打开报告编辑页' : (format === 'png' ? 'Download PNG' : 'Open print dialog')}
              </>
            )}
          </button>
        </div>
      </div>
    </div>
  );
}

const slug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');

// ──────────────────────────────────────────────────────────────────────────
function hexToRgba(hex, a) {
  let h = hex.replace('#', '');
  if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
  const r = parseInt(h.slice(0,2),16);
  const g = parseInt(h.slice(2,4),16);
  const b = parseInt(h.slice(4,6),16);
  return `rgba(${r},${g},${b},${a})`;
}
function shiftLightness(hex, pct) {
  let h = hex.replace('#', '');
  if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
  const r = parseInt(h.slice(0,2),16);
  const g = parseInt(h.slice(2,4),16);
  const b = parseInt(h.slice(4,6),16);
  const adjust = (v) => Math.max(0, Math.min(255, v + pct * 255 / 100));
  return `rgb(${adjust(r)},${adjust(g)},${adjust(b)})`;
}

const BOUNDARY_CACHE_BUST = String(Date.now());

function loadBoundaryScript(src, eventName) {
  return new Promise((resolve, reject) => {
    const onReady = (event) => {
      cleanup();
      resolve(event.detail);
    };
    const onError = () => {
      cleanup();
      reject(new Error('Failed to load ' + src));
    };
    const cleanup = () => {
      document.removeEventListener(eventName, onReady);
      script.removeEventListener('error', onError);
    };
    const script = document.createElement('script');
    // Boundary脚本无HTML标签级?v=缓存位，静态服务器无cache头时浏览器启发式缓存会
    // 供旧代码（REV-R1/REV-R2均实测踩坑）。会话级时间戳保证每次启动取新文件；Vite迁移(a-2)后由构建指纹取代。
    script.src = src + (src.indexOf('?') === -1 ? '?b=' : '&b=') + BOUNDARY_CACHE_BUST;
    script.async = false;
    document.addEventListener(eventName, onReady, { once: true });
    script.addEventListener('error', onError);
    document.head.appendChild(script);
  });
}

function bootAxisApp() {
return Promise.all([
  loadBoundaryScript('core/routes/routeTable.js', 'sports-os:route-table-ready'),
  loadBoundaryScript('core/persistence/AppPersistence.js', 'sports-os:app-persistence-ready'),
  loadBoundaryScript('core/context/ContextScope.js', 'sports-os:context-scope-ready'),
  loadBoundaryScript('core/context/ReportDraftAdapter.js', 'sports-os:report-draft-adapter-ready'),
  loadBoundaryScript('core/force/ForceTargetAdapter.js', 'sports-os:force-target-adapter-ready'),
  loadBoundaryScript('core/persistence/ForceSessionStore.js', 'sports-os:force-session-store-ready'),
  loadBoundaryScript('core/repositories/BaseRepository.js', 'sports-os:base-repository-ready'),
  loadBoundaryScript('core/repositories/RepositoryMetadata.js', 'sports-os:repository-metadata-ready'),
  loadBoundaryScript('core/reviews/ReviewEvidenceLifecycle.js', 'sports-os:review-evidence-lifecycle-ready'),
  loadBoundaryScript('core/knowledge/KnowledgeRegistry.js', 'sports-os:knowledge-registry-ready'),
  loadBoundaryScript('core/knowledge/KnowledgeRuleValidator.js', 'sports-os:knowledge-rule-validator-ready'),
  loadBoundaryScript('core/reports/ReviewedEvidenceSummary.js', 'sports-os:reviewed-evidence-summary-ready'),
  loadBoundaryScript('core/monitoring/SJReviewedEvidenceSummary.js', 'sports-os:sj-reviewed-evidence-summary-ready'),
  loadBoundaryScript('core/monitoring/IMTPReviewedEvidenceSummary.js', 'sports-os:imtp-reviewed-evidence-summary-ready'),
  loadBoundaryScript('core/monitoring/FieldTestReviewedEvidenceSummary.js', 'sports-os:field-test-reviewed-evidence-summary-ready'),
  loadBoundaryScript('core/reports/IndividualReportEvidenceSummary.js', 'sports-os:individual-report-evidence-summary-ready'),
  loadBoundaryScript('core/coach/CoachEvidenceSummary.js', 'sports-os:coach-evidence-summary-ready'),
  loadBoundaryScript('core/ui-contract/EvidenceUIContract.js', 'sports-os:evidence-ui-contract-ready'),
  loadBoundaryScript('core/coach/WeeklyCoachReviewedEvidenceSummary.js', 'sports-os:weekly-coach-reviewed-evidence-summary-ready'),
  loadBoundaryScript('core/coach/ReviewedEvidenceRows.js', 'sports-os:reviewed-evidence-rows-ready'),
  loadBoundaryScript('core/story/StoryEvidenceSummary.js', 'sports-os:story-evidence-summary-ready'),
  loadBoundaryScript('core/training/TrainingContextSeed.js', 'sports-os:training-context-seed-ready'),
  loadBoundaryScript('core/repositories/ForceSessionRepository.js', 'sports-os:force-session-repository-ready'),
  loadBoundaryScript('core/repositories/ForceSessionReviewRepository.js', 'sports-os:force-session-review-repository-ready'),
  loadBoundaryScript('core/repositories/ForceReportDraftRepository.js', 'sports-os:force-report-draft-repository-ready'),
  loadBoundaryScript('core/repositories/IndividualReportDraftRepository.js', 'sports-os:individual-report-draft-repository-ready'),
  loadBoundaryScript('core/repositories/AssessmentReviewRepository.js', 'sports-os:assessment-review-repository-ready'),
  loadBoundaryScript('core/repositories/ManualMetricReviewRepository.js', 'sports-os:manual-metric-review-repository-ready'),
  loadBoundaryScript('core/repositories/AthleteRepository.js', 'sports-os:athlete-repository-ready'),
  loadBoundaryScript('core/repositories/AthleteCollectionWriter.js', 'sports-os:athlete-collection-writer-ready'),
  loadBoundaryScript('core/import/LegacyImportPlanner.js', 'sports-os:legacy-import-planner-ready'),
  loadBoundaryScript('core/repositories/LegacyImportRepository.js', 'sports-os:legacy-import-repository-ready'),
  loadBoundaryScript('core/repositories/VizPanelPrefsRepository.js', 'sports-os:viz-panel-prefs-repository-ready'),
  loadBoundaryScript('core/athlete/AthleteCascadeDelete.js', 'sports-os:athlete-cascade-delete-ready'),
  loadBoundaryScript('core/persistence/ForceTraceStore.js', 'sports-os:force-trace-store-ready'),
  loadBoundaryScript('core/repositories/ForceTraceRepository.js', 'sports-os:force-trace-repository-ready'),
  loadBoundaryScript('core/persistence/ForceSourceFileStore.js', 'sports-os:force-source-file-store-ready'),
  loadBoundaryScript('core/repositories/ForceSourceFileRepository.js', 'sports-os:force-source-file-repository-ready'),
  loadBoundaryScript('core/force/ForceTraceSaveSaga.js', 'sports-os:force-trace-save-saga-ready'),
  loadBoundaryScript('core/force/ForceTraceReadModel.js', 'sports-os:force-trace-read-model-ready'),
  loadBoundaryScript('core/force/ForceTraceBackup.js', 'sports-os:force-trace-backup-ready'),
  loadBoundaryScript('core/force/ForceRestoreEngine.js', 'sports-os:force-restore-engine-ready'),
]).then(([routeTable, persistence, contextScopeModule, reportDraftAdapterModule, forceTargetAdapterModule, forceModule, baseRepository, repositoryMetadata, reviewEvidenceLifecycleModule, knowledgeRegistryModule, knowledgeRuleValidatorModule, reviewedEvidenceSummaryModule, sjReviewedEvidenceSummaryModule, imtpReviewedEvidenceSummaryModule, fieldTestReviewedEvidenceSummaryModule, individualReportEvidenceSummaryModule, coachEvidenceSummaryModule, evidenceUIContractModule, weeklyCoachReviewedEvidenceSummaryModule, reviewedEvidenceRowsModule, storyEvidenceSummaryModule, trainingContextSeedModule, forceRepositoryModule, forceReviewRepositoryModule, forceReportDraftRepositoryModule, individualReportDraftRepositoryModule, assessmentReviewRepositoryModule, manualMetricReviewRepositoryModule, athleteRepositoryModule, athleteCollectionWriterModule, legacyImportPlannerModule, legacyImportRepositoryModule, vizPanelPrefsRepositoryModule, athleteCascadeDeleteModule, forceTraceStoreModule, forceTraceRepositoryModule, forceSourceFileStoreModule, forceSourceFileRepositoryModule, forceTraceSaveSagaModule, forceTraceReadModelModule, forceTraceBackupModule, forceRestoreEngineModule]) => {
  const forceSessions = forceModule.createForceSessionStore(persistence);
  const knowledgeRegistry = knowledgeRegistryModule.createKnowledgeRegistry({
    appPersistence: persistence,
    metadata: repositoryMetadata,
    createBaseRepository: baseRepository.createBaseRepository,
  });
  const knowledgeRuleValidator = knowledgeRuleValidatorModule.createKnowledgeRuleValidator();
  const forceSessionRepository = forceRepositoryModule.createForceSessionRepository({
    forceStore: forceSessions,
    metadata: repositoryMetadata,
    configFor: window.forceTestConfig,
    types: window.FORCE_TEST_TYPES || ['cmj', 'sj', 'imtp'],
    createBaseRepository: baseRepository.createBaseRepository,
  });
  const forceSessionReviewRepository = forceReviewRepositoryModule.createForceSessionReviewRepository({
    appPersistence: persistence,
    metadata: repositoryMetadata,
    createBaseRepository: baseRepository.createBaseRepository,
  });
  const forceReportDraftRepository = forceReportDraftRepositoryModule.createForceReportDraftRepository({ appPersistence: persistence });
  window.ForceReportDraftRepo = forceReportDraftRepository;
  const individualReportDraftRepository = individualReportDraftRepositoryModule.createIndividualReportDraftRepository({ appPersistence: persistence });
  const assessmentReviewRepository = assessmentReviewRepositoryModule.createAssessmentReviewRepository({
    appPersistence: persistence,
    metadata: repositoryMetadata,
  });
  const manualMetricReviewRepository = manualMetricReviewRepositoryModule.createManualMetricReviewRepository({
    appPersistence: persistence,
    metadata: repositoryMetadata,
  });
  const athleteRepository = athleteRepositoryModule.createAthleteRepository({
    appPersistence: persistence,
    metadata: repositoryMetadata,
    createBaseRepository: baseRepository.createBaseRepository,
  });
  const athleteCollection = athleteCollectionWriterModule.createAthleteCollectionWriter({
    athleteRepository,
    appPersistence: persistence,
    metadata: repositoryMetadata,
  });
  const legacyImportPlanner = legacyImportPlannerModule.createLegacyImportPlanner();
  const legacyImportRepository = legacyImportRepositoryModule.createLegacyImportRepository({ appPersistence: persistence });
  // VIZ-1 training visualization-panel prefs (local-preference domain). Exposed on
  // window so TrainingView (window.FieldDataStore consumer) can load/save without
  // threading a new prop through the view dispatch chain.
  const vizPanelPrefsRepository = vizPanelPrefsRepositoryModule.createVizPanelPrefsRepository({
    appPersistence: persistence,
    createBaseRepository: baseRepository.createBaseRepository,
  });
  window.VizPanelPrefsRepo = vizPanelPrefsRepository;
  // FORCE-TRACE M2b/M2c: full CMJ force-time traces (IndexedDB) + the capture/lifecycle saga.
  const forceTraceStore = forceTraceStoreModule.createForceTraceStore();
  const forceTraceRepository = forceTraceRepositoryModule.createForceTraceRepository({
    traceStore: forceTraceStore,
    createBaseRepository: baseRepository.createBaseRepository,
  });
  const forceSourceFileStore = forceSourceFileStoreModule.createForceSourceFileStore();
  const forceSourceFileRepository = forceSourceFileRepositoryModule.createForceSourceFileRepository({ store: forceSourceFileStore });
  const forceTraceSaga = forceTraceSaveSagaModule.createForceTraceSaveSaga({ traceRepository: forceTraceRepository });
  // FORCE-TRACE M3-D: Force backup/restore (D3 contract) — journaled replace-all over CMJ/SJ/IMTP
  // sessions + CMJ traces only; crash recovery runs at startup BEFORE the M2 reconcile.
  const forceRestoreEngine = forceRestoreEngineModule.createForceRestoreEngine({
    appPersistence: persistence,
    traceStore: forceTraceStore,
    traceRepository: forceTraceRepository,
    readModel: forceTraceReadModelModule,
    reconcile: window.reconcileForceTraces,
    forcePrefixes: ['cmj', 'sj', 'imtp'].map((t) => ((window.forceTestConfig && window.forceTestConfig(t)) || {}).storeKeyPrefix || (t + '_sess_')),
  });
  const athleteCascadeDeps = {
    appPersistence: persistence,
    athleteRepository,
    forceSessionStore: forceSessions,
    forceTraceStore,
    forceSourceFileStore,
    configFor: window.forceTestConfig,
    get fieldDataStore() { return window.FieldDataStore || null; },
  };
  const athleteCascadeDelete = {
    countAthleteData: (athleteId) => athleteCascadeDeleteModule.countAthleteData(athleteCascadeDeps, athleteId),
    deleteAthleteCascade: (athleteId) => athleteCascadeDeleteModule.deleteAthleteCascade(athleteCascadeDeps, athleteId),
  };
  const knowledgeDocumentStore = window.KnowledgeDocumentStore.createKnowledgeDocumentStore();
  const knowledgeDocumentRepository = window.KnowledgeDocumentRepository.createKnowledgeDocumentRepository({
    store: knowledgeDocumentStore,
    contract: window.KnowledgeDocumentContract,
  });
  const knowledgeReady = knowledgeDocumentRepository.initialize(window.AxisUniversalKnowledgePack)
    .then(() => ({ ok: true }))
    .catch((error) => {
      console.error('Universal knowledge initialization failed:', error);
      return { ok: false, error };
    });
  const rawKnowledgeRetrieval = window.KnowledgeRetrieval.createKnowledgeRetrieval({
    repository: knowledgeDocumentRepository,
  });
  const knowledgeRetrieval = Object.freeze({
    search: async (request) => {
      const ready = await knowledgeReady;
      if (!ready.ok) throw ready.error;
      return rawKnowledgeRetrieval.search(request);
    },
  });
  const aiActionStore = window.AiActionStore.createAiActionStore();
  const aiActionRepository = window.AiActionRepository.createAiActionRepository({
    store: aiActionStore,
    contract: window.AiActionProposalContract,
  });
  const confirmedActionExecutor = window.ConfirmedActionExecutor.createConfirmedActionExecutor({
    repository: aiActionRepository,
    actionAdapters: {
      report_draft: async ({ target, payload, expectedTargetRevision, idempotencyKey, sourceDecisionCaseId }) => {
        const scope = { athleteId: target.athleteId, season: target.season };
        const existing = individualReportDraftRepository.load(scope);
        const currentRevision = existing ? String(existing.updatedAt || '') : '';
        if (expectedTargetRevision != null && String(expectedTargetRevision) !== currentRevision) {
          throw new Error('Report draft revision conflict.');
        }
        const saved = individualReportDraftRepository.save(scope, {
          updatedAt: new Date().toISOString(),
          config: {
            ...(existing?.config || {}),
            ...(payload.config || {}),
            aiDraftMeta: { idempotencyKey, sourceDecisionCaseId },
          },
        });
        return { persisted: true, recordId: individualReportDraftRepository.scopeKey(scope), revision: saved.updatedAt };
      },
      training_plan_draft: async ({ target, payload, expectedTargetRevision, idempotencyKey, sourceDecisionCaseId }) => {
        const fieldStore = window.FieldDataStore;
        if (!fieldStore?.saveProgram || !fieldStore?.getProgram) throw new Error('Training repository is unavailable.');
        await fieldStore.init('default');
        const supplied = payload.program && typeof payload.program === 'object' ? payload.program : {};
        const existing = supplied.id ? await fieldStore.getProgram(supplied.id) : null;
        const currentRevision = existing ? String(existing.updatedAt || '') : '';
        if (expectedTargetRevision != null && String(expectedTargetRevision) !== currentRevision) {
          throw new Error('Training draft revision conflict.');
        }
        const saved = await fieldStore.saveProgram({
          ...supplied,
          name: payload.name || supplied.name,
          mode: supplied.mode || 'individual',
          athleteId: target.athleteId,
          athleteIds: [],
          status: 'draft',
          sessions: Array.isArray(supplied.sessions) ? supplied.sessions : [],
          aiDraftMeta: { idempotencyKey, sourceDecisionCaseId },
        });
        return { persisted: true, recordId: saved.id, revision: String(saved.updatedAt || '') };
      },
    },
  });
  ReactDOM.createRoot(document.getElementById('root')).render(
    <App
      routeTable={routeTable}
      persistence={persistence}
      repositories={{
        forceSessions: forceSessionRepository,
        forceSessionReviews: forceSessionReviewRepository,
        assessmentReviews: assessmentReviewRepository,
        manualMetricReviews: manualMetricReviewRepository,
        reviewEvidenceLifecycle: reviewEvidenceLifecycleModule,
        contextScope: contextScopeModule,
        reportDraftAdapter: reportDraftAdapterModule,
        forceTargetAdapter: forceTargetAdapterModule,
        individualReportDrafts: individualReportDraftRepository,
        knowledgeRegistry,
        knowledgeRuleValidator,
        reviewedEvidenceSummary: reviewedEvidenceSummaryModule,
        sjReviewedEvidenceSummary: sjReviewedEvidenceSummaryModule,
        imtpReviewedEvidenceSummary: imtpReviewedEvidenceSummaryModule,
        fieldTestReviewedEvidenceSummary: fieldTestReviewedEvidenceSummaryModule,
        individualReportEvidenceSummary: individualReportEvidenceSummaryModule,
        coachEvidenceSummary: coachEvidenceSummaryModule,
        evidenceUIContract: evidenceUIContractModule,
        weeklyCoachReviewedEvidenceSummary: weeklyCoachReviewedEvidenceSummaryModule,
        reviewedEvidenceRows: reviewedEvidenceRowsModule,
        storyEvidenceSummary: storyEvidenceSummaryModule,
        trainingContextSeed: trainingContextSeedModule,
        athletes: athleteRepository,
        athleteCollection,
        legacyImportPlanner,
        legacyImportRepository,
        athleteCascadeDelete,
        forceTraces: forceTraceRepository,
        forceSourceFiles: forceSourceFileRepository,
        forceTraceSaga,
        forceTraceReadModel: forceTraceReadModelModule,
        forceTraceBackup: forceTraceBackupModule,
        forceRestoreEngine,
        knowledgeDocuments: knowledgeDocumentRepository,
        knowledgeRetrieval,
        knowledgeReady,
        knowledgeOwnerId: 'local-workspace-owner',
        confirmedActionExecutor,
      }}
    />
  );
}).catch((err) => {
  console.error('App boundary modules failed to load:', err);
});
}

if (!window.AxisAuthBootstrap) {
  document.getElementById('root').textContent = 'Axis authentication boundary failed to load.';
  throw new Error('AxisAuthBootstrap is required.');
}
window.AxisAuthBootstrap.start({ onAuthenticated: bootAxisApp });
