// training.jsx  v1 — 训练计划模块（方案 B：编排画布 + 执行卡片）
// 数据层：window.FieldDataStore（exercises / programs / trainingLogs，IndexedDB，离线优先、留云同步）。
// 结构：TrainingView（壳）→ ProgramList / ProgramBuilder / ProgramExecution + ExerciseLibrary + Calculators。
// 文案 English-first，留 i18n 中文译名路径。

const { useState: trS, useEffect: trE, useMemo: trM, useRef: trR } = React;

// ── helpers ───────────────────────────────────────────────────────────────
const TR_GROUP_TYPES = [
  { id: 'straight', label: 'Straight 常规' },
  { id: 'superset', label: 'Superset 超级组' },
  { id: 'cluster',  label: 'Cluster 集群组' },
  { id: 'custom',   label: 'Custom 自定义' },
];
const TR_GROUP_LABELS = ['A', 'B', 'C', 'D', 'E', 'F'];

function trUid(p) {
  return (p || '') + (crypto?.randomUUID ? crypto.randomUUID() : Date.now().toString(36) + Math.random().toString(36).slice(2, 8));
}
function trToday() { return new Date().toISOString().slice(0, 10); }

// Block-sequence letter badge (A, B, C… Z, AA, AB…) for a block's position within its session's block list.
// Pure index→letter mapping; does not read/alter block order or identity.
function trBlockLetter(index) {
  let n = index, s = '';
  do { s = String.fromCharCode(65 + (n % 26)) + s; n = Math.floor(n / 26) - 1; } while (n >= 0);
  return s;
}

// Shared 组×次 · 负荷 annotation for a block header, used identically by ProgramBuilder (SessionEditor/
// BlockEditor) and ProgramExecution. Reads only the block's first set (or a same-shape summary) — no
// invented values: parts with no data are simply omitted, and '' is returned when nothing is available.
function trBlockAnnotation(block) {
  const sets = (block && block.sets) || [];
  if (!sets.length) return '';
  const s0 = sets[0];
  const parts = [];
  if (s0.reps != null && s0.reps !== '') parts.push(`${sets.length}×${s0.reps}`);
  else parts.push(`${sets.length}×`);
  if (s0.weight != null && s0.weight !== '') parts.push(`${s0.weight}kg`);
  else if (s0.pct1rm != null && s0.pct1rm !== '') parts.push(`${s0.pct1rm}%1RM`);
  return parts.join(' · ');
}
function trPrescriptionExtras(set) {
  const parts = [];
  if (set?.tempo) parts.push(`Tempo ${set.tempo}`);
  if (set?.restSec !== '' && set?.restSec != null) parts.push(`Rest ${set.restSec}s`);
  if (set?.targetVelocityMS !== '' && set?.targetVelocityMS != null) parts.push(`VBT ${set.targetVelocityMS}m/s`);
  if (set?.velocityLossPct !== '' && set?.velocityLossPct != null) parts.push(`VL≤${set.velocityLossPct}%`);
  if (set?.note) parts.push(`Note ${set.note}`);
  return parts;
}

// Resolve an athlete's known 1RM (kg) for an exercise's oneRMKey from the test DB (latest season value).
function resolveOneRM(athlete, oneRMKey, seasons) {
  if (!athlete || !oneRMKey) return null;
  const dates = [...(seasons || Object.keys(athlete.seasons || {}))].sort().reverse();
  for (const d of dates) {
    const v = (athlete.seasons || {})[d]?.[oneRMKey];
    if (v != null && isFinite(+v)) return +v;
  }
  return null;
}

// Aliases: default-library names → their equivalent rung in a C-PS ladder (so regress/progress
// covers the built-in library, whose names differ slightly from the ladder's). Non-destructive.
const TR_LADDER_ALIASES = {
  'back squat': 'barbell back squat', 'front squat': 'barbell front squat',
  'deadlift': 'conventional dl', 'trap bar deadlift': 'trap bar dl', 'hip thrust': 'barbell bench hip thrust',
  'bench press': 'bb bench press', 'overhead press': 'barbell oh press',
  'barbell row': 'barbell bent row', 'lat pulldown': 'bilateral pulldown',
  'bulgarian split squat': 'rfe split squat', 'step-up': 'weighted step up', 'walking lunge': 'forward lunge',
  'plank': 'full plank', 'ab wheel rollout': 'roll-outs',
};
// Find the C-PS progression ladder containing an exercise name (direct match, then alias).
// Returns { ladder, idx, pattern } or null. Powers the block regress/progress (↓/↑) buttons.
function findProgression(name) {
  const user = window.USER_PROGRESSIONS || [];                                 // user-defined ladders take precedence
  const cps = (window.CPS_KNOWLEDGE && window.CPS_KNOWLEDGE.progressions) || [];
  const progs = [...user, ...cps];
  const n = String(name || '').trim().toLowerCase();
  if (!n) return null;
  const search = (q) => {
    for (const p of progs) {
      const idx = (p.ladder || []).findIndex(x => String(x).toLowerCase() === q);
      if (idx >= 0) return { ladder: p.ladder, idx, pattern: p.pattern || p.name };
    }
    return null;
  };
  return search(n) || (TR_LADDER_ALIASES[n] ? search(TR_LADDER_ALIASES[n]) : null);
}

const trInput = {
  width: '100%', padding: '6px 8px', background: 'var(--panel)', border: '1px solid var(--border)',
  borderRadius: 4, color: 'var(--text)', font: '13px var(--font-sans)', outline: 'none',
};
const trCell = { ...trInput, padding: '5px 6px', textAlign: 'center', fontFamily: 'var(--font-mono)' };

function TrLabel({ children, style }) {
  return <div style={{ fontSize: 9.5, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.1em', fontWeight: 600, ...style }}>{children}</div>;
}

// ── Export / Print ──────────────────────────────────────────────────────────
const _esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
function _prescWeight(set, oneRM) {
  if (set.weight != null && set.weight !== '') return +set.weight;
  if (set.pct1rm && oneRM) return Math.round((+set.pct1rm / 100) * oneRM);
  return '';
}
function _safeName(s) { return String(s || 'program').replace(/[^\w一-龥-]+/g, '_'); }

// Export a program to an Excel-openable .xls (HTML-table; opens in Excel/WPS with formatting).
function exportProgramExcel(program, refAthlete, seasons) {
  const head = ['Session 训练日', 'Week 周', 'Group 组别', 'Exercise 动作', 'Set 组', 'Weight(kg) 重量', '%1RM', 'Reps 次', 'RIR', 'RPE', 'Tempo', 'Rest(s) 休息', 'VBT Target(m/s)', 'Velocity Loss(%)', 'Note 备注'];
  let body = '';
  (program.sessions || []).forEach(s => (s.blocks || []).forEach(b => {
    const oneRM = resolveOneRM(refAthlete, b.oneRMKey, seasons);
    (b.sets || []).forEach((set, i) => {
      const cells = [s.name, s.week || '', b.group || '', b.exerciseName, i + 1, _prescWeight(set, oneRM), set.pct1rm || '', set.reps || '', set.rir || '', set.rpe || '', set.tempo || '', set.restSec ?? '', set.targetVelocityMS ?? '', set.velocityLossPct ?? '', set.note || ''];
      body += '<tr>' + cells.map(c => `<td>${_esc(c)}</td>`).join('') + '</tr>';
    });
  }));
  const doc = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel"><head><meta charset="utf-8"></head><body>`
    + `<h3>${_esc(program.name)}</h3><div>${program.mode === 'team' ? 'Team' : 'Individual'} · ${_esc(refAthlete?.name || '')} · ${program.date || ''}</div>`
    + `<table border="1" cellspacing="0"><tr>${head.map(h => `<th>${_esc(h)}</th>`).join('')}</tr>${body}</table></body></html>`;
  const blob = new Blob(['﻿' + doc], { type: 'application/vnd.ms-excel' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a'); a.href = url; a.download = `${_safeName(program.name)}.xls`;
  document.body.appendChild(a); a.click(); a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 1500);
}

// Print a clean training card (one block per row, prescribed + blank "actual" columns to fill on the floor).
function printProgram(program, refAthlete, seasons, athleteName) {
  const w = window.open('', '_blank');
  if (!w) { window.alert('请允许弹出窗口以打印训练卡'); return; }
  let sessionsHtml = '';
  (program.sessions || []).forEach(s => {
    let rows = '';
    (s.blocks || []).forEach(b => {
      const oneRM = resolveOneRM(refAthlete, b.oneRMKey, seasons);
      (b.sets || []).forEach((set, i) => {
        const extras = trPrescriptionExtras(set);
        const presc = `${_prescWeight(set, oneRM) || '—'}${set.pct1rm ? ` (${set.pct1rm}%)` : ''} × ${set.reps || '—'}${set.rir !== '' && set.rir != null ? ` · RIR${set.rir}` : ''}${extras.length ? ` · ${extras.join(' · ')}` : ''}`;
        rows += `<tr><td>${i === 0 ? `<b>${_esc(b.group ? b.group + ' ' : '')}${_esc(b.exerciseName)}</b>` : ''}</td><td>${i + 1}</td><td>${_esc(presc)}</td><td class="blank"></td><td class="blank"></td><td class="blank"></td></tr>`;
      });
    });
    sessionsHtml += `<h2>${_esc(s.name)}${s.week ? ` · Wk${s.week}` : ''}</h2><table><tr><th>动作 Exercise</th><th>组</th><th>处方 Prescribed</th><th>实际 kg</th><th>实际 reps</th><th>RIR</th></tr>${rows}</table>`;
  });
  const doc = `<html><head><meta charset="utf-8"><title>${_esc(program.name)} — 训练卡</title><style>
    body{font-family:-apple-system,"Segoe UI",sans-serif;color:#1c2433;padding:24px;}
    h1{font-size:20px;margin:0 0 2px} .meta{color:#6b7287;font-size:13px;margin-bottom:16px}
    h2{font-size:14px;margin:18px 0 6px;border-bottom:2px solid #1c2433;padding-bottom:3px}
    table{border-collapse:collapse;width:100%;margin-bottom:8px;font-size:12px}
    th,td{border:1px solid #c9cfd9;padding:5px 8px;text-align:left} th{background:#f0f0ec;font-size:10px;text-transform:uppercase}
    td.blank{background:#fff;min-width:54px} @media print{@page{margin:14mm}}
  </style></head><body>
    <h1>${_esc(program.name)} — 训练卡 Training Card</h1>
    <div class="meta">${program.mode === 'team' ? 'Team 团队' : 'Individual 个人'} · ${_esc(athleteName || refAthlete?.name || '')} · ${program.date || ''}　　运动员签名 ______　日期 ______</div>
    ${sessionsHtml}
  </body></html>`;
  w.document.write(doc); w.document.close(); w.focus();
  setTimeout(() => w.print(), 300);
}

function formatTrainingContextDate(value) {
  if (!value) return '—';
  const date = new Date(value);
  if (Number.isNaN(date.getTime())) return String(value).slice(0, 10) || '—';
  return date.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}

function TrainingPlanningContext({ context, onOpenReview, onOpenReport }) {
  if (!context) return null;
  const reviewed = context.reviewedStatus === 'reviewed';
  return (
    <div className="tr-card" style={{ margin: '12px 20px 0', padding: '10px 12px', display: 'grid', gap: 8 }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
        <div>
          <div style={{ fontSize: 10, color: 'var(--muted-2)', textTransform: 'uppercase', letterSpacing: '.08em', fontWeight: 700 }}>Planning Context</div>
          <div style={{ fontSize: 13, color: 'var(--text)', fontWeight: 650 }}>{context.athleteName}</div>
        </div>
        <span style={{
          fontSize: 9.5,
          color: reviewed ? 'var(--pos)' : 'var(--muted)',
          border: '1px solid var(--border)',
          borderRadius: 999,
          padding: '2px 7px',
          textTransform: 'uppercase',
        }}>{reviewed ? 'Reviewed' : 'Not Reviewed'}</span>
      </div>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 10, fontSize: 11, color: 'var(--muted)' }}>
        <div><strong style={{ color: 'var(--text)' }}>Sports Scientist Conclusion</strong><br/>{context.acceptedClassification}</div>
        <div><strong style={{ color: 'var(--text)' }}>Reviewer Training Focus</strong><br/>{context.reviewerTrainingFocus}</div>
        <div><strong style={{ color: 'var(--text)' }}>Reviewed session date</strong><br/>{String(context.sessionDate || '—').slice(0, 10)}</div>
        <div><strong style={{ color: 'var(--text)' }}>Last reviewed</strong><br/>{formatTrainingContextDate(context.lastReviewedAt)}</div>
      </div>
      <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
        {onOpenReview && onOpenReview === onOpenReport ? (
          <button type="button" className="btn" disabled={!context.sessionExists} style={{ fontSize: 11, padding: '4px 8px' }} onClick={() => onOpenReport?.(context)}>Open Review / Report</button>
        ) : (
          <>
            <button type="button" className="btn" disabled={!context.sessionExists} style={{ fontSize: 11, padding: '4px 8px' }} onClick={() => onOpenReview?.(context)}>Open Review</button>
            <button type="button" className="btn" disabled={!context.sessionExists} style={{ fontSize: 11, padding: '4px 8px' }} onClick={() => onOpenReport?.(context)}>Open Report</button>
          </>
        )}
      </div>
    </div>
  );
}

// ── TrainingView plan-summary support ──────────────────────────────────────
function TrainingPlanOverview({ programs = [], athleteId, fromDate, toDate }) {
  const FS = window.FieldDataStore;
  const [logs, setLogs] = trS([]);
  trE(() => {
    let alive = true;
    (async () => {
      if (!FS || !athleteId) { setLogs([]); return; }
      try {
        const rows = await FS.listTrainingLogsByAthlete(athleteId, fromDate, toDate);
        if (alive) setLogs(rows || []);
      } catch (error) {
        if (alive) setLogs([]);
      }
    })();
    return () => { alive = false; };
  }, [FS, athleteId, fromDate, toDate]);

  const individualPrograms = programs.filter(program => program.mode !== 'team').length;
  const teamPrograms = programs.length - individualPrograms;
  let completedSets = 0;
  let totalSets = 0;
  logs.forEach(log => Object.values(log.actuals || {}).forEach(rows => (rows || []).forEach(row => {
    totalSets += 1;
    if (row.done) completedSets += 1;
  })));
  const completion = totalSets ? Math.round(completedSets / totalSets * 100) : null;
  const recentlyUpdated = programs.filter(program => {
    const timestamp = Date.parse(program.updatedAt || program.savedAt || '');
    return Number.isFinite(timestamp) && Date.now() - timestamp <= 7 * 86400000;
  }).length;
  const stats = [
    { label: '有效计划', value: programs.length, note: `个人 ${individualPrograms} · 团队 ${teamPrograms}` },
    { label: '本周执行', value: logs.length, note: `${fromDate} – ${toDate}` },
    { label: '完成率', value: completion == null ? '—' : `${completion}%`, note: totalSets ? `${completedSets}/${totalSets} 个已记录训练组` : '尚无可计算训练组' },
    { label: '最近更新', value: recentlyUpdated || '—', note: recentlyUpdated ? '过去 7 天更新的计划' : '暂无时间戳记录' },
  ];
  return (
    <section className="training-plan-kpis" aria-label="计划库概览">
      {stats.map(stat => (
        <article key={stat.label} className="training-plan-kpi">
          <span>{stat.label}</span>
          <strong>{stat.value}</strong>
          <small>{stat.note}</small>
        </article>
      ))}
    </section>
  );
}

// ── TrainingView (shell) ────────────────────────────────────────────────────
function TrainingView({ athletes = [], groups = [], seasons = [], patchAthleteMetrics, selectedAthleteId = null, initialProgramId = null, onConsumeInitialProgram, trainingContextSeed = null, confirmedResultRows = [], onOpenTrainingContextReview = null, onOpenTrainingContextReport = null }) {
  const FS = window.FieldDataStore;
  const [dbReady, setDbReady] = trS(null);
  const [programs, setPrograms] = trS([]);
  const [exercises, setExercises] = trS([]);
  const [mode, setMode] = trS('list');          // 'list' | 'build' | 'exec'
  const [editing, setEditing] = trS(null);       // program being built/edited (null = new)
  const [execCtx, setExecCtx] = trS(null);       // { program, athleteId, date }
  const [workspaceTab, setWorkspaceTab] = trS('plans');
  // Landing-level shared state (TR-UI2): week summary + week calendar share one
  // selected athlete and one week range. weekOffset = 0 → this week, ±1 → adjacent.
  const [landingAthleteId, setLandingAthleteId] = trS(selectedAthleteId || athletes[0]?.id || '');
  const [weekOffset, setWeekOffset] = trS(0);
  // Training Data period (TR-UI3): independent of the week calendar's weekOffset.
  const [periodDays, setPeriodDays] = trS(7);
  // VIZ-1 right-side visualization panel prefs (panelOpen + ordered module ids),
  // persisted per panel domain via window.VizPanelPrefsRepo (local-preference
  // domain). Read once on mount; every change saves back immediately.
  const vizPrefsRepo = trM(() => window.VizPanelPrefsRepo || null, []);
  const [vizPrefs, setVizPrefs] = trS(() =>
    vizPrefsRepo ? vizPrefsRepo.load('training') : { panelOpen: true, modules: ['week-load-heat', 'rpe-trend-14d', 'plan-structure'] });
  const updateVizPrefs = React.useCallback((next) => {
    setVizPrefs(prev => {
      const merged = typeof next === 'function' ? next(prev) : next;
      if (vizPrefsRepo) vizPrefsRepo.save('training', merged);
      return merged;
    });
  }, [vizPrefsRepo]);

  // Follow the global roster selection while preserving a valid local fallback.
  trE(() => {
    if (!athletes.length) { setLandingAthleteId(''); return; }
    if (selectedAthleteId && athletes.some(a => a.id === selectedAthleteId)) {
      setLandingAthleteId(selectedAthleteId);
      return;
    }
    setLandingAthleteId(current => athletes.some(a => a.id === current) ? current : athletes[0].id);
  }, [athletes, selectedAthleteId]);

  const weekRange = trM(() => weekMondayToSunday(weekOffset), [weekOffset]);
  const periodRangeVal = trM(() => periodRange(periodDays), [periodDays]);

  const refresh = React.useCallback(async () => {
    if (!FS) return;
    try {
      setPrograms(await FS.listPrograms());
      setExercises(await FS.listExercises());
      if (FS.listProgressions) window.USER_PROGRESSIONS = await FS.listProgressions();
    } catch (e) { console.warn('training refresh failed', e); }
  }, [FS]);

  trE(() => {
    let alive = true;
    (async () => {
      const ok = FS ? await FS.healthCheck() : false;
      if (!alive) return;
      setDbReady(ok);
      if (ok) { await FS.init('default'); if (alive) await refresh(); }
    })();
    return () => { alive = false; };
  }, [FS, refresh]);

  // Calendar → open a specific program in the builder (once programs have loaded)
  trE(() => {
    if (!initialProgramId) return;
    const p = programs.find(x => x.id === initialProgramId);
    if (p) { setEditing(p); setMode('build'); onConsumeInitialProgram && onConsumeInitialProgram(); }
  }, [initialProgramId, programs]); // eslint-disable-line react-hooks/exhaustive-deps

  // re-pull the library when the C-PS seeder finishes (handles first-load mid-seed race)
  trE(() => {
    const onSeeded = () => refresh();
    window.addEventListener('cps-seeded', onSeeded);
    return () => window.removeEventListener('cps-seeded', onSeeded);
  }, [refresh]);

  const saveProgram = async (draft) => {
    const p = await FS.saveProgram(draft);
    await refresh();
    setMode('list'); setWorkspaceTab('plans'); setEditing(null);
    return p;
  };
  // Persist a program edit (e.g. session linkedEvidenceRefs) without leaving the current view.
  const persistProgram = async (draft) => {
    const p = await FS.saveProgram(draft);
    await refresh();
    return p;
  };
  const deleteProgram = async (id) => {
    if (!window.confirm('删除该训练计划及其所有执行记录？此操作不可撤销。')) return;
    await FS.deleteProgram(id);
    await refresh();
  };
  const openExec = (program, athleteId, date) => {
    setExecCtx({ program, athlete: athletes.find(a => a.id === athleteId), date: date || trToday() });
    setMode('exec');
  };
  const openWorkspace = (tab) => {
    setMode('list');
    setEditing(null);
    setExecCtx(null);
    setWorkspaceTab(tab);
  };

  let body;
  if (mode === 'build') {
    body = <ProgramBuilder existing={editing} exercises={exercises} athletes={athletes} seasons={seasons}
              onSave={saveProgram} onCancel={() => openWorkspace('plans')}
              onOpenLibrary={() => openWorkspace('library')} onOpenProgression={() => openWorkspace('progressions')}
              onExecute={() => { if (editing) { setExecCtx({ program: editing, athlete: athletes.find(a => a.id === (editing.mode === 'team' ? editing.athleteIds?.[0] : editing.athleteId)), date: trToday() }); setMode('exec'); } }} />;
  } else if (mode === 'exec' && execCtx) {
    body = <ProgramExecution {...execCtx} exercises={exercises} seasons={seasons}
              trainingContextSeed={trainingContextSeed} confirmedResultRows={confirmedResultRows}
              patchAthleteMetrics={patchAthleteMetrics}
              onPersistProgram={persistProgram}
              onBack={() => { setMode('list'); setExecCtx(null); }} />;
  } else {
    body = workspaceTab === 'records' ? (
      <div className="training-workflow-page training-records-page" data-training-workflow-page="records">
        <div className="training-workflow-lead">
          <div><span className="training-eyebrow">TRAINING LOG</span><h2>计划记录</h2><p>按运动员、计划和日期回看真实执行；无记录不等于休息。</p></div>
          <input className="training-date-control" type="date" defaultValue={trToday()} aria-label="训练记录截止日期" />
        </div>
        <TrainingRecentRecords athleteId={landingAthleteId} programs={programs} athletes={athletes} onOpenExec={openExec} />
      </div>
    ) : workspaceTab === 'library' ? (
      <ExerciseLibrary embedded FS={FS} exercises={exercises} onChange={refresh} />
    ) : workspaceTab === 'progressions' ? (
      <ProgressionManager embedded FS={FS} exercises={exercises} onChange={refresh} />
    ) : workspaceTab === 'calculator' ? (
      <CalculatorModal embedded />
    ) : (
      <div className="training-workflow-page training-plan-page" data-training-workflow-page="plans">
        <div className="training-workflow-lead">
          <div><span className="training-eyebrow">PROGRAMS</span><h2>计划库</h2><p>先看范围、对象与最近执行，再决定编辑计划或开始记录。</p></div>
          <button className="btn primary" onClick={() => { setEditing(null); setMode('build'); }}>＋ 新建计划</button>
        </div>
        <TrainingPlanOverview programs={programs} athleteId={landingAthleteId} fromDate={weekRange.fromDate} toDate={weekRange.toDate} />
        <section className="training-plan-library-surface" data-training-program-library>
          <ProgramList programs={programs} athletes={athletes}
            onEdit={(p) => { setEditing(p); setMode('build'); }}
            onExec={(p, athleteId) => openExec(p, athleteId, trToday())}
            onDelete={deleteProgram} />
        </section>
        <details className="training-evidence-surface">
          <summary className="training-evidence-heading">
            <div><h2>执行与趋势</h2><p>展开查看本周记录、真实负荷和趋势；缺失数据不会被解释为训练事实。</p></div>
            <span>展开</span>
          </summary>
          <div className="training-evidence-body">
            <TrainingPlanningContext context={trainingContextSeed} onOpenReview={onOpenTrainingContextReview} onOpenReport={onOpenTrainingContextReport} />
            <TrainingWeekSummaryCard athletes={athletes} athleteId={landingAthleteId} onAthleteChange={setLandingAthleteId} fromDate={weekRange.fromDate} toDate={weekRange.toDate} />
            <div className="tr-viz-layout" data-training-landing-layout>
              <div className="tr-viz-content training-landing-content" style={{ display: 'flex', flexDirection: 'column' }}>
                <div className="tr-card training-today-card" data-training-today-launcher style={{ margin: '14px 20px 0', padding: '12px 14px' }}>
                  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: programs.length ? 9 : 0 }}>
                    <div><div className="tr-card-title">今日执行</div><div style={{ marginTop: 2, fontSize: 11, color: 'var(--muted)' }}>从已有计划直接开始记录；不推测计划日期。</div></div>
                  </div>
                  {programs.length > 0 && <div className="training-launch-grid" style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
                    {programs.map(program => {
                      const athleteId = program.mode === 'team' ? program.athleteIds?.[0] : program.athleteId;
                      return <button key={program.id} className="btn primary training-launch-button" disabled={!athleteId} onClick={() => athleteId && openExec(program, athleteId, trToday())}>开始 · {program.name}</button>;
                    })}
                  </div>}
                </div>
                <TrainingWeekCalendar athletes={athletes} athleteId={landingAthleteId} fromDate={weekRange.fromDate} toDate={weekRange.toDate} weekOffset={weekOffset}
                  programs={programs} onPrevWeek={() => setWeekOffset(o => o - 1)} onNextWeek={() => setWeekOffset(o => o + 1)} onOpenExec={openExec} />
                <TrainingPeriodStatsCard athletes={athletes} athleteId={landingAthleteId} fromDate={periodRangeVal.fromDate} toDate={periodRangeVal.toDate}
                  periodDays={periodDays} onPeriodChange={setPeriodDays} />
              </div>
              <TrainingVizPanel athleteId={landingAthleteId} weekRange={weekRange} programs={programs} prefs={vizPrefs} onChangePrefs={updateVizPrefs} />
            </div>
          </div>
        </details>
      </div>
    );
  }

  return (
    <main className="training-workbench" style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', overflow: 'auto', background: 'var(--bg)' }}>
      <div className="training-workbench-head">
        <div>
          <div className="training-workbench-title">训练工作流</div>
          <div className="training-workbench-subtitle">从计划设计到执行记录，所有资源保持在同一条工作路径中。</div>
        </div>
        <nav className="training-workflow-tabs" data-training-workflow-tabs aria-label="训练工作流">
          <button className={mode === 'list' && workspaceTab === 'plans' ? 'on' : ''} onClick={() => openWorkspace('plans')}>计划库</button>
          <button className={mode === 'build' ? 'on' : ''} onClick={() => { setEditing(null); setMode('build'); }}>新建计划</button>
          <button data-training-records-entry className={mode === 'list' && workspaceTab === 'records' ? 'on' : ''} onClick={() => openWorkspace('records')}>计划记录</button>
          <button className={mode === 'list' && workspaceTab === 'library' ? 'on' : ''} onClick={() => openWorkspace('library')}>动作库</button>
          <button className={mode === 'list' && workspaceTab === 'progressions' ? 'on' : ''} onClick={() => openWorkspace('progressions')}>进阶阶梯</button>
          <button className={mode === 'list' && workspaceTab === 'calculator' ? 'on' : ''} onClick={() => openWorkspace('calculator')}>计算器</button>
        </nav>
        <span className="training-db-state" title={dbReady ? '本地数据库就绪' : '本地存储状态'} aria-label="本地数据库状态" style={{ color: dbReady ? 'var(--pos)' : 'var(--muted)' }}>{dbReady == null ? '…' : dbReady ? '●' : '○'}</span>
      </div>
      {body}
    </main>
  );
}

// ── TrainingWeekCalendar (TR-UI2) ───────────────────────────────────────────
// Mon–Sun day cards for the selected athlete's week. Facts only: each card shows
// the day's *execution records* (trainingLogs — the only date-bearing data) and
// their feedback status. Program sessions are plan slots without calendar dates,
// so we never invent a plan→date mapping. A day with no log is shown as neutral
// "无记录 / No record" (we don't know if it was rest or simply unrecorded).
const TR_WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const TR_WEEKDAYS_CN = ['一', '二', '三', '四', '五', '六', '日'];

function TrainingWeekCalendar({ athletes = [], athleteId, fromDate, toDate, weekOffset = 0, programs = [], onPrevWeek, onNextWeek, onOpenExec }) {
  const FS = window.FieldDataStore;
  const [logsByDate, setLogsByDate] = trS({});
  const today = trM(() => trToday(), []);

  trE(() => {
    let alive = true;
    (async () => {
      if (!FS || !athleteId) { setLogsByDate({}); return; }
      try {
        const logs = await FS.listTrainingLogsByAthlete(athleteId, fromDate, toDate);
        if (!alive) return;
        const by = {};
        (logs || []).forEach(l => { (by[l.date] = by[l.date] || []).push(l); });
        setLogsByDate(by);
      } catch (e) { if (alive) setLogsByDate({}); }
    })();
    return () => { alive = false; };
  }, [FS, athleteId, fromDate, toDate]);

  // 7 local dates Mon→Sun for the visible week (derived from fromDate string).
  const days = trM(() => {
    const [y, m, d] = String(fromDate || '').split('-').map(Number);
    if (!y) return [];
    const start = new Date(y, m - 1, d);
    return Array.from({ length: 7 }, (_, i) => trFmtLocalDate(new Date(start.getFullYear(), start.getMonth(), start.getDate() + i)));
  }, [fromDate]);

  if (!athletes.length) return null;
  const progById = {};
  (programs || []).forEach(p => { progById[p.id] = p; });

  // Aggregate one date's logs into display facts (records count, feedback presence, first RPE).
  const dayFacts = (date) => {
    const logs = logsByDate[date] || [];
    let anyFeedback = false, rpe = null, execLog = null;
    logs.forEach(l => {
      const fbs = l.sessionFeedback ? Object.values(l.sessionFeedback) : [];
      if (fbs.length) {
        anyFeedback = true;
        for (const fb of fbs) { if (fb && fb.sessionRPE != null && rpe == null) rpe = fb.sessionRPE; }
      }
      // Prefer the first log whose program still exists as the click target.
      if (!execLog && progById[l.programId]) execLog = l;
      else if (!execLog) execLog = l;
    });
    return { logs, recordCount: logs.length, hasRecord: logs.length > 0, anyFeedback, rpe, execLog };
  };

  return (
    <div className="training-week-calendar" style={{ margin: '12px 20px 0' }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
        <div style={{ fontSize: 10, color: 'var(--mac-muted, var(--muted-2))', textTransform: 'uppercase', letterSpacing: '.08em', fontWeight: 700 }}>
          Week Calendar · 本周日历
        </div>
        <div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 6 }}>
          <button type="button" className="btn" title="上一周 Previous week" style={{ fontSize: 12, padding: '2px 9px' }} onClick={onPrevWeek}>‹</button>
          <span style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'var(--font-mono)', minWidth: 150, textAlign: 'center' }}>
            {fromDate} – {toDate}{weekOffset === 0 ? '（本周）' : ''}
          </span>
          <button type="button" className="btn" title="下一周 Next week" style={{ fontSize: 12, padding: '2px 9px' }} onClick={onNextWeek}>›</button>
        </div>
      </div>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(96px, 1fr))', gap: 8, overflowX: 'auto' }}>
        {days.map((date, i) => {
          const f = dayFacts(date);
          const isToday = date === today;
          const dayNum = date.slice(8);
          const targetProgram = f.execLog ? progById[f.execLog.programId] : null;
          const missingProgram = f.hasRecord && !targetProgram;
          const clickable = f.hasRecord && !!targetProgram;
          const open = () => { if (clickable) onOpenExec && onOpenExec(targetProgram, f.execLog.athleteId, date); };
          return (
            <div key={date}
              className="tr-card tr-daycard"
              onClick={open}
              title={missingProgram ? '计划已删除，无法打开执行页 Program deleted' : (clickable ? '打开该日执行记录 Open records' : undefined)}
              style={{
                cursor: clickable ? 'pointer' : 'default',
                opacity: missingProgram ? 0.6 : 1,
                borderColor: isToday ? 'var(--mac-blue, var(--accent))' : undefined,
                boxShadow: isToday ? '0 0 0 1px var(--mac-blue, var(--accent))' : undefined,
              }}>
              <div className="tr-daycard-head">
                <span className="tr-daycard-dow">{TR_WEEKDAYS[i]}<span style={{ fontSize: 9, color: 'var(--muted-2)' }}> 周{TR_WEEKDAYS_CN[i]}</span></span>
                <span className="tr-daycard-date" style={isToday ? { color: 'var(--mac-blue, var(--accent))' } : null}>{dayNum}</span>
              </div>
              {f.hasRecord ? (
                <div className="tr-daycard-body">
                  <div style={{ fontSize: 11, color: 'var(--text)' }}>{f.recordCount} 条记录 record{f.recordCount !== 1 ? 's' : ''}</div>
                  <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', alignItems: 'center' }}>
                    <span className={f.anyFeedback ? 'tr-chip tr-chip-ok' : 'tr-chip'} style={{ fontSize: 10, padding: '1px 6px' }}>
                      {f.anyFeedback ? '已反馈' : '未反馈'}
                    </span>
                    {f.rpe != null && <span className="tr-chip tr-chip-info" style={{ fontSize: 10, padding: '1px 6px' }}>RPE {f.rpe}</span>}
                  </div>
                  {missingProgram && <div style={{ fontSize: 9.5, color: 'var(--muted-2)' }}>计划已删除 Program deleted</div>}
                </div>
              ) : (
                <div className="tr-daycard-body">
                  <div style={{ fontSize: 11, color: 'var(--muted-2)' }}>无记录 / No record</div>
                </div>
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
}

// ── TrainingWeekSummaryCard ─────────────────────────────────────────────────
// Read-only weekly training summary (Mon–Sun, local dates). Pure display of
// core/training/TrainingWeekSummary.js output — sessions/completed/RPE/duration/load only.
function trFmtLocalDate(d) {
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
// Monday–Sunday local range, offset by whole weeks (0 = this week, ±1 = adjacent).
function weekMondayToSunday(weekOffset = 0) {
  const now = new Date();
  const day = now.getDay(); // 0=Sun..6=Sat
  const diffToMonday = (day === 0 ? -6 : 1 - day) + weekOffset * 7;
  const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate() + diffToMonday);
  const sunday = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + 6);
  // 本地日期串（toISOString 是 UTC，会在 UTC+ 时区把周边界前移一天）
  return { fromDate: trFmtLocalDate(monday), toDate: trFmtLocalDate(sunday) };
}
function currentWeekMondayToSunday() { return weekMondayToSunday(0); }

function TrainingWeekSummaryStat({ label, value, sub }) {
  return (
    <div className="tr-stat">
      <div className="tr-stat-label">{label}</div>
      <div className="tr-stat-value mono">{value == null ? '—' : value}</div>
      {sub && <div style={{ fontSize: 9.5, color: 'var(--mac-muted, var(--muted))' }}>{sub}</div>}
    </div>
  );
}

// Per-metric display formatting (value suffix + sub-label), keyed by
// TrainingStatMetrics id. Purely presentational — the underlying number comes
// from the registry's compute(); this only decides how to print it.
function trFormatStatValue(id, raw) {
  if (raw == null) return null;
  if (id === 'totalDurationMin') return `${raw} min`;
  return raw;
}
function trStatSub(id) {
  return id === 'sessionLoad' ? '时长×RPE (Foster) · 需两者齐备' : null;
}

// "＋" stat-selector popover (mockup .stats-edit / .stats-pop form). Renders the
// full TrainingStatMetrics registry as a checklist; toggling saves immediately
// via onChangeMetrics. Closes on outside click (mousedown, matching the
// SeasonSelector pattern elsewhere in this codebase).
function TrainingStatSelector({ selected, onChangeMetrics }) {
  const [open, setOpen] = trS(false);
  const ref = trR(null);
  const SM = window.TrainingStatMetrics;

  trE(() => {
    const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener('mousedown', h);
    return () => document.removeEventListener('mousedown', h);
  }, []);

  if (!SM) return null;
  const registry = SM.listStatMetrics();
  const toggle = (id) => {
    const has = selected.includes(id);
    const next = has ? selected.filter(x => x !== id) : [...selected, id];
    onChangeMetrics(next);
  };

  return (
    <div ref={ref} style={{ position: 'relative', marginLeft: 'auto' }}>
      <button type="button" className="tr-stat-edit" title="选择呈现指标 · Select metrics"
        onClick={() => setOpen(o => !o)}>
        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"><path d="M12 5v14M5 12h14"/></svg>
      </button>
      {open && (
        <div className="tr-stat-pop">
          {registry.map(m => (
            <label key={m.id}>
              <input type="checkbox" checked={selected.includes(m.id)} onChange={() => toggle(m.id)} />
              {m.label}
            </label>
          ))}
          <div className="tr-stat-pop-note">纲要原则：任何数据呈现位均可由用户自由选择显示内容，配置持久保存。</div>
        </div>
      )}
    </div>
  );
}

function TrainingWeekSummaryCard({ athletes = [], athleteId, onAthleteChange, fromDate, toDate }) {
  const FS = window.FieldDataStore;
  const [summary, setSummary] = trS(null);
  const [logs, setLogs] = trS([]);
  // SEL-1: which stat metrics to render, persisted via window.VizPanelPrefsRepo
  // under the 'training-week-stats' domain ({ metrics: string[] }). Falls back
  // to the registry's default 5 metrics if the repository isn't available yet.
  const statPrefsRepo = trM(() => window.VizPanelPrefsRepo || null, []);
  const [selectedMetrics, setSelectedMetrics] = trS(() => {
    if (statPrefsRepo) return statPrefsRepo.load('training-week-stats').metrics;
    return (window.TrainingStatMetrics && window.TrainingStatMetrics.DEFAULT_METRIC_IDS) ||
      ['sessions', 'completed', 'avgRPE', 'totalDurationMin', 'sessionLoad'];
  });
  const updateSelectedMetrics = React.useCallback((next) => {
    setSelectedMetrics(next);
    if (statPrefsRepo) statPrefsRepo.save('training-week-stats', { metrics: next });
  }, [statPrefsRepo]);

  trE(() => {
    let alive = true;
    (async () => {
      if (!FS || !athleteId || !window.TrainingWeekSummary) { setSummary(null); setLogs([]); return; }
      try {
        const weekLogs = await FS.listTrainingLogsByAthlete(athleteId, fromDate, toDate);
        if (!alive) return;
        setSummary(window.TrainingWeekSummary.summarizeTrainingWeek(weekLogs));
        setLogs(weekLogs || []);
      } catch (e) { if (alive) { setSummary(null); setLogs([]); } }
    })();
    return () => { alive = false; };
  }, [FS, athleteId, fromDate, toDate]);

  if (!athletes.length) return null;
  const s = summary || { sessions: null, completed: null, avgRPE: null, totalDurationMin: null, sessionLoad: null };
  const SM = window.TrainingStatMetrics;
  const metricInput = { summary: s, logs };

  return (
    <div className="tr-card" data-training-week-summary style={{ margin: '12px 20px 0', padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 10 }}>
      <div className="tr-card-head" style={{ padding: 0, border: 0 }}>
        <div style={{ fontSize: 10, color: 'var(--mac-muted, var(--muted-2))', textTransform: 'uppercase', letterSpacing: '.08em', fontWeight: 700 }}>
          Week Summary · 本周训练汇总
        </div>
        {athletes.length > 1 && (
          <select value={athleteId} onChange={e => onAthleteChange && onAthleteChange(e.target.value)}
            style={{ ...trInput, width: 'auto', fontSize: 11, padding: '4px 8px' }}>
            {athletes.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
          </select>
        )}
      </div>
      <div style={{ display: 'flex', gap: 22, flexWrap: 'wrap', position: 'relative' }}>
        {selectedMetrics.map(id => {
          const meta = SM && SM.listStatMetrics().find(m => m.id === id);
          if (!meta) return null;
          const raw = SM ? SM.computeStatMetric(id, metricInput) : null;
          return (
            <TrainingWeekSummaryStat key={id} label={meta.label}
              value={trFormatStatValue(id, raw)} sub={trStatSub(id)} />
          );
        })}
        <TrainingStatSelector selected={selectedMetrics} onChangeMetrics={updateSelectedMetrics} />
      </div>
    </div>
  );
}

// ── TrainingPeriodStatsCard (TR-UI3) ────────────────────────────────────────
// Period-switchable (7/14/30 day) training-data segment: read-only aggregation
// of core/training/TrainingPeriodStats.js over trainingLogs[].actuals, plus an
// inline volume/intensity combo chart. Descriptive only — training volume and
// intensity as executed, no inference beyond the arithmetic.
const TR_PERIOD_OPTIONS = [
  { days: 7, label: '7天 7d' },
  { days: 14, label: '14天 14d' },
  { days: 30, label: '30天 30d' },
];

// N-day local-date range ending today (inclusive), N-1 days back. Local dates only.
function periodRange(days) {
  const now = new Date();
  const to = trFmtLocalDate(now);
  const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - (days - 1));
  const from = trFmtLocalDate(start);
  return { fromDate: from, toDate: to };
}

// Catmull-Rom → cubic-bezier smoothed path through points, using the
// standard 1/6-tangent control-point formula (matches
// mockups/proposals/minimal-cold-redesign.html's hand-authored curve).
// points: [{x, y}, ...] in ascending x order. Returns an SVG path `d` string,
// or '' for fewer than 2 points.
function trSmoothPath(points) {
  if (!points || points.length < 2) return '';
  if (points.length === 2) {
    return `M${points[0].x},${points[0].y} L${points[1].x},${points[1].y}`;
  }
  let d = `M${points[0].x},${points[0].y}`;
  for (let i = 0; i < points.length - 1; i++) {
    const p0 = points[i - 1] || points[i];
    const p1 = points[i];
    const p2 = points[i + 1];
    const p3 = points[i + 2] || p2;
    const c1x = p1.x + (p2.x - p0.x) / 6;
    const c1y = p1.y + (p2.y - p0.y) / 6;
    const c2x = p2.x - (p3.x - p1.x) / 6;
    const c2y = p2.y - (p3.y - p1.y) / 6;
    d += ` C${c1x},${c1y} ${c2x},${c2y} ${p2.x},${p2.y}`;
  }
  return d;
}

let trChartGradSeq = 0;

function TrainingPeriodStatsChart({ daily }) {
  const gradId = trR(null);
  if (!gradId.current) gradId.current = `trBarGrad${++trChartGradSeq}`;

  const w = 560, h = 160, padL = 34, padR = 12, padT = 10, padB = 22;
  const innerW = w - padL - padR, innerH = h - padT - padB;
  const maxVolume = Math.max(1, ...daily.map(d => d.volume || 0));
  const weights = daily.map(d => d.avgWeight).filter(v => typeof v === 'number' && isFinite(v));
  const maxWeight = weights.length ? Math.max(...weights) : 1;
  const n = daily.length;
  const slot = innerW / n;
  const barW = Math.max(4, slot * 0.55);

  const xOf = (i) => padL + i * slot + slot / 2;
  const yVol = (v) => padT + innerH - (v / maxVolume) * innerH;
  const yWeight = (v) => padT + innerH - (v / maxWeight) * innerH;

  const linePoints = daily
    .map((d, i) => (typeof d.avgWeight === 'number' && isFinite(d.avgWeight) ? { x: xOf(i), y: yWeight(d.avgWeight) } : null))
    .filter(Boolean);
  const linePath = trSmoothPath(linePoints);

  return (
    <svg viewBox={`0 0 ${w} ${h}`} style={{ width: '100%', height: 'auto', display: 'block' }} className="tr-chart">
      <defs>
        <linearGradient id={gradId.current} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#3d9bff" />
          <stop offset="1" stopColor="#0a6fe0" />
        </linearGradient>
      </defs>
      <line x1={padL} y1={padT + innerH} x2={w - padR} y2={padT + innerH} stroke="var(--border)" strokeWidth="1" />
      {daily.map((d, i) => (
        <rect key={d.date}
          className="tr-chart-bar"
          x={xOf(i) - barW / 2}
          y={yVol(d.volume || 0)}
          width={barW}
          height={Math.max(0, padT + innerH - yVol(d.volume || 0))}
          rx="3"
          fill={`url(#${gradId.current})`}
          opacity="0.82">
          <title>{`${d.date} · ${d.volume || 0} kg`}</title>
        </rect>
      ))}
      {linePath && (
        <path className="tr-chart-line" d={linePath} fill="none" stroke="var(--mac-orange)"
          strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
      )}
      {daily.map((d, i) => (
        typeof d.avgWeight === 'number' && isFinite(d.avgWeight)
          ? (
            <circle key={'pt-' + d.date} className="tr-chart-pt" cx={xOf(i)} cy={yWeight(d.avgWeight)} r="2.5" fill="var(--mac-orange)">
              <title>{`${d.date} · avg ${d.avgWeight} kg`}</title>
            </circle>
          )
          : null
      ))}
      {daily.map((d, i) => (
        <text key={'lbl-' + d.date} x={xOf(i)} y={h - 6} textAnchor="middle" fontSize="8" fill="var(--muted-2)">
          {d.date.slice(5)}
        </text>
      ))}
    </svg>
  );
}

function TrainingPeriodStatsCard({ athletes = [], athleteId, fromDate, toDate, periodDays, onPeriodChange }) {
  const FS = window.FieldDataStore;
  const [stats, setStats] = trS(null);

  trE(() => {
    let alive = true;
    (async () => {
      if (!FS || !athleteId || !window.TrainingPeriodStats) { setStats(null); return; }
      try {
        const logs = await FS.listTrainingLogsByAthlete(athleteId, fromDate, toDate);
        if (!alive) return;
        setStats(window.TrainingPeriodStats.summarizeTrainingPeriod(logs));
      } catch (e) { if (alive) setStats(null); }
    })();
    return () => { alive = false; };
  }, [FS, athleteId, fromDate, toDate]);

  if (!athletes.length) return null;
  const s = stats || { totalVolume: null, avgIntensity: null, trainingDays: null, totalSets: null, daily: [] };
  const daily = s.daily || [];

  return (
    <div className="tr-card training-period-card" style={{ margin: '12px 20px 20px', padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 10 }}>
      <div className="tr-card-head" style={{ padding: 0, border: 0 }}>
        <div style={{ fontSize: 10, color: 'var(--mac-muted, var(--muted-2))', textTransform: 'uppercase', letterSpacing: '.08em', fontWeight: 700 }}>
          Training Data · 训练数据
        </div>
        <div style={{ display: 'flex', gap: 4 }}>
          {TR_PERIOD_OPTIONS.map(opt => (
            <button key={opt.days} type="button"
              className={opt.days === periodDays ? 'tr-chip tr-chip-ok' : 'tr-chip'}
              style={{ fontSize: 10, padding: '2px 8px', cursor: 'pointer', border: 0 }}
              onClick={() => onPeriodChange(opt.days)}>
              {opt.label}
            </button>
          ))}
        </div>
      </div>
      <div style={{ display: 'flex', gap: 22, flexWrap: 'wrap' }}>
        <TrainingWeekSummaryStat label="Total Volume" value={s.totalVolume == null ? null : `${s.totalVolume} kg`} />
        <TrainingWeekSummaryStat label="Avg Intensity" value={s.avgIntensity == null ? null : `${s.avgIntensity} kg`} />
        <TrainingWeekSummaryStat label="Training Days" value={s.trainingDays} />
        <TrainingWeekSummaryStat label="Total Sets" value={s.totalSets} />
      </div>
      {daily.length === 0 ? (
        <div style={{ fontSize: 12, color: 'var(--muted-2)', padding: '18px 0', textAlign: 'center' }}>
          该周期无执行记录 / No executed records in this period
        </div>
      ) : (
        <TrainingPeriodStatsChart key={periodDays} daily={daily} />
      )}
    </div>
  );
}

// ── TrainingVizPanel (VIZ-1) ────────────────────────────────────────────────
// Collapsible right-side visualization panel driven by the TrainingVizModules
// registry. Renders the user's enabled modules in their saved order; each module
// header carries move-up / move-down / remove; a footer "+ 添加指标模块" reveals a
// picker of registry modules not yet enabled. All ordering / enable changes go
// through onChangePrefs (persisted). Stored facts only — no inference layer.
const TR_HEAT_STEPS = ['#d9e9fb', '#aed0f6', '#6faaec', '#2f7fd4']; // pale→deep, 4 quartile buckets

// week-load-heat view: 7 cells, hover title shows the day's session load or 无记录.
function TrVizHeatModule({ data }) {
  const cells = data || [];
  const loads = cells.map(c => c.load).filter(v => typeof v === 'number');
  const max = loads.length ? Math.max(...loads) : 0;
  const colorFor = (load) => {
    if (load == null) return 'var(--panel-2, var(--panel))';
    if (max <= 0) return TR_HEAT_STEPS[0];
    const q = Math.min(3, Math.floor((load / max) * 4 - 1e-9));
    return TR_HEAT_STEPS[Math.max(0, q)];
  };
  if (!cells.length) return <div className="tr-mod-empty">暂无数据 / No data</div>;
  return (
    <>
      <div className="tr-heat7">
        {cells.map(c => (
          <div key={c.date} className="tr-heat-cell"
            style={{ background: colorFor(c.load) }}
            title={`${c.date.slice(5)} · ${c.load == null ? '无记录 / No record' : `load ${c.load}`}`} />
        ))}
      </div>
      <div className="tr-mod-foot">session load · 深浅=日负荷</div>
    </>
  );
}

// rpe-trend-14d view: smoothed line + points (trSmoothPath), hover point title shows date · RPE.
function TrVizRpeModule({ data }) {
  const pts = data || [];
  if (!pts.length) return <div className="tr-mod-empty">暂无数据 / No data</div>;
  const w = 240, h = 70, padX = 10, padT = 8, padB = 6;
  const innerW = w - padX * 2, innerH = h - padT - padB;
  const n = pts.length;
  const xOf = (i) => n === 1 ? w / 2 : padX + (i / (n - 1)) * innerW;
  const yOf = (rpe) => padT + innerH - (Math.max(0, Math.min(10, rpe)) / 10) * innerH;
  const linePoints = pts.map((p, i) => ({ x: xOf(i), y: yOf(p.rpe) }));
  const linePath = trSmoothPath(linePoints);
  return (
    <>
      <svg viewBox={`0 0 ${w} ${h}`} className="tr-chart">
        <line x1="0" y1={h - padB} x2={w} y2={h - padB} stroke="var(--border)" strokeWidth="1" />
        {linePath && (
          <path className="tr-chart-line" d={linePath} fill="none" stroke="var(--accent)"
            strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
        )}
        <g fill="var(--accent)">
          {pts.map((p, i) => (
            <circle key={p.date} className="tr-chart-pt" cx={xOf(i)} cy={yOf(p.rpe)} r="2.25">
              <title>{`${p.date.slice(5)} · RPE ${p.rpe}`}</title>
            </circle>
          ))}
        </g>
      </svg>
      <div className="tr-mod-foot">仅记录事实 · 不做疲劳推断</div>
    </>
  );
}

// plan-structure view: proportional bar segments per group, hover shows 组数 · 占比.
function TrVizStructureModule({ data }) {
  const groups = data || [];
  if (!groups.length) return <div className="tr-mod-empty">暂无数据 / No data</div>;
  const w = 240, h = 14, gap = 2;
  const totalGap = gap * Math.max(0, groups.length - 1);
  const usableW = w - totalGap;
  let x = 0;
  const segs = groups.map((g, i) => {
    const segW = Math.max(1, g.share * usableW);
    const seg = { ...g, x, w: segW, opacity: Math.max(0.25, 0.85 - i * 0.18) };
    x += segW + gap;
    return seg;
  });
  return (
    <>
      <svg viewBox={`0 0 ${w} ${h}`}>
        {segs.map(s => (
          <rect key={s.group} x={s.x} y="2" width={s.w} height="10" rx="2" fill="var(--accent)" opacity={s.opacity}>
            <title>{`${s.group} · ${s.sets} 组 · ${Math.round(s.share * 100)}%`}</title>
          </rect>
        ))}
      </svg>
      <div className="tr-mod-foot">hover 查看各块组数与占比</div>
    </>
  );
}

function TrVizModuleView({ id, data }) {
  if (id === 'week-load-heat') return <TrVizHeatModule data={data} />;
  if (id === 'rpe-trend-14d') return <TrVizRpeModule data={data} />;
  if (id === 'plan-structure') return <TrVizStructureModule data={data} />;
  return <div className="tr-mod-empty">暂无数据 / No data</div>;
}

function TrainingVizPanel({ athleteId, weekRange, programs = [], prefs, onChangePrefs }) {
  const FS = window.FieldDataStore;
  const VM = window.TrainingVizModules;
  const [logs, setLogs] = trS([]);
  const [pickerOpen, setPickerOpen] = trS(false);

  // Trailing 14-day range for RPE trend; the heat module uses weekRange internally.
  const rpeRange = trM(() => periodRange(14), []);
  // Fetch a range covering both the visible week and the trailing 14 days.
  const fetchFrom = trM(() => (weekRange.fromDate < rpeRange.fromDate ? weekRange.fromDate : rpeRange.fromDate), [weekRange.fromDate, rpeRange.fromDate]);
  const fetchTo = trM(() => (weekRange.toDate > rpeRange.toDate ? weekRange.toDate : rpeRange.toDate), [weekRange.toDate, rpeRange.toDate]);

  trE(() => {
    let alive = true;
    (async () => {
      if (!FS || !athleteId) { setLogs([]); return; }
      try {
        const l = await FS.listTrainingLogsByAthlete(athleteId, fetchFrom, fetchTo);
        if (alive) setLogs(l || []);
      } catch (e) { if (alive) setLogs([]); }
    })();
    return () => { alive = false; };
  }, [FS, athleteId, fetchFrom, fetchTo]);

  const registry = VM ? VM.listVizModules() : [];
  const regById = {};
  registry.forEach(m => { regById[m.id] = m; });
  const enabled = (prefs.modules || []).filter(id => regById[id]);
  const available = registry.filter(m => !enabled.includes(m.id));

  const inputFor = (id) => {
    if (id === 'week-load-heat') return { logs, programs, athleteId, fromDate: weekRange.fromDate, toDate: weekRange.toDate };
    return { logs, programs, athleteId, fromDate: rpeRange.fromDate, toDate: rpeRange.toDate };
  };

  const move = (id, dir) => {
    onChangePrefs(prev => {
      const arr = (prev.modules || []).slice();
      const i = arr.indexOf(id);
      const j = i + dir;
      if (i < 0 || j < 0 || j >= arr.length) return prev;
      [arr[i], arr[j]] = [arr[j], arr[i]];
      return { ...prev, modules: arr };
    });
  };
  const remove = (id) => onChangePrefs(prev => ({ ...prev, modules: (prev.modules || []).filter(x => x !== id) }));
  const add = (id) => { onChangePrefs(prev => ({ ...prev, modules: [...(prev.modules || []), id] })); setPickerOpen(false); };
  const toggleOpen = () => onChangePrefs(prev => ({ ...prev, panelOpen: !prev.panelOpen }));

  const closed = !prefs.panelOpen;

  return (
    <aside className={closed ? 'tr-viz closed' : 'tr-viz'}
      onClick={closed ? toggleOpen : undefined}>
      <div className="tr-viz-h">
        <b>可视化 · Visualization</b>
        <button type="button" className="tr-viz-toggle" title="折叠 / 展开 Collapse / Expand"
          onClick={(e) => { e.stopPropagation(); toggleOpen(); }}>
          <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
            <path d={closed ? 'm9 5 7 7-7 7' : 'm15 5-7 7 7 7'} />
          </svg>
        </button>
        <span className="tr-viz-vertical">可视化</span>
      </div>
      <div className="tr-viz-body">
        {enabled.length === 0 && (
          <div className="tr-mod-empty" style={{ padding: '14px 0' }}>暂无启用的指标模块 / No modules enabled</div>
        )}
        {enabled.map((id, i) => {
          const meta = regById[id];
          const data = VM ? VM.computeVizModule(id, inputFor(id)) : null;
          return (
            <div key={id} className="tr-mod" data-mod={id}>
              <div className="tr-mod-h">
                <span className="tr-mod-title">{meta.label}</span>
                <button type="button" className="tr-mod-btn" title="上移 Move up" disabled={i === 0} onClick={() => move(id, -1)}>↑</button>
                <button type="button" className="tr-mod-btn" title="下移 Move down" disabled={i === enabled.length - 1} onClick={() => move(id, 1)}>↓</button>
                <button type="button" className="tr-mod-btn tr-mod-x" title="移除 Remove" onClick={() => remove(id)}>×</button>
              </div>
              <TrVizModuleView id={id} data={data} />
            </div>
          );
        })}
        {available.length > 0 && (
          <>
            <button type="button" className="tr-viz-add" onClick={() => setPickerOpen(o => !o)}>+ 添加指标模块 Add module</button>
            {pickerOpen && (
              <div className="tr-viz-picker">
                {available.map(m => (
                  <button key={m.id} type="button" onClick={() => add(m.id)}>
                    {m.label} <small>{m.dataSource}</small>
                  </button>
                ))}
              </div>
            )}
          </>
        )}
      </div>
      <div className="tr-viz-f">模块可增删、排序，配置随用户保存。数据源限已存事实（记录 / 反馈 / 计划），扩展新模块不改面板框架。</div>
    </aside>
  );
}

// ── Recent records — explicit saved execution history, not inferred plans ───
function TrainingRecentRecords({ athleteId, programs, athletes, onOpenExec }) {
  const FS = window.FieldDataStore;
  const [logs, setLogs] = trS([]);
  const [loading, setLoading] = trS(false);
  const [query, setQuery] = trS('');
  const [programFilter, setProgramFilter] = trS('all');
  trE(() => {
    let alive = true;
    if (!FS || !athleteId) {
      setLogs([]);
      return () => { alive = false; };
    }
    const end = trToday();
    const from = new Date(end + 'T00:00:00');
    from.setDate(from.getDate() - 89);
    setLoading(true);
    FS.listTrainingLogsByAthlete(athleteId, trFmtLocalDate(from), end)
      .then(rows => {
        if (alive) setLogs([...(rows || [])].sort((a, b) => String(b.date).localeCompare(String(a.date))));
      })
      .catch(() => { if (alive) setLogs([]); })
      .finally(() => { if (alive) setLoading(false); });
    return () => { alive = false; };
  }, [FS, athleteId]);

  const athlete = athletes.find(a => a.id === athleteId);
  const planName = (id) => programs.find(p => p.id === id)?.name || '已删除计划';
  const completion = (log) => {
    let done = 0, total = 0;
    Object.values(log.actuals || {}).forEach(rows => (rows || []).forEach(row => {
      total += 1;
      if (row.done) done += 1;
    }));
    return total ? Math.round(done / total * 100) : 0;
  };
  const q = query.trim().toLowerCase();
  const visible = logs.filter(log =>
    (programFilter === 'all' || log.programId === programFilter)
    && (!q || `${planName(log.programId)} ${log.date}`.toLowerCase().includes(q))
  );
  const recordsWithFeedback = logs.filter(log => Object.values(log.sessionFeedback || {}).length > 0).length;
  const feedbackRate = logs.length ? Math.round(recordsWithFeedback / logs.length * 100) : null;
  const averageCompletion = logs.length ? Math.round(logs.reduce((sum, log) => sum + completion(log), 0) / logs.length) : null;

  return (
    <div className="training-records-grid" data-training-records>
    <section className="training-records-card tr-card">
      <div className="training-records-head">
        <div>
          <div className="tr-card-title">计划记录 · Recent execution</div>
          <div className="training-section-copy">回看过去 90 天真实保存的执行记录；无记录不等于休息。</div>
        </div>
        <span className="training-records-count">{visible.length} 条</span>
      </div>
      <div className="training-records-filters">
        <input value={query} onChange={e => setQuery(e.target.value)} placeholder="搜索计划或日期" aria-label="搜索训练记录" />
        <select value={programFilter} onChange={e => setProgramFilter(e.target.value)} aria-label="按计划筛选训练记录">
          <option value="all">全部计划</option>
          {programs.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
        </select>
      </div>
      {loading ? <div className="training-records-empty">正在读取训练记录…</div> : visible.length === 0 ? (
        <div className="training-records-empty">{logs.length ? '没有匹配的记录。' : '过去 90 天暂无训练记录。开始执行计划后会出现在这里。'}</div>
      ) : (
        <div className="training-records-list">
          {visible.slice(0, 12).map(log => {
            const program = programs.find(p => p.id === log.programId);
            const feedback = Object.values(log.sessionFeedback || {});
            const rpes = feedback.map(row => row?.sessionRPE).filter(v => v != null);
            return (
              <article key={`${log.programId}-${log.athleteId}-${log.date}`} className="training-record-row">
                <time>{log.date}</time>
                <div>
                  <strong>{planName(log.programId)}</strong>
                  <span>{athlete?.name || '运动员'} · 完成组 {completion(log)}%{rpes.length ? ` · RPE ${rpes.join('/')}` : ''}</span>
                </div>
                <button className="btn" disabled={!program} onClick={() => program && onOpenExec(program, athleteId, log.date)}>打开记录</button>
              </article>
            );
          })}
        </div>
      )}
    </section>
    <aside className="training-record-completeness">
      <span className="training-eyebrow">DATA QUALITY</span>
      <h3>记录完整性</h3>
      <div><span>执行记录</span><strong>{logs.length || '—'}</strong><small>过去 90 天</small></div>
      <div><span>平均完成</span><strong>{averageCompletion == null ? '—' : `${averageCompletion}%`}</strong><small>仅基于已保存训练组</small></div>
      <div><span>反馈覆盖</span><strong>{feedbackRate == null ? '—' : `${feedbackRate}%`}</strong><small>{recordsWithFeedback}/{logs.length || 0} 条含训练反馈</small></div>
    </aside>
    </div>
  );
}

// ── ProgramList ─────────────────────────────────────────────────────────────
function ProgramList({ programs, athletes, onEdit, onExec, onDelete }) {
  const nameOf = (id) => (athletes.find(a => a.id === id) || {}).name || id;
  return (
    <div className="training-program-list">
      <div className="training-program-list-head">
        <div><strong>全部计划</strong><span>{programs.length} 个计划</span></div>
        <span>对象 / 结构 / 操作</span>
      </div>
      {programs.length === 0 && (
        <div className="training-program-empty">
          还没有训练计划。<br/>点击「新建计划」开始编排个人或团队训练。
        </div>
      )}
      <div className="training-program-rows">
        {programs.map(p => {
          const sessions = p.sessions || [];
          const exCount = sessions.reduce((n, s) => n + (s.blocks || []).length, 0);
          const people = p.mode === 'team' ? (p.athleteIds || []) : (p.athleteId ? [p.athleteId] : []);
          return (
            <article key={p.id} className="tr-card" data-training-program-card>
              <div className="training-program-identity">
                <span className={`training-program-type ${p.mode === 'team' ? 'team' : ''}`}>
                    {p.mode === 'team' ? 'TEAM' : 'INDIV'}
                </span>
                <div><strong>{p.name || '未命名计划'}</strong><span>{p.mode === 'team' ? `${people.length} 名运动员` : nameOf(p.athleteId)}</span></div>
              </div>
              <div className="training-program-meta">
                <span>{sessions.length} 个训练日</span><span>{exCount} 个动作</span><span>{p.structureType === 'periodized' ? '周期化' : '单日'}</span>
              </div>
              <div className="tr-row" data-training-program-actions>
                <button className="btn" onClick={() => onEdit(p)}>编辑</button>
                {people.length > 0 && (
                  <select onChange={e => { if (e.target.value) onExec(p, e.target.value); e.target.value = ''; }} defaultValue=""
                    aria-label={`${p.name} 执行对象`}>
                    <option value="">开始执行…</option>
                    {people.map(id => <option key={id} value={id}>{nameOf(id)}</option>)}
                  </select>
                )}
                <button className="training-delete-action" aria-label={`删除 ${p.name}`} onClick={() => onDelete(p.id)}>×</button>
              </div>
            </article>
          );
        })}
      </div>
    </div>
  );
}

// ── ProgramBuilder ────────────────────────────────────────────────────────
function ProgramBuilder({ existing, exercises, athletes, seasons, onSave, onCancel, onOpenLibrary, onOpenProgression, onExecute }) {
  const blank = () => ({
    name: '', mode: 'individual', athleteId: athletes[0]?.id || null, athleteIds: [],
    structureType: 'daily', date: '',
    prescriptionSchemaVersion: 1,
    prescriptionFields: { tempo: false, rest: false, note: false, vbt: false },
    currentWeek: 1, phase: 1, weekTypes: {},   // 当前周 · 阶段 · 每周类型 {周号: 'intensify'|'deload'|'test'}
    sessions: [{ id: trUid('s_'), name: 'Day 1', week: 1, blocks: [] }],
  });
  const [form, setForm] = trS(() => existing ? JSON.parse(JSON.stringify(existing)) : blank());
  const [error, setError] = trS(null);
  // 编排画布(周期矩阵)= 主画布;有数据时默认进矩阵,空程序默认逐日编辑
  const [bview, setBview] = trS(() => (existing?.sessions || []).some(s => (s.blocks || []).length) ? 'matrix' : 'edit');
  // 计划设置(名称/模式/运动员/结构/日期)折叠 —— 新建默认展开、编辑默认收起
  const [metaOpen, setMetaOpen] = trS(!existing);
  // 显示字段开关(对照 mockup 字段行)→ 透传给周期矩阵单元格
  const [mfields, setMfields] = trS({ load: true, rpe: true, pct1rm: false });
  // 本周依从(从训练日志算:当前周各 session 已完成组 / 总处方组)
  const [adherence, setAdherence] = trS(null);
  const dragInfo = trR(null); // ref: { si, bi } of the block being dragged

  // The athlete whose 1RM we resolve for %1RM previews (individual → that athlete; team → first selected)
  const refAthleteId = form.mode === 'team' ? (form.athleteIds[0] || null) : form.athleteId;
  const refAthlete = athletes.find(a => a.id === refAthleteId) || null;

  const setSessions = (fn) => setForm(f => ({ ...f, sessions: fn(f.sessions) }));
  const updSession = (si, fn) => setSessions(ss => ss.map((s, i) => i === si ? fn(s) : s));
  const updBlock = (si, bi, fn) => updSession(si, s => ({ ...s, blocks: s.blocks.map((b, i) => i === bi ? fn(b) : b) }));

  const addSession = () => setSessions(ss => [...ss, { id: trUid('s_'), name: `Day ${ss.length + 1}`, week: ss[ss.length - 1]?.week || 1, blocks: [] }]);
  const removeSession = (si) => setSessions(ss => ss.filter((_, i) => i !== si));
  const addBlock = (si, exerciseId) => {
    const ex = exercises.find(e => e.id === exerciseId);
    if (!ex) return;
    updSession(si, s => ({ ...s, blocks: [...s.blocks, {
      id: trUid('b_'), exerciseId: ex.id, exerciseName: ex.name, oneRMKey: ex.oneRMKey || null,
      group: null, groupType: 'straight',
      sets: [{ weight: '', pct1rm: '', reps: '', rir: '', rpe: '', tempo: '', restSec: '', note: '', targetVelocityMS: '', velocityLossPct: '' }],
    }] }));
  };
  const removeBlock = (si, bi) => updSession(si, s => ({ ...s, blocks: s.blocks.filter((_, i) => i !== bi) }));
  const addSet = (si, bi) => updBlock(si, bi, b => {
    const last = b.sets[b.sets.length - 1] || {};
    return { ...b, sets: [...b.sets, { ...last }] };
  });
  const removeSet = (si, bi, ssi) => updBlock(si, bi, b => ({ ...b, sets: b.sets.filter((_, i) => i !== ssi) }));
  const updSet = (si, bi, ssi, key, val) => updBlock(si, bi, b => ({ ...b, sets: b.sets.map((s, i) => i === ssi ? { ...s, [key]: val } : s) }));

  // drag-drop reorder of blocks within a session
  const onDrop = (si, bi) => {
    const d = dragInfo.current;
    if (!d || d.si !== si || d.bi === bi) { dragInfo.current = null; return; }
    updSession(si, s => {
      const arr = [...s.blocks];
      const [moved] = arr.splice(d.bi, 1);
      arr.splice(bi, 0, moved);
      return { ...s, blocks: arr };
    });
    dragInfo.current = null;
  };

  const toggleTeamAthlete = (id) => setForm(f => ({ ...f, athleteIds: f.athleteIds.includes(id) ? f.athleteIds.filter(x => x !== id) : [...f.athleteIds, id] }));

  // ── Phase C: 周期矩阵结构编辑(按「训练日」跨所有周作用)──────────────────
  const [mxPickDay, setMxPickDay] = trS(null); // day name awaiting +加动作 via ExercisePicker
  const mxProgress = (dayName, exName, dir) => {
    const prog = findProgression(exName); if (!prog) return;
    const newName = dir > 0 ? (prog.idx < prog.ladder.length - 1 ? prog.ladder[prog.idx + 1] : null)
                            : (prog.idx > 0 ? prog.ladder[prog.idx - 1] : null);
    if (!newName) return;
    const m = exercises.find(e => String(e.name).toLowerCase() === String(newName).toLowerCase());
    setSessions(ss => ss.map(s => s.name !== dayName ? s : ({ ...s, blocks: s.blocks.map(b =>
      b.exerciseName === exName ? { ...b, exerciseName: newName, exerciseId: m ? m.id : null, oneRMKey: m ? (m.oneRMKey || null) : null } : b) })));
  };
  const mxDeleteRow = (dayName, exName) =>
    setSessions(ss => ss.map(s => s.name !== dayName ? s : ({ ...s, blocks: s.blocks.filter(b => b.exerciseName !== exName) })));
  const mxAddExercise = (dayName, exerciseId) => {
    const ex = exercises.find(e => e.id === exerciseId); if (!ex) return;
    setSessions(ss => ss.map(s => s.name !== dayName ? s : ({ ...s, blocks: [...s.blocks, {
      id: trUid('b_'), exerciseId: ex.id, exerciseName: ex.name, oneRMKey: ex.oneRMKey || null, group: null, groupType: 'straight',
      sets: [{ weight: '', pct1rm: '', reps: '', rir: '', rpe: '', tempo: '', restSec: '', note: '', targetVelocityMS: '', velocityLossPct: '' }] }] })));
  };
  const mxAddDay = () => {
    const weeks = [...new Set((form.sessions || []).map(s => s.week || 1))].sort((a, b) => a - b);
    const names = []; (form.sessions || []).forEach(s => { if (!names.includes(s.name)) names.push(s.name); });
    const newName = `Day ${names.length + 1}`;
    const ws = weeks.length ? weeks : [1];
    setSessions(ss => [...ss, ...ws.map(w => ({ id: trUid('s_'), name: newName, week: w, blocks: [] }))]);
  };
  const mxAddWeek = () => {
    const weeks = [...new Set((form.sessions || []).map(s => s.week || 1))].sort((a, b) => a - b);
    const last = weeks.length ? weeks[weeks.length - 1] : 0;
    const cloned = (form.sessions || []).filter(s => (s.week || 1) === last)
      .map(s => ({ ...JSON.parse(JSON.stringify(s)), id: trUid('s_'), week: last + 1 }));
    setForm(f => ({ ...f, currentWeek: last + 1, sessions: [...f.sessions, ...cloned] }));
  };

  // ── Phase D: 点格弹 popover 改处方(统一写入该格 block 各组;空格则新建 block)──
  const [mxCell, setMxCell] = trS(null);
  const openCellEdit = (day, ex, week, rect, b) => {
    const s0 = (b && b.sets && b.sets[0]) || {};
    const hasW = s0.weight != null && s0.weight !== '';
    setMxCell({ day, ex, week,
      top: Math.max(16, Math.min(rect.bottom + 6, window.innerHeight - 420)),
      left: Math.max(8, Math.min(rect.left, window.innerWidth - 316)),
      sets: b && b.sets ? b.sets.length : 3,
      reps: s0.reps != null ? s0.reps : '',
      load: hasW ? s0.weight : (s0.pct1rm != null ? s0.pct1rm : ''),
      unit: hasW ? 'kg' : 'pct',
      rpe: s0.rpe != null ? s0.rpe : '',
      tempo: s0.tempo || '',
      restSec: s0.restSec ?? '',
      note: s0.note || '',
      targetVelocityMS: s0.targetVelocityMS ?? '',
      velocityLossPct: s0.velocityLossPct ?? '' });
  };
  const mxSetCell = () => {
    const { day, ex, week, sets, reps, load, unit, rpe, tempo, restSec, note, targetVelocityMS, velocityLossPct } = mxCell;
    const n = Math.max(1, parseInt(sets, 10) || 1);
    const one = {};
    if (reps !== '') one.reps = reps;
    if (load !== '') { if (unit === 'pct') one.pct1rm = load; else one.weight = load; }
    if (rpe !== '') one.rpe = rpe;
    if (prescriptionFields.tempo && tempo !== '') one.tempo = tempo;
    if (prescriptionFields.rest && restSec !== '') one.restSec = restSec;
    if (prescriptionFields.note && note !== '') one.note = note;
    if (prescriptionFields.vbt) {
      if (targetVelocityMS !== '') one.targetVelocityMS = targetVelocityMS;
      if (velocityLossPct !== '') one.velocityLossPct = velocityLossPct;
    }
    const newSets = Array.from({ length: n }, () => ({ ...one }));
    setSessions(ss => ss.map(s => {
      if (s.name !== day || (s.week || 1) !== week) return s;
      const has = (s.blocks || []).some(b => b.exerciseName === ex);
      const blocks = has
        ? s.blocks.map(b => b.exerciseName === ex ? { ...b, sets: newSets } : b)
        : [...(s.blocks || []), { id: trUid('b_'), exerciseId: null, exerciseName: ex, group: null, groupType: 'straight', sets: newSets }];
      return { ...s, blocks };
    }));
    setMxCell(null);
  };

  // 本周依从:当前周各 session 的已完成组 / 总组(从训练日志 actuals,key=`${sessionId}__${blockId}`)
  trE(() => {
    const FS = window.FieldDataStore;
    if (!FS || !existing?.id) { setAdherence(null); return; }
    let alive = true;
    (async () => {
      try {
        const ok = await FS.healthCheck(); if (!ok || !alive) return;
        const logs = await FS.listTrainingLogsByProgram(existing.id);
        if (!alive) return;
        const cw = form.currentWeek || 1;
        const wkIds = new Set((form.sessions || []).filter(s => (s.week || 1) === cw).map(s => s.id));
        let done = 0, total = 0;
        logs.forEach(l => Object.entries(l.actuals || {}).forEach(([key, arr]) => {
          if (!wkIds.has(key.split('__')[0])) return;
          (arr || []).forEach(r => { total += 1; if (r.done) done += 1; });
        }));
        if (alive) setAdherence(total ? Math.round(done / total * 100) : null);
      } catch (e) { /* offline */ }
    })();
    return () => { alive = false; };
  }, [existing?.id, form.currentWeek, form.sessions]);

  const submit = () => {
    if (!form.name.trim()) { setError('计划名称必填 Program name required.'); return; }
    if (form.mode === 'individual' && !form.athleteId) { setError('请选择一名运动员。'); return; }
    if (form.mode === 'team' && form.athleteIds.length === 0) { setError('团队模式请至少选择一名运动员。'); return; }
    setError(null);
    // Additive prescription contract. VBT device adapters may later populate the
    // execution row's meanVelocityMS / velocitySource / externalSetId without
    // changing the plan's stable targetVelocityMS / velocityLossPct fields.
    onSave({ ...existing, ...form, prescriptionSchemaVersion: 1, name: form.name.trim() });
  };

  const pmWeeks = new Set((form.sessions || []).map(s => s.week || 1)).size;
  const prescriptionFields = { tempo: false, rest: false, note: false, vbt: false, ...(form.prescriptionFields || {}) };
  const togglePrescriptionField = (key) => setForm(f => ({
    ...f,
    prescriptionFields: { tempo: false, rest: false, note: false, vbt: false, ...(f.prescriptionFields || {}), [key]: !(f.prescriptionFields || {})[key] },
  }));
  const ftagStyle = (on) => ({ fontFamily: 'var(--font-mono)', fontWeight: 500, fontSize: 10.5, background: on ? 'var(--panel-hi)' : 'transparent', border: `1px ${on ? 'solid' : 'dashed'} var(--border)`, borderRadius: 5, padding: '2px 8px', color: on ? 'var(--text-2)' : 'var(--muted)', cursor: 'pointer' });
  const segBtn = (on) => ({ padding: '5px 14px', fontSize: 12, fontWeight: 600, cursor: 'pointer', border: 0, borderRadius: 6, fontFamily: 'var(--font-sans)', background: on ? 'var(--panel)' : 'transparent', color: on ? 'var(--text)' : 'var(--muted)', boxShadow: on ? '0 1px 2px rgba(20,22,28,.08)' : 'none' });

  return (
    <div className="training-workflow-page training-builder-page training-program-builder" data-training-workflow-page="builder" data-training-program-builder>
      <div className="training-workflow-lead training-builder-lead">
        <div><span className="training-eyebrow">PROGRAM BUILDER</span><h2>{existing ? '编辑计划' : '新建计划'}</h2><p>先定义对象与结构，再编排动作和处方；Tempo、备注与 VBT 字段按需开启。</p></div>
      </div>
      {/* topbar:面包屑 + 导出/打印/保存(对照 mockup .topbar)*/}
      <div className="training-builder-topbar" style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
        <button className="btn" style={{ fontSize: 12 }} onClick={onCancel}>← Programs</button>
        <span style={{ fontSize: 12.5, color: 'var(--muted)' }}>训练 · <b style={{ color: 'var(--text)', fontWeight: 600 }}>{form.name || (existing ? '编辑计划' : '新建计划')}</b></span>
        <div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
          <button className="btn" style={{ fontSize: 12 }} onClick={() => exportProgramExcel({ ...form }, refAthlete, seasons)} title="导出为 Excel(.xls)">⬇ 导出 Excel</button>
          <button className="btn" style={{ fontSize: 12 }} onClick={() => printProgram({ ...form }, refAthlete, seasons)} title="打印训练卡">🖨 打印卡</button>
          <button className="btn primary" style={{ fontSize: 12 }} onClick={submit}>{existing ? '保存' : '创建计划'}</button>
        </div>
      </div>
      <div className="training-builder-steps" aria-label="计划编排步骤">
        <div className="on"><b>01 基本信息</b><span>对象、日期与结构</span></div>
        <div><b>02 周期结构</b><span>周次、阶段与训练日</span></div>
        <div><b>03 动作处方</b><span>动作、组次与负重</span></div>
        <div><b>04 检查发布</b><span>保存、执行与导出</span></div>
      </div>

      {/* progmeta(对照 mockup .progmeta):裸排画布,不加卡;程序名 + 指派 + 状态石 */}
      <section style={{ padding: '2px 2px 6px', marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 20, flexWrap: 'wrap' }}>
        <div style={{ minWidth: 0 }}>
          <h1 style={{ margin: 0, fontSize: 21, fontWeight: 600, letterSpacing: '-.01em', color: 'var(--text)' }}>{form.name || '未命名计划'}</h1>
          <div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 4 }}>
            指派:{form.mode === 'team' ? `${form.athleteIds.length} 人` : (refAthlete?.name || '未指定')}
            {form.date ? ` · 配对日期 ${form.date} 起 · 已在日历显示` : ' · 未配对日历'}
          </div>
        </div>
        <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
          {(() => {
            const curW = form.currentWeek || 1;
            const sm = { fontSize: 11, color: 'var(--muted)', fontWeight: 500 };
            const stones = [
              { k: '周期', v: <>第 {curW}<small style={sm}>/{pmWeeks} 周</small></> },
              { k: '阶段', v: `Phase ${form.phase || 1}` },
              { k: '本周依从', v: adherence != null ? <>{adherence}<small style={sm}>%</small></> : '—', color: adherence != null ? 'var(--pos)' : 'var(--muted)' },
            ];
            return stones.map(s => (
              <div key={s.k} style={{ minWidth: 92, border: '1px solid var(--border)', borderRadius: 9, background: 'var(--panel-2)', padding: '9px 13px' }}>
                <div style={{ fontSize: 9.5, fontFamily: 'var(--font-mono)', letterSpacing: '.1em', textTransform: 'uppercase', color: 'var(--muted-2)', fontWeight: 600 }}>{s.k}</div>
                <div className="mono" style={{ fontSize: 22, fontWeight: 600, marginTop: 6, color: s.color || 'var(--text)', letterSpacing: '-.02em' }}>{s.v}</div>
              </div>
            ));
          })()}
        </div>
      </section>

      {/* 计划设置(可折叠):名称/模式/运动员/结构/日期 */}
      <button onClick={() => setMetaOpen(o => !o)} className="btn" style={{ fontSize: 12, marginBottom: metaOpen ? 10 : 12 }}>⚙ 计划设置 {metaOpen ? '▴' : '▾'}</button>
      {metaOpen && (
      <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 12, padding: '14px 16px', marginBottom: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
        <div style={{ display: 'grid', gridTemplateColumns: '1.4fr 0.9fr 0.9fr', gap: 12 }}>
          <label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
            <TrLabel>Program name 计划名称 ✱</TrLabel>
            <input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="e.g. Off-season Block 1 — Lower" style={trInput} autoFocus/>
          </label>
          <label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
            <TrLabel>配对日期 Date（上日历）</TrLabel>
            <input type="date" value={form.date || ''} onChange={e => setForm(f => ({ ...f, date: e.target.value }))} style={trInput}/>
          </label>
          <label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
            <TrLabel>Structure 结构</TrLabel>
            <select value={form.structureType} onChange={e => setForm(f => ({ ...f, structureType: e.target.value }))} style={trInput}>
              <option value="daily">Daily 按天（自由）</option>
              <option value="periodized">Periodized 周期化（按周/块）</option>
            </select>
          </label>
        </div>
        <div style={{ display: 'grid', gridTemplateColumns: '0.9fr 0.9fr 1.4fr', gap: 12 }}>
          <label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
            <TrLabel>当前周 Current week</TrLabel>
            <input type="number" min="1" value={form.currentWeek || 1} onChange={e => setForm(f => ({ ...f, currentWeek: Math.max(1, +e.target.value || 1) }))} style={trInput}/>
          </label>
          <label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
            <TrLabel>阶段 Phase</TrLabel>
            <input type="number" min="1" value={form.phase || 1} onChange={e => setForm(f => ({ ...f, phase: Math.max(1, +e.target.value || 1) }))} style={trInput}/>
          </label>
          <div/>
        </div>
        {pmWeeks > 0 && (
          <div>
            <TrLabel style={{ marginBottom: 6 }}>周次类型 Week types(强化 / 减载 / 测试)</TrLabel>
            <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
              {[...new Set((form.sessions || []).map(s => s.week || 1))].sort((a, b) => a - b).map(w => (
                <label key={w} style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11.5, color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>
                  W{w}
                  <select value={(form.weekTypes || {})[w] || 'normal'} onChange={e => setForm(f => ({ ...f, weekTypes: { ...(f.weekTypes || {}), [w]: e.target.value } }))} style={{ ...trInput, padding: '3px 6px', fontSize: 11, width: 'auto' }}>
                    <option value="normal">普通</option>
                    <option value="intensify">强化</option>
                    <option value="deload">减载</option>
                    <option value="test">测试</option>
                  </select>
                </label>
              ))}
            </div>
          </div>
        )}
        <div>
          <TrLabel style={{ marginBottom: 6 }}>Mode 模式</TrLabel>
          <div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
            {[['individual', '个人 Individual'], ['team', '团队 Team']].map(([m, lbl]) => (
              <button key={m} onClick={() => setForm(f => ({ ...f, mode: m }))} style={{
                padding: '5px 12px', borderRadius: 5, cursor: 'pointer', fontSize: 12, fontWeight: 500,
                background: form.mode === m ? 'var(--accent)' : 'var(--panel-hi)', color: form.mode === m ? '#fff' : 'var(--text-2)',
                border: `1px solid ${form.mode === m ? 'var(--accent)' : 'var(--border)'}`,
              }}>{lbl}</button>
            ))}
          </div>
          {form.mode === 'individual' ? (
            <select value={form.athleteId || ''} onChange={e => setForm(f => ({ ...f, athleteId: e.target.value }))} style={{ ...trInput, maxWidth: 280 }}>
              {athletes.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
            </select>
          ) : (
            <>
              <div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 8, alignItems: 'center' }}>
                <button onClick={() => setForm(f => ({ ...f, athleteIds: athletes.map(a => a.id) }))} className="btn" style={{ fontSize: 10.5, padding: '3px 9px' }}>全选 All</button>
                <button onClick={() => setForm(f => ({ ...f, athleteIds: [] }))} className="btn" style={{ fontSize: 10.5, padding: '3px 9px' }}>清除 Clear</button>
                <span style={{ width: 1, height: 16, background: 'var(--border)', margin: '0 3px' }}/>
                <span style={{ fontSize: 9.5, color: 'var(--muted-2)' }}>按位置加选</span>
                {[...new Set(athletes.map(a => a.position).filter(Boolean))].map(pos => (
                  <button key={pos} onClick={() => setForm(f => ({ ...f, athleteIds: [...new Set([...f.athleteIds, ...athletes.filter(a => a.position === pos).map(a => a.id)])] }))}
                    className="btn" style={{ fontSize: 10.5, padding: '3px 9px' }}>+ {pos}</button>
                ))}
                <span style={{ fontSize: 10, color: 'var(--muted)', marginLeft: 'auto', fontFamily: 'var(--font-mono)' }}>{form.athleteIds.length}/{athletes.length} 选中</span>
              </div>
              <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
                {athletes.map(a => {
                  const on = form.athleteIds.includes(a.id);
                  return <button key={a.id} onClick={() => toggleTeamAthlete(a.id)} style={{
                    padding: '4px 9px', borderRadius: 999, cursor: 'pointer', fontSize: 11,
                    background: on ? 'var(--accent-soft)' : 'var(--panel-hi)', color: on ? 'var(--accent)' : 'var(--text-2)',
                    border: `1px solid ${on ? 'var(--accent)' : 'var(--border)'}`,
                  }}>{on ? '✓ ' : ''}{a.name} <span style={{ fontSize: 9, color: 'var(--muted-2)' }}>{a.position}</span></button>;
                })}
              </div>
            </>
          )}
          {form.mode === 'team' && (
            <div style={{ fontSize: 10.5, color: 'var(--muted-2)', marginTop: 6 }}>团队模板套用所选运动员；执行时各人可单独调整、读取各自数据库 1RM。</div>
          )}
        </div>
      </div>
      )}

      {/* 画布工具条:编排画布/执行卡片/动作库 seg + 逐日编辑 + 进阶阶梯 — 对照 mockup */}
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, margin: '2px 0', flexWrap: 'wrap' }}>
        <div style={{ display: 'inline-flex', background: 'var(--panel-hi)', border: '1px solid var(--border)', borderRadius: 8, padding: 2, gap: 2 }}>
          <button type="button" onClick={() => setBview('matrix')} style={segBtn(bview === 'matrix')}>编排画布</button>
          <button type="button" onClick={() => onExecute && onExecute()} style={segBtn(false)}>执行卡片</button>
          <button type="button" onClick={() => onOpenLibrary && onOpenLibrary()} style={segBtn(false)}>动作库</button>
        </div>
        <button type="button" className="btn" style={{ fontSize: 12, ...(bview === 'edit' ? { borderColor: 'var(--accent)', color: 'var(--accent)' } : null) }} onClick={() => setBview(bview === 'edit' ? 'matrix' : 'edit')}>逐日编辑</button>
        {onOpenProgression && <button type="button" className="btn" style={{ fontSize: 12 }} onClick={onOpenProgression}>进阶阶梯 ▾</button>}
        <span style={{ fontSize: 10.5, color: 'var(--muted-2)', marginLeft: 'auto' }}>拖拽动作排序 · ↑升阶 / ↓降阶按库内进阶链</span>
      </div>

      <div className="training-prescription-fields" data-training-prescription-fields>
        <div>
          <strong>处方字段</strong>
          <span>重量、%1RM、次数、RIR、RPE 始终保留；专项字段按计划启用并随计划保存。</span>
        </div>
        <div className="training-prescription-field-toggles">
          {[['tempo', 'Tempo'], ['rest', '休息秒数'], ['note', '备注'], ['vbt', 'VBT 目标']].map(([key, label]) => (
            <button key={key} type="button" className={prescriptionFields[key] ? 'on' : ''} onClick={() => togglePrescriptionField(key)}>
              {prescriptionFields[key] ? '✓ ' : '＋ '}{label}
            </button>
          ))}
        </div>
        {prescriptionFields.vbt && <p>VBT 当前记录处方目标速度、允许速度损失和执行速度；设备自动导入可通过这些稳定字段后续接入。</p>}
      </div>

      {/* 显示字段(对照 mockup 字段行)— 控制矩阵单元格 */}
      {bview === 'matrix' && (
        <div style={{ display: 'flex', alignItems: 'center', gap: 7, flexWrap: 'wrap', margin: '4px 0 2px' }}>
          <span style={{ fontSize: 11, color: 'var(--muted)' }}>显示字段:</span>
          <span style={ftagStyle(true)}>组×次</span>
          {[['load', '负荷'], ['rpe', 'RPE'], ['pct1rm', '%1RM']].map(([k, l]) => (
            <button key={k} type="button" onClick={() => setMfields(f => ({ ...f, [k]: !f[k] }))} style={ftagStyle(mfields[k])}>{mfields[k] ? l : '+ ' + l}</button>
          ))}
          <span style={{ fontSize: 10.5, color: 'var(--muted-2)', marginLeft: 'auto' }}>横向 = 周次(周期化) · 纵向 = 动作 · 当前 W{form.currentWeek || 1}</span>
        </div>
      )}

      {bview === 'edit' ? (<>
        {form.sessions.map((session, si) => (
          <SessionEditor
            key={session.id} session={session} si={si} exercises={exercises}
            refAthlete={refAthlete} seasons={seasons} structureType={form.structureType}
            fields={prescriptionFields}
            onName={(name) => updSession(si, s => ({ ...s, name }))}
            onWeek={(week) => updSession(si, s => ({ ...s, week: +week || 1 }))}
            onRemove={() => removeSession(si)}
            onAddBlock={(exId) => addBlock(si, exId)}
            onRemoveBlock={(bi) => removeBlock(si, bi)}
            onUpdBlock={(bi, fn) => updBlock(si, bi, fn)}
            onAddSet={(bi) => addSet(si, bi)}
            onRemoveSet={(bi, ssi) => removeSet(si, bi, ssi)}
            onUpdSet={(bi, ssi, key, val) => updSet(si, bi, ssi, key, val)}
            dragInfo={dragInfo}
            onDragStartBlock={(bi) => { dragInfo.current = { si, bi }; }}
            onDropBlock={(bi) => onDrop(si, bi)}
          />
        ))}
        <button className="btn" style={{ fontSize: 12, marginTop: 4 }} onClick={addSession}>+ Add session 添加训练日</button>
      </>) : (
        <>
        <PeriodMatrix sessions={form.sessions} exercises={exercises} fields={{ ...mfields, ...prescriptionFields }} currentWeek={form.currentWeek} weekTypes={form.weekTypes}
          editable onProgress={mxProgress} onDeleteRow={mxDeleteRow} onAddExercise={(d) => setMxPickDay(d)} onAddDay={mxAddDay} onAddWeek={mxAddWeek} onEditCell={openCellEdit}/>
        {mxPickDay && <ExercisePicker exercises={exercises} onPick={(id) => { mxAddExercise(mxPickDay, id); setMxPickDay(null); }} onClose={() => setMxPickDay(null)}/>}
        {mxCell && (<>
          <div onClick={() => setMxCell(null)} style={{ position: 'fixed', inset: 0, zIndex: 2200 }}/>
          <div style={{ position: 'fixed', top: mxCell.top, left: mxCell.left, zIndex: 2201, width: 300, maxHeight: 'calc(100vh - 32px)', overflow: 'auto', background: 'var(--panel)', border: '1px solid var(--border-strong)', borderRadius: 8, boxShadow: '0 12px 32px rgba(0,0,0,.18)', padding: '10px 12px' }}>
            <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-2)', marginBottom: 8, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{mxCell.ex} · W{mxCell.week}</div>
            {[['组数', 'sets'], ['次数', 'reps'], ['RPE', 'rpe']].map(([lab, key]) => (
              <label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6, fontSize: 11.5, color: 'var(--muted)' }}>
                <span style={{ width: 30 }}>{lab}</span>
                <input value={mxCell[key]} onChange={e => setMxCell(c => ({ ...c, [key]: e.target.value }))} style={{ ...trInput, flex: 1, padding: '4px 7px', fontSize: 12, minWidth: 0 }}/>
              </label>
            ))}
            <label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 9, fontSize: 11.5, color: 'var(--muted)' }}>
              <span style={{ width: 30 }}>负荷</span>
              <input value={mxCell.load} onChange={e => setMxCell(c => ({ ...c, load: e.target.value }))} style={{ ...trInput, flex: 1, padding: '4px 7px', fontSize: 12, minWidth: 0 }}/>
              <div style={{ display: 'inline-flex', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden', flexShrink: 0 }}>
                {['kg', 'pct'].map(u => <button key={u} type="button" onClick={() => setMxCell(c => ({ ...c, unit: u }))} style={{ padding: '3px 7px', fontSize: 11, border: 0, cursor: 'pointer', background: mxCell.unit === u ? 'var(--accent)' : 'var(--panel-hi)', color: mxCell.unit === u ? '#fff' : 'var(--muted)' }}>{u === 'kg' ? 'kg' : '%'}</button>)}
              </div>
            </label>
            {prescriptionFields.tempo && <label className="training-matrix-extra-field"><span>Tempo</span><input value={mxCell.tempo} onChange={e => setMxCell(c => ({ ...c, tempo: e.target.value }))} placeholder="31X1" style={trInput}/></label>}
            {prescriptionFields.rest && <label className="training-matrix-extra-field"><span>休息</span><input value={mxCell.restSec} onChange={e => setMxCell(c => ({ ...c, restSec: e.target.value }))} placeholder="秒" style={trInput}/></label>}
            {prescriptionFields.vbt && <>
              <label className="training-matrix-extra-field"><span>目标速度</span><input value={mxCell.targetVelocityMS} onChange={e => setMxCell(c => ({ ...c, targetVelocityMS: e.target.value }))} placeholder="m/s" style={trInput}/></label>
              <label className="training-matrix-extra-field"><span>速度损失</span><input value={mxCell.velocityLossPct} onChange={e => setMxCell(c => ({ ...c, velocityLossPct: e.target.value }))} placeholder="%" style={trInput}/></label>
            </>}
            {prescriptionFields.note && <label className="training-matrix-extra-field"><span>备注</span><input value={mxCell.note} onChange={e => setMxCell(c => ({ ...c, note: e.target.value }))} placeholder="技术提示或限制" style={trInput}/></label>}
            <div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
              <button type="button" onClick={() => setMxCell(null)} className="btn" style={{ fontSize: 11.5, padding: '4px 10px' }}>取消</button>
              <button type="button" onClick={mxSetCell} className="btn primary" style={{ fontSize: 11.5, padding: '4px 10px' }}>保存</button>
            </div>
          </div>
        </>)}
        </>
      )}

      {error && <div style={{ marginTop: 12, padding: '7px 10px', borderRadius: 4, background: 'rgba(239,68,68,.08)', border: '1px solid rgba(239,68,68,.3)', color: 'var(--neg)', fontSize: 12 }}>{error}</div>}
    </div>
  );
}

// ── PeriodMatrix — 周期化矩阵透视(动作 × 周次);editable 时:↑↓进阶 / 删行 / +加动作·训练日·周 ──
function PeriodMatrix({ sessions, exercises = [], fields = { load: true }, currentWeek, weekTypes = {}, editable = false, onProgress, onDeleteRow, onAddExercise, onAddDay, onAddWeek, onEditCell }) {
  const all = sessions || [];
  const weeks = [...new Set(all.map(s => s.week || 1))].sort((a, b) => a - b);
  const curWeek = currentWeek != null ? currentWeek : (weeks.length ? weeks[weeks.length - 1] : null);
  // 周次类型(对照 mockup):当前 / 强化 / 减载 / 测试 → 子标 + 配色
  const wkStyle = (w) => {
    if (w === curWeek) return { label: '当前', color: 'var(--accent)', bg: 'var(--accent-soft)' };
    const t = weekTypes[w] || weekTypes[String(w)];
    if (t === 'deload') return { label: '减载', color: 'var(--warn)', bg: 'rgba(217,119,6,.10)' };
    if (t === 'test') return { label: '测试', color: 'var(--pos)', bg: 'rgba(22,163,74,.10)' };
    if (t === 'intensify') return { label: '强化', color: 'var(--accent-2)', bg: undefined };
    return { label: '', color: 'var(--muted)', bg: undefined };
  };
  const catOf = (b) => { const ex = exercises.find(e => e.id === b.exerciseId); return (ex && ex.category) || b.group || ''; };
  const dayNames = [];
  all.forEach(s => { if (!dayNames.includes(s.name)) dayNames.push(s.name); });
  const summarize = (b) => {
    const sets = b.sets || [];
    if (!sets.length) return { sr: '—', ld: '' };
    const s0 = sets[0];
    // 负荷按「显示字段」开关组装:负荷(kg 或回退 %)· %1RM · RPE
    const bits = [];
    if (fields.load) { if (s0.weight != null && s0.weight !== '') bits.push(s0.weight + 'kg'); else if (s0.pct1rm) bits.push(s0.pct1rm + '%'); }
    if (fields.pct1rm && s0.pct1rm && !bits.some(x => /%$/.test(x))) bits.push(s0.pct1rm + '%');
    if (fields.rpe && s0.rpe) bits.push('R' + s0.rpe);
    if (fields.tempo && s0.tempo) bits.push('T ' + s0.tempo);
    if (fields.rest && s0.restSec !== '' && s0.restSec != null) bits.push(s0.restSec + 's');
    if (fields.vbt && s0.targetVelocityMS !== '' && s0.targetVelocityMS != null) bits.push(s0.targetVelocityMS + 'm/s');
    if (fields.vbt && s0.velocityLossPct !== '' && s0.velocityLossPct != null) bits.push('VL≤' + s0.velocityLossPct + '%');
    if (fields.note && s0.note) bits.push('备注:' + String(s0.note).slice(0, 18));
    return { sr: `${sets.length}×${s0.reps != null ? s0.reps : ''}`, ld: bits.join(' ') };
  };
  if (!all.length || !weeks.length) {
    return <div style={{ fontSize: 12, color: 'var(--muted)', padding: '14px 4px' }}>暂无训练日 / 周次数据。切到「逐日编辑」添加。</div>;
  }
  const cols = `minmax(168px,1.5fr) repeat(${weeks.length}, minmax(92px,1fr))`;
  const hair = '1px solid var(--border)';
  const cb = { borderBottom: hair, borderRight: hair, padding: '8px 9px', minWidth: 0 };
  const mono = (size, weight, color) => ({ fontFamily: 'var(--font-mono)', fontSize: size, fontWeight: weight, color });
  const mxBtn = (on) => ({ width: 18, height: 18, borderRadius: 4, border: '1px solid var(--border)', background: 'var(--panel)', color: on ? 'var(--text-2)' : 'var(--muted-2)', fontSize: 11, lineHeight: 1, cursor: on ? 'pointer' : 'default', opacity: on ? 1 : 0.4, padding: 0 });
  const mxAddBtn = { marginLeft: 8, fontSize: 10.5, padding: '2px 8px', borderRadius: 5, border: '1px dashed var(--border-strong)', background: 'transparent', color: 'var(--accent)', cursor: 'pointer', fontFamily: 'var(--font-sans)' };
  const mxFootBtn = { fontSize: 12, padding: '6px 12px', borderRadius: 7, border: '1px solid var(--border)', background: 'var(--panel)', color: 'var(--text-2)', cursor: 'pointer', fontFamily: 'var(--font-sans)' };
  return (
    <>
    <div style={{ border: hair, borderRadius: 12, overflow: 'auto', background: 'var(--panel)' }}>
      <div style={{ display: 'grid', gridTemplateColumns: cols, minWidth: 168 + weeks.length * 92 }}>
        <div style={{ ...cb, position: 'sticky', left: 0, zIndex: 3, background: 'var(--panel-2)', ...mono(10, 600, 'var(--muted-2)'), letterSpacing: '.06em', display: 'flex', alignItems: 'center' }}>动作 \ 周</div>
        {weeks.map(w => {
          const ws = wkStyle(w);
          return (
            <div key={w} style={{ ...cb, textAlign: 'center', ...mono(10.5, 600, ws.color), letterSpacing: '.04em', background: ws.bg || 'var(--panel-2)' }}>
              W{w}{ws.label && <span style={{ display: 'block', fontSize: 8, fontWeight: 500, color: ws.color, marginTop: 2 }}>{ws.label}</span>}
            </div>
          );
        })}
        {dayNames.map(name => {
          const daySessions = all.filter(s => s.name === name);
          const exList = [];
          daySessions.forEach(s => (s.blocks || []).forEach(b => { if (!exList.some(x => x.name === b.exerciseName)) exList.push({ name: b.exerciseName, exerciseId: b.exerciseId, group: b.group }); }));
          return (
            <React.Fragment key={name}>
              <div style={{ ...cb, gridColumn: '1 / -1', position: 'sticky', left: 0, background: 'var(--panel-hi)', fontSize: 11.5, fontWeight: 600, color: 'var(--text-2)', display: 'flex', alignItems: 'center', gap: 8 }}>
                {name || '训练日'}<span style={{ marginLeft: 'auto', ...mono(9.5, 600, 'var(--muted-2)') }}>{exList.length} 项</span>
                {editable && <button type="button" onClick={() => onAddExercise && onAddExercise(name)} style={mxAddBtn} title="加动作到本训练日(全周)">+ 加动作</button>}
              </div>
              {exList.map(ex => {
                const cat = catOf(ex);
                return (
                  <React.Fragment key={ex.name}>
                    <div style={{ ...cb, position: 'sticky', left: 0, background: 'var(--panel)', display: 'flex', flexDirection: 'column', gap: 4, justifyContent: 'center', minWidth: 0 }}>
                      <div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
                        <span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1, minWidth: 0 }}>{ex.name}</span>
                        {cat && <span style={{ ...mono(8.5, 600, 'var(--accent)'), letterSpacing: '.05em', textTransform: 'uppercase', background: 'var(--accent-soft)', padding: '1px 5px', borderRadius: 4, flexShrink: 0 }}>{cat}</span>}
                      </div>
                      {editable && (() => {
                        const prog = findProgression(ex.name);
                        const down = prog && prog.idx > 0 ? prog.ladder[prog.idx - 1] : null;
                        const up = prog && prog.idx < prog.ladder.length - 1 ? prog.ladder[prog.idx + 1] : null;
                        return (
                          <div style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
                            <button type="button" disabled={!down} onClick={() => onProgress && onProgress(name, ex.name, -1)} title={down ? `降阶 → ${down}` : '无更简动作'} style={mxBtn(!!down)}>↓</button>
                            <button type="button" disabled={!up} onClick={() => onProgress && onProgress(name, ex.name, 1)} title={up ? `升阶 → ${up}` : '无更难动作'} style={mxBtn(!!up)}>↑</button>
                            <button type="button" onClick={() => onDeleteRow && onDeleteRow(name, ex.name)} title="删除该动作(全周)" style={{ ...mxBtn(true), marginLeft: 'auto', color: 'var(--muted)' }}>×</button>
                          </div>
                        );
                      })()}
                    </div>
                    {weeks.map(w => {
                      const s = daySessions.find(x => (x.week || 1) === w);
                      const b = s && (s.blocks || []).find(bb => bb.exerciseName === ex.name);
                      const c = b ? summarize(b) : null;
                      const ws = wkStyle(w);
                      return (
                        <div key={w} onClick={editable && onEditCell ? (e) => onEditCell(name, ex.name, w, e.currentTarget.getBoundingClientRect(), b) : undefined} title={editable ? '点击改处方' : undefined} style={{ ...cb, textAlign: 'center', display: 'flex', flexDirection: 'column', gap: 2, justifyContent: 'center', background: ws.bg, cursor: editable ? 'pointer' : 'default' }}>
                          {c ? (<>
                            <span style={mono(11.5, 600, 'var(--text)')}>{c.sr}</span>
                            {c.ld && <span style={mono(10, 500, 'var(--muted)')}>{c.ld}</span>}
                          </>) : <span style={{ color: 'var(--muted-2)' }}>·</span>}
                        </div>
                      );
                    })}
                  </React.Fragment>
                );
              })}
            </React.Fragment>
          );
        })}
      </div>
    </div>
    {editable && (
      <div style={{ display: 'flex', gap: 8, padding: '12px 2px 2px' }}>
        <button type="button" onClick={() => onAddDay && onAddDay()} style={mxFootBtn}>+ 加训练日</button>
        <button type="button" onClick={() => onAddWeek && onAddWeek()} style={mxFootBtn}>+ 加一周</button>
      </div>
    )}
    </>
  );
}

// ── ExercisePicker — searchable modal (replaces the long native <select>) ────
function ExercisePicker({ exercises, onPick, onClose }) {
  const [q, setQ] = trS('');
  const [cat, setCat] = trS('All');
  const order = (window.FieldDataStore && window.FieldDataStore.EXERCISE_CATEGORIES) || [];
  const cats = ['All', ...order.filter(c => exercises.some(e => e.category === c))];
  const ql = q.trim().toLowerCase();
  const filtered = exercises.filter(e =>
    (cat === 'All' || e.category === cat) &&
    (!ql || (e.name + ' ' + (e.region || '') + ' ' + (e.position || '') + ' ' + (e.category || '')).toLowerCase().includes(ql)));
  const byCat = {};
  filtered.forEach(e => { (byCat[e.category] = byCat[e.category] || []).push(e); });
  const catsShown = order.filter(c => byCat[c]);
  return (
    <div data-training-exercise-picker onClick={onClose} style={{ position: 'fixed', inset: 0, zIndex: 2100, background: 'rgba(15,18,15,.45)', backdropFilter: 'blur(6px)', display: 'grid', placeItems: 'center', padding: 20 }}>
      <div onClick={e => e.stopPropagation()} style={{ background: 'var(--panel)', border: '1px solid var(--border-strong)', borderRadius: 10, width: 'min(540px, 96vw)', maxHeight: '82vh', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 60px rgba(15,18,15,.25)' }}>
        <div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 10 }}>
          <TrLabel>添加动作 Add exercise</TrLabel>
          <span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>{filtered.length}</span>
          <button onClick={onClose} style={{ background: 'none', border: 0, color: 'var(--muted)', fontSize: 20, cursor: 'pointer' }}>×</button>
        </div>
        <div style={{ padding: '10px 16px', borderBottom: '1px solid var(--border)' }}>
          <input value={q} onChange={e => setQ(e.target.value)} autoFocus placeholder="搜索动作 / 区域 / 体位…" style={{ ...trInput, fontSize: 13 }}/>
          <div style={{ display: 'flex', gap: 5, overflowX: 'auto', marginTop: 8, paddingBottom: 2 }}>
            {cats.map(c => <button key={c} onClick={() => setCat(c)} style={{ flex: '0 0 auto', padding: '4px 10px', borderRadius: 999, fontSize: 11, cursor: 'pointer', whiteSpace: 'nowrap', border: `1px solid ${cat === c ? 'var(--accent)' : 'var(--border)'}`, background: cat === c ? 'var(--accent)' : 'var(--panel-hi)', color: cat === c ? '#fff' : 'var(--text-2)' }}>{c === 'All' ? '全部' : c}</button>)}
          </div>
        </div>
        <div style={{ flex: 1, overflow: 'auto', padding: '8px 16px' }}>
          {filtered.length === 0 && <div style={{ padding: '24px 0', textAlign: 'center', color: 'var(--muted)', fontSize: 12 }}>没有匹配的动作。</div>}
          {catsShown.map(c => (
            <div key={c} style={{ marginBottom: 10 }}>
              <TrLabel style={{ marginBottom: 4 }}>{c}</TrLabel>
              <div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
                {byCat[c].map(e => (
                  <button key={e.id} data-exercise-id={e.id} onClick={() => onPick(e.id)} style={{ display: 'flex', alignItems: 'center', gap: 8, textAlign: 'left', padding: '7px 9px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', cursor: 'pointer', font: 'inherit', fontSize: 13, color: 'var(--text)' }}
                    onMouseEnter={ev => ev.currentTarget.style.background = 'var(--panel-hi)'} onMouseLeave={ev => ev.currentTarget.style.background = 'transparent'}>
                    {e.media && <span style={{ color: 'var(--accent)' }}>▶</span>}
                    <span style={{ flex: 1 }}>{e.name}{e.position ? <span style={{ color: 'var(--muted-2)', fontSize: 11 }}> · {e.position}</span> : ''}</span>
                    {e.phase != null && <span style={{ fontSize: 9.5, color: 'var(--muted-2)' }}>P{e.phase}</span>}
                    {e.oneRMKey && <span style={{ fontSize: 9, color: 'var(--muted-2)' }}>1RM</span>}
                    {e.source === 'C-PS' && <span style={{ fontSize: 9, color: 'var(--accent)', opacity: .7 }}>C-PS</span>}
                  </button>
                ))}
              </div>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

// ── SessionEditor (one training day) ────────────────────────────────────────
function SessionEditor({ session, si, exercises, refAthlete, seasons, structureType, fields, onName, onWeek, onRemove, onAddBlock, onRemoveBlock, onUpdBlock, onAddSet, onRemoveSet, onUpdSet, dragInfo, onDragStartBlock, onDropBlock }) {
  const [pickerOpen, setPickerOpen] = trS(false);
  return (
    <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 8, padding: '12px 14px', marginBottom: 12 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
        {structureType === 'periodized' && (
          <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
            <TrLabel>Wk</TrLabel>
            <input type="number" min="1" value={session.week || 1} onChange={e => onWeek(e.target.value)} style={{ ...trCell, width: 46 }}/>
          </span>
        )}
        <input value={session.name} onChange={e => onName(e.target.value)} placeholder="Day name" style={{ ...trInput, maxWidth: 220, fontWeight: 600 }}/>
        <span style={{ fontSize: 11, color: 'var(--muted)' }}>{session.blocks.length} exercise{session.blocks.length !== 1 ? 's' : ''}</span>
        <button className="btn" style={{ fontSize: 11, marginLeft: 'auto', color: 'var(--muted)' }} onClick={onRemove}>× Remove day</button>
      </div>

      {session.blocks.map((block, bi) => (
        <BlockEditor key={block.id} block={block} bi={bi} refAthlete={refAthlete} seasons={seasons} exercises={exercises} fields={fields}
          onRemove={() => onRemoveBlock(bi)} onUpd={(fn) => onUpdBlock(bi, fn)}
          onAddSet={() => onAddSet(bi)} onRemoveSet={(ssi) => onRemoveSet(bi, ssi)} onUpdSet={(ssi, k, v) => onUpdSet(bi, ssi, k, v)}
          onDragStart={() => onDragStartBlock(bi)} onDrop={() => onDropBlock(bi)} />
      ))}

      <div style={{ display: 'flex', gap: 6, marginTop: 8, alignItems: 'center' }}>
        <button type="button" className="btn" style={{ fontSize: 12 }} onClick={() => setPickerOpen(true)}>+ 添加动作 Add exercise</button>
      </div>
      {pickerOpen && <ExercisePicker exercises={exercises} onPick={(id) => { onAddBlock(id); setPickerOpen(false); }} onClose={() => setPickerOpen(false)} />}
    </div>
  );
}

// ── BlockEditor (one exercise + its sets) ───────────────────────────────────
function BlockEditor({ block, bi, refAthlete, seasons, exercises = [], fields = {}, onRemove, onUpd, onAddSet, onRemoveSet, onUpdSet, onDragStart, onDrop }) {
  const oneRM = resolveOneRM(refAthlete, block.oneRMKey, seasons);
  const metrics = window.FieldDataStore?.computeBlockMetrics(block.sets, oneRM) || {};
  const groupColor = block.group ? ['#3b82f6', '#a78bfa', '#34d399', '#f59e0b', '#f472b6', '#22d3ee'][TR_GROUP_LABELS.indexOf(block.group) % 6] : null;
  // C-PS progression ladder for this exercise → enables ↓ regress / ↑ progress
  const prog = findProgression(block.exerciseName);
  const swapExercise = (newName) => onUpd(b => {
    const m = exercises.find(e => String(e.name).toLowerCase() === String(newName).toLowerCase());
    return { ...b, exerciseName: newName, exerciseId: m ? m.id : null, oneRMKey: m ? (m.oneRMKey || null) : null };
  });
  const regressTo = prog && prog.idx > 0 ? prog.ladder[prog.idx - 1] : null;
  const progressTo = prog && prog.idx < prog.ladder.length - 1 ? prog.ladder[prog.idx + 1] : null;
  const setFields = [
    { key: 'weight', label: 'kg', width: 72 },
    { key: 'pct1rm', label: '%1RM', width: 72 },
    { key: 'reps', label: 'Reps', width: 64 },
    { key: 'rir', label: 'RIR', width: 58 },
    { key: 'rpe', label: 'RPE', width: 58 },
    ...(fields.tempo ? [{ key: 'tempo', label: 'Tempo', width: 78, placeholder: '31X1' }] : []),
    ...(fields.rest ? [{ key: 'restSec', label: 'Rest(s)', width: 72, placeholder: '120' }] : []),
    ...(fields.vbt ? [
      { key: 'targetVelocityMS', label: '目标m/s', width: 78, placeholder: '0.65' },
      { key: 'velocityLossPct', label: 'VL≤%', width: 72, placeholder: '20' },
    ] : []),
    ...(fields.note ? [{ key: 'note', label: '备注', width: 150, placeholder: '技术提示 / 限制' }] : []),
  ];
  const setGrid = `28px ${setFields.map(field => `minmax(${field.width}px,${field.key === 'note' ? '1.8fr' : '1fr'})`).join(' ')} 24px`;

  return (
    <div
      draggable
      onDragStart={onDragStart}
      onDragOver={e => e.preventDefault()}
      onDrop={onDrop}
      style={{ border: `1px solid ${groupColor ? groupColor + '55' : 'var(--border)'}`, borderLeft: groupColor ? `3px solid ${groupColor}` : '1px solid var(--border)', borderRadius: 6, padding: '9px 10px', marginBottom: 8, background: 'var(--panel-2)' }}
    >
      {/* block header */}
      <div className="tr-row" style={{ border: 0, padding: 0, display: 'flex', alignItems: 'center', gap: 8, marginBottom: 7, flexWrap: 'wrap' }}>
        <span title="拖拽排序" style={{ cursor: 'grab', color: 'var(--muted-2)', fontSize: 13 }}>⠿</span>
        <span className="tr-badge" style={{ fontSize: 11, fontWeight: 700 }}>{trBlockLetter(bi)}</span>
        <span style={{ width: 26, height: 26, borderRadius: 6, background: 'var(--panel-hi)', display: 'grid', placeItems: 'center', fontSize: 14, flex: '0 0 auto' }}>🏋</span>
        {block.group && <span style={{ fontSize: 10, fontWeight: 700, color: '#fff', background: groupColor, borderRadius: 4, padding: '1px 6px' }}>{block.group}</span>}
        <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)' }}>{block.exerciseName}</span>
        {trBlockAnnotation(block) && <span style={{ fontSize: 10.5, fontFamily: 'var(--font-mono)', color: 'var(--muted)' }}>{trBlockAnnotation(block)}</span>}
        {prog && (
          <span style={{ display: 'inline-flex', gap: 2 }} title={`进阶阶梯:${prog.pattern}（${prog.idx + 1}/${prog.ladder.length}）`}>
            <button type="button" onClick={() => regressTo && swapExercise(regressTo)} disabled={!regressTo}
              title={regressTo ? `降阶 → ${regressTo}` : '已是最简'}
              style={{ width: 22, height: 22, borderRadius: 5, cursor: regressTo ? 'pointer' : 'default', border: '1px solid var(--border)', background: 'var(--panel)', color: regressTo ? 'var(--text-2)' : 'var(--muted-2)', fontSize: 12, opacity: regressTo ? 1 : 0.4, padding: 0 }}>↓</button>
            <button type="button" onClick={() => progressTo && swapExercise(progressTo)} disabled={!progressTo}
              title={progressTo ? `升阶 → ${progressTo}` : '已是最难'}
              style={{ width: 22, height: 22, borderRadius: 5, cursor: progressTo ? 'pointer' : 'default', border: '1px solid var(--border)', background: 'var(--panel)', color: progressTo ? 'var(--text-2)' : 'var(--muted-2)', fontSize: 12, opacity: progressTo ? 1 : 0.4, padding: 0 }}>↑</button>
          </span>
        )}
        {oneRM != null && <span style={{ fontSize: 10, color: 'var(--muted)' }}>1RM {oneRM}kg（测试库）</span>}
        {/* grouping controls */}
        <select value={block.groupType} onChange={e => onUpd(b => ({ ...b, groupType: e.target.value }))} style={{ ...trInput, width: 'auto', fontSize: 10.5, padding: '3px 6px', marginLeft: 'auto' }}>
          {TR_GROUP_TYPES.map(g => <option key={g.id} value={g.id}>{g.label}</option>)}
        </select>
        <select value={block.group || ''} onChange={e => onUpd(b => ({ ...b, group: e.target.value || null }))} title="分组标签（同组=联动，如超级组 A1A2）" style={{ ...trInput, width: 'auto', fontSize: 10.5, padding: '3px 6px' }}>
          <option value="">—</option>
          {TR_GROUP_LABELS.map(g => <option key={g} value={g}>Group {g}</option>)}
        </select>
        <button onClick={onRemove} style={{ background: 'none', border: 0, color: 'var(--muted)', cursor: 'pointer', fontSize: 15, lineHeight: 1 }}>×</button>
      </div>

      {/* set rows */}
      <div className="training-set-table">
        <div className="training-set-row training-set-head" style={{ gridTemplateColumns: setGrid }}>
          <span>Set</span>
          {setFields.map(field => <span key={field.key}>{field.label}</span>)}
          <span/>
        </div>
        {block.sets.map((s, ssi) => (
          <div key={ssi} className="training-set-row" style={{ gridTemplateColumns: setGrid }}>
            <span style={{ fontSize: 11, color: 'var(--muted-2)', fontFamily: 'var(--font-mono)', textAlign: 'center' }}>{ssi + 1}</span>
            {setFields.map(field => (
              <input key={field.key} value={s[field.key] ?? ''} onChange={e => onUpdSet(ssi, field.key, e.target.value)}
                placeholder={field.key === 'weight' && oneRM && s.pct1rm ? String(Math.round(oneRM * s.pct1rm / 100)) : field.placeholder || ''} style={trCell}/>
            ))}
            <button onClick={() => onRemoveSet(ssi)} style={{ background: 'none', border: 0, color: 'var(--muted-2)', cursor: 'pointer', fontSize: 13 }}>×</button>
          </div>
        ))}
      </div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 5 }}>
        <button className="btn" style={{ fontSize: 10.5, padding: '3px 8px' }} onClick={onAddSet}>+ set</button>
        <span style={{ fontSize: 10.5, color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>
          VL {metrics.volumeLoad || 0} · reps {metrics.totalReps || 0}
          {metrics.estimated1RM != null && ` · e1RM ${metrics.estimated1RM}`}
          {metrics.avgIntensityPct != null && ` · ${metrics.avgIntensityPct}%`}
          {metrics.stressIndex ? ` · SI ${metrics.stressIndex}` : ''}
        </span>
      </div>
    </div>
  );
}

// ── ExerciseLibrary (workspace page; modal compatibility retained for nested callers) ─
function ExerciseLibrary({ FS, exercises, onChange, onClose, embedded = false }) {
  const cats = FS?.EXERCISE_CATEGORIES || [];
  const blank = { name: '', category: 'Squat', description: '', media: '' };
  const [editing, setEditing] = trS(null);   // null | 'new' | exercise object
  const [form, setForm] = trS(blank);
  const [q, setQ] = trS('');
  const [catFilter, setCatFilter] = trS('All');
  const ql = q.trim().toLowerCase();
  const matches = (e) => (catFilter === 'All' || e.category === catFilter) && (!ql || (e.name + ' ' + (e.region || '') + ' ' + (e.position || '')).toLowerCase().includes(ql));
  const shownCount = exercises.filter(matches).length;

  const openNew  = () => { setEditing('new'); setForm(blank); };
  const openEdit = (e) => { setEditing(e); setForm({ name: e.name || '', category: e.category || 'Squat', description: e.description || '', media: e.media?.url || '' }); };
  const closeForm = () => { setEditing(null); setForm(blank); };

  const save = async () => {
    if (!form.name.trim()) return;
    const base = editing && editing !== 'new' ? editing : {};
    await FS.saveExercise({ ...base, name: form.name.trim(), category: form.category, description: form.description.trim() || undefined,
      media: form.media.trim() ? { type: /\.(mp4|webm|mov)$/i.test(form.media) ? 'video' : 'image', url: form.media.trim() } : null });
    closeForm(); onChange();
  };
  const del = async () => { if (editing && editing !== 'new') { await FS.deleteExercise(editing.id); closeForm(); onChange(); } };

  const isImg = form.media && !/\.(mp4|webm|mov)$/i.test(form.media);

  const library = (
      <div className="training-tool-dialog training-library-dialog" data-training-exercise-library onClick={e => e.stopPropagation()}>
        <div style={{ padding: '13px 18px', borderBottom: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
          <div>
            <TrLabel>Exercise Library 动作库</TrLabel>
            <div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>{exercises.length} exercises · 点击动作查看/编辑</div>
          </div>
          <div style={{ display: 'flex', gap: 8 }}>
            <button className="btn primary" style={{ fontSize: 12 }} onClick={openNew}>+ Add 新建</button>
            {!embedded && <button onClick={onClose} style={{ background: 'none', border: 0, color: 'var(--muted)', fontSize: 20, cursor: 'pointer' }}>×</button>}
          </div>
        </div>

        {editing && (
          <div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', background: 'var(--panel-2)' }}>
            <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
              <TrLabel>{editing === 'new' ? '新建动作' : `编辑 · ${editing.name}`}{editing !== 'new' && editing.oneRMKey ? '  · 关联测试库 1RM' : ''}</TrLabel>
              <button onClick={closeForm} style={{ background: 'none', border: 0, color: 'var(--muted)', fontSize: 16, cursor: 'pointer' }}>×</button>
            </div>
            <div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr', gap: 10 }}>
              <input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="Exercise name 动作名称" style={trInput} autoFocus/>
              <select value={form.category} onChange={e => setForm(f => ({ ...f, category: e.target.value }))} style={trInput}>{cats.map(c => <option key={c}>{c}</option>)}</select>
              <input value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} placeholder="Description / cues 描述/要点" style={trInput}/>
              <input value={form.media} onChange={e => setForm(f => ({ ...f, media: e.target.value }))} placeholder="Image/Video URL 图/视频链接" style={trInput}/>
            </div>
            {form.media && (
              <div style={{ marginTop: 8 }}>
                {isImg
                  ? <img src={form.media} alt="" style={{ maxHeight: 120, borderRadius: 6, border: '1px solid var(--border)' }} onError={e => { e.target.style.display = 'none'; }}/>
                  : <a href={form.media} target="_blank" rel="noreferrer" style={{ fontSize: 12, color: 'var(--accent)' }}>▶ 打开视频 {form.media.slice(0, 50)}</a>}
              </div>
            )}
            <div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
              <button className="btn primary" style={{ fontSize: 12 }} onClick={save}>{editing === 'new' ? '创建' : '保存修改'}</button>
              {editing !== 'new' && <button className="btn" style={{ fontSize: 12, color: 'var(--neg)' }} onClick={del}>删除</button>}
              <button className="btn" style={{ fontSize: 12 }} onClick={closeForm}>取消</button>
            </div>
          </div>
        )}

        {/* search + category filter */}
        <div style={{ padding: '10px 16px', borderBottom: '1px solid var(--border)', background: 'var(--panel-2)' }}>
          <input value={q} onChange={e => setQ(e.target.value)} placeholder={`搜索 ${exercises.length} 个动作 / 区域 / 体位…`} style={{ ...trInput, fontSize: 13 }}/>
          <div style={{ display: 'flex', gap: 5, overflowX: 'auto', marginTop: 8, paddingBottom: 2 }}>
            {['All', ...cats.filter(c => exercises.some(e => e.category === c))].map(c => (
              <button key={c} onClick={() => setCatFilter(c)} style={{ flex: '0 0 auto', padding: '4px 10px', borderRadius: 999, fontSize: 11, cursor: 'pointer', whiteSpace: 'nowrap', border: `1px solid ${catFilter === c ? 'var(--accent)' : 'var(--border)'}`, background: catFilter === c ? 'var(--accent)' : 'var(--panel)', color: catFilter === c ? '#fff' : 'var(--text-2)' }}>{c === 'All' ? '全部' : c}</button>
            ))}
          </div>
        </div>

        <div className="training-library-results" style={{ flex: 1, overflow: 'auto', padding: '10px 16px' }}>
          {shownCount === 0 && <div style={{ padding: '24px 0', textAlign: 'center', color: 'var(--muted)', fontSize: 12 }}>没有匹配的动作。</div>}
          {cats.filter(c => exercises.some(e => e.category === c && matches(e))).map(cat => (
            <div key={cat} className="training-library-group" style={{ marginBottom: 12 }}>
              <TrLabel style={{ marginBottom: 5 }}>{cat}</TrLabel>
              <div className="training-library-items" style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
                {exercises.filter(e => e.category === cat && matches(e)).map(e => {
                  const on = editing && editing !== 'new' && editing.id === e.id;
                  return (
                    <button key={e.id} className="training-library-item" onClick={() => openEdit(e)} title={e.description || '点击查看/编辑'} style={{
                      display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 12, padding: '5px 10px', borderRadius: 999, cursor: 'pointer',
                      background: on ? 'var(--accent-soft)' : 'var(--panel-hi)', border: `1px solid ${on ? 'var(--accent)' : 'var(--border)'}`, color: on ? 'var(--accent)' : 'var(--text-2)',
                    }}>
                      {e.media && <span title="has media" style={{ color: 'var(--accent)' }}>▶</span>}
                      {e.name}
                      {e.oneRMKey && <span style={{ fontSize: 9, color: 'var(--muted-2)' }}>1RM</span>}
                      <span style={{ fontSize: 10, color: 'var(--muted-2)' }}>✎</span>
                    </button>
                  );
                })}
              </div>
            </div>
          ))}
        </div>
      </div>
  );
  if (!embedded) {
    return <div className="training-tool-overlay" onClick={onClose}>{library}</div>;
  }
  const activeCats = ['All', ...cats.filter(c => exercises.some(e => e.category === c))];
  return (
    <div className="training-workflow-page training-library-page" data-training-workflow-page="library">
      <div className="training-workflow-lead">
        <div><span className="training-eyebrow">EXERCISE LIBRARY</span><h2>动作库</h2><p>维护动作、分类、技术提示和媒体；计划编排始终读取同一份动作数据。</p></div>
        <button className="btn primary" onClick={openNew}>＋ 新建动作</button>
      </div>
      <div className="training-library-page-grid">
        <aside className="training-library-categories">
          <strong>动作类别</strong>
          {activeCats.map(c => (
            <button key={c} className={catFilter === c ? 'on' : ''} onClick={() => setCatFilter(c)}>
              <span>{c === 'All' ? '全部动作' : c}</span>
              <b>{c === 'All' ? exercises.length : exercises.filter(e => e.category === c).length}</b>
            </button>
          ))}
        </aside>
        {library}
      </div>
    </div>
  );
}

// ── CalculatorModal (1RM / RPE) ─────────────────────────────────────────────
function CalculatorModal({ onClose, embedded = false }) {
  const [tab, setTab] = trS('1rm');
  const [w, setW] = trS(''); const [reps, setReps] = trS('');
  const [target, setTarget] = trS('');
  const e1rm = (w && reps) ? +w * (1 + +reps / 30) : null; // Epley
  // RPE → %1RM (Helms/RTS-style table, reps × RPE → %1RM)
  const RPE_PCT = { 10: [100, 95.5, 92.2, 89.2, 86.3, 83.7, 81.1, 78.6, 76.2, 73.9], 9: [95.5, 92.2, 89.2, 86.3, 83.7, 81.1, 78.6, 76.2, 73.9, 70.7], 8: [92.2, 89.2, 86.3, 83.7, 81.1, 78.6, 76.2, 73.9, 70.7, 68], 7: [89.2, 86.3, 83.7, 81.1, 78.6, 76.2, 73.9, 70.7, 68, 65.3] };
  const [rpe, setRpe] = trS('8'); const [rReps, setRReps] = trS('5'); const [r1rm, setR1rm] = trS('');
  const pct = RPE_PCT[rpe]?.[Math.min(9, Math.max(0, (+rReps || 1) - 1))];
  const estLoad = (pct && r1rm) ? Math.round(+r1rm * pct / 100) : null;

  const oneRmCard = (
    <section className="training-calc-card">
      <span className="training-eyebrow">Epley equation</span><h3>1RM 估算</h3>
      <div className="training-calc-fields">
        <label><TrLabel>Weight kg</TrLabel><input value={w} onChange={e => setW(e.target.value)} style={trInput}/></label>
        <label><TrLabel>Reps</TrLabel><input value={reps} onChange={e => setReps(e.target.value)} style={trInput}/></label>
      </div>
      <div className="training-calc-result"><TrLabel>Estimated 1RM</TrLabel><strong>{e1rm ? Math.round(e1rm) : '—'}<small> kg</small></strong></div>
    </section>
  );
  const rpeCard = (
    <section className="training-calc-card">
      <span className="training-eyebrow">RPE LOAD</span><h3>RPE → 负重</h3>
      <div className="training-calc-fields three">
        <label><TrLabel>1RM kg</TrLabel><input value={r1rm} onChange={e => setR1rm(e.target.value)} style={trInput}/></label>
        <label><TrLabel>Reps</TrLabel><input value={rReps} onChange={e => setRReps(e.target.value)} style={trInput}/></label>
        <label><TrLabel>RPE</TrLabel><select value={rpe} onChange={e => setRpe(e.target.value)} style={trInput}>{[10, 9, 8, 7].map(r => <option key={r} value={r}>{r}</option>)}</select></label>
      </div>
      <div className="training-calc-result"><TrLabel>Target load @ {pct || '—'}%</TrLabel><strong>{estLoad || '—'}<small> kg</small></strong></div>
      <p>RPE→%1RM 表（reps × RPE），参考 RTS/Helms。</p>
    </section>
  );
  if (embedded) {
    return (
      <div className="training-workflow-page training-calculator-page" data-training-workflow-page="calculator" data-training-calculator>
        <div className="training-workflow-lead"><div><span className="training-eyebrow">CALCULATORS</span><h2>训练计算器</h2><p>快速估算 1RM 与基于 RPE 的目标负重；结果只作编排参考，不自动写入处方。</p></div></div>
        <div className="training-calculator-grid">{oneRmCard}{rpeCard}</div>
      </div>
    );
  }
  return (
    <div className="training-tool-overlay" data-training-calculator onClick={onClose}>
      <div className="training-tool-dialog training-calculator-dialog" onClick={e => e.stopPropagation()}>
        <div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
          <div style={{ display: 'flex', gap: 6 }}>
            {[['1rm', '1RM 估算'], ['rpe', 'RPE → 负重']].map(([id, lbl]) => (
              <button key={id} onClick={() => setTab(id)} style={{ padding: '5px 11px', borderRadius: 5, cursor: 'pointer', fontSize: 12, fontWeight: 600, background: tab === id ? 'var(--accent)' : 'var(--panel-hi)', color: tab === id ? '#fff' : 'var(--text-2)', border: `1px solid ${tab === id ? 'var(--accent)' : 'var(--border)'}` }}>{lbl}</button>
            ))}
          </div>
          <button onClick={onClose} style={{ background: 'none', border: 0, color: 'var(--muted)', fontSize: 20, cursor: 'pointer' }}>×</button>
        </div>
        <div style={{ padding: '16px' }}>
          {tab === '1rm' ? (
            <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
              <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
                <label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}><TrLabel>Weight kg</TrLabel><input value={w} onChange={e => setW(e.target.value)} style={trInput}/></label>
                <label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}><TrLabel>Reps</TrLabel><input value={reps} onChange={e => setReps(e.target.value)} style={trInput}/></label>
              </div>
              <div style={{ textAlign: 'center', padding: '12px', background: 'var(--panel-2)', borderRadius: 6 }}>
                <TrLabel>Estimated 1RM (Epley)</TrLabel>
                <div style={{ fontSize: 30, fontWeight: 700, fontFamily: 'var(--font-mono)', color: 'var(--accent)' }}>{e1rm ? Math.round(e1rm) : '—'}<span style={{ fontSize: 14, color: 'var(--muted)' }}> kg</span></div>
              </div>
            </div>
          ) : (
            <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
              <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 10 }}>
                <label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}><TrLabel>1RM kg</TrLabel><input value={r1rm} onChange={e => setR1rm(e.target.value)} style={trInput}/></label>
                <label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}><TrLabel>Reps</TrLabel><input value={rReps} onChange={e => setRReps(e.target.value)} style={trInput}/></label>
                <label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}><TrLabel>RPE</TrLabel><select value={rpe} onChange={e => setRpe(e.target.value)} style={trInput}>{[10, 9, 8, 7].map(r => <option key={r} value={r}>{r}</option>)}</select></label>
              </div>
              <div style={{ textAlign: 'center', padding: '12px', background: 'var(--panel-2)', borderRadius: 6 }}>
                <TrLabel>Target load @ {pct || '—'}%</TrLabel>
                <div style={{ fontSize: 30, fontWeight: 700, fontFamily: 'var(--font-mono)', color: 'var(--accent)' }}>{estLoad || '—'}<span style={{ fontSize: 14, color: 'var(--muted)' }}> kg</span></div>
              </div>
              <div style={{ fontSize: 10, color: 'var(--muted-2)', textAlign: 'center' }}>RPE→%1RM 表(reps×RPE),参考 RTS/Helms。</div>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

// ── ProgressionManager — user-defined 进阶/退阶 ladders ─────────────────────
function ProgressionManager({ FS, exercises, onChange, onClose, embedded = false }) {
  const cats = FS?.EXERCISE_CATEGORIES || [];
  const [userProgs, setUserProgs] = trS([]);
  const [editing, setEditing] = trS(null);   // null | 'new' | progression object
  const [form, setForm] = trS({ name: '', category: 'Squat', ladder: [] });
  const [pickerOpen, setPickerOpen] = trS(false);
  const reload = async () => { try { setUserProgs(await FS.listProgressions()); } catch (e) {} };
  trE(() => { reload(); }, []); // eslint-disable-line react-hooks/exhaustive-deps

  const openNew = () => { setEditing('new'); setForm({ name: '', category: 'Squat', ladder: [] }); };
  const openEdit = (p) => { setEditing(p); setForm({ name: p.name || '', category: p.category || 'Squat', ladder: [...(p.ladder || [])] }); };
  const forkCps = (p) => { setEditing('new'); setForm({ name: (p.pattern || '') + ' (自定义)', category: p.category || 'Squat', ladder: [...(p.ladder || [])] }); };
  const closeEd = () => setEditing(null);

  const addRung = (id) => { const ex = exercises.find(e => e.id === id); if (ex) setForm(f => ({ ...f, ladder: [...f.ladder, ex.name] })); setPickerOpen(false); };
  const moveRung = (i, dir) => setForm(f => { const a = [...f.ladder]; const j = i + dir; if (j < 0 || j >= a.length) return f; [a[i], a[j]] = [a[j], a[i]]; return { ...f, ladder: a }; });
  const removeRung = (i) => setForm(f => ({ ...f, ladder: f.ladder.filter((_, k) => k !== i) }));

  const save = async () => {
    if (!form.name.trim() || form.ladder.length < 2) return;
    const base = editing && editing !== 'new' ? editing : {};
    await FS.saveProgression({ ...base, name: form.name.trim(), category: form.category, ladder: form.ladder });
    await reload(); onChange && onChange(); closeEd();
  };
  const del = async () => { if (editing && editing !== 'new') { await FS.deleteProgression(editing.id); await reload(); onChange && onChange(); closeEd(); } };

  const cps = (window.CPS_KNOWLEDGE && window.CPS_KNOWLEDGE.progressions) || [];

  const manager = (
      <div className="training-tool-dialog training-progression-dialog" data-training-progression-manager onClick={e => e.stopPropagation()}>
        <div style={{ padding: '13px 18px', borderBottom: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
          <div>
            <TrLabel>进阶 / 退阶阶梯 Progressions</TrLabel>
            <div style={{ fontSize: 13, color: 'var(--muted)' }}>自定义动作的升/降阶链 · 编排里出现 ↓/↑ 按钮</div>
          </div>
          <div style={{ display: 'flex', gap: 8 }}>
            {!editing && <button className="btn primary" style={{ fontSize: 12 }} onClick={openNew}>+ 新建阶梯</button>}
            {!embedded && <button onClick={onClose} style={{ background: 'none', border: 0, color: 'var(--muted)', fontSize: 20, cursor: 'pointer' }}>×</button>}
          </div>
        </div>

        {editing ? (
          <div style={{ flex: 1, overflow: 'auto', padding: '14px 18px' }}>
            <div style={{ display: 'grid', gridTemplateColumns: '1.5fr 1fr', gap: 10, marginBottom: 12 }}>
              <label><TrLabel>阶梯名称</TrLabel><input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="如:我的深蹲进阶" style={trInput} autoFocus/></label>
              <label><TrLabel>分类</TrLabel><select value={form.category} onChange={e => setForm(f => ({ ...f, category: e.target.value }))} style={trInput}>{cats.map(c => <option key={c}>{c}</option>)}</select></label>
            </div>
            <TrLabel style={{ marginBottom: 6 }}>动作顺序(上=退阶端 / 下=进阶端)</TrLabel>
            {form.ladder.length === 0 && <div style={{ fontSize: 12, color: 'var(--muted)', padding: '8px 0' }}>还没有动作,点下方「+ 加动作」从简到难依次添加。</div>}
            <div style={{ display: 'flex', flexDirection: 'column', gap: 5, marginBottom: 10 }}>
              {form.ladder.map((nm, i) => (
                <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 9px', border: '1px solid var(--border)', borderRadius: 6, background: 'var(--panel-2)' }}>
                  <span style={{ fontSize: 10, color: 'var(--muted-2)', fontFamily: 'var(--font-mono)', width: 16 }}>{i}</span>
                  <span style={{ flex: 1, fontSize: 13 }}>{nm}</span>
                  <button onClick={() => moveRung(i, -1)} disabled={i === 0} style={{ width: 22, height: 22, borderRadius: 5, cursor: i === 0 ? 'default' : 'pointer', border: '1px solid var(--border)', background: 'var(--panel)', opacity: i === 0 ? 0.4 : 1 }}>↑</button>
                  <button onClick={() => moveRung(i, 1)} disabled={i === form.ladder.length - 1} style={{ width: 22, height: 22, borderRadius: 5, cursor: i === form.ladder.length - 1 ? 'default' : 'pointer', border: '1px solid var(--border)', background: 'var(--panel)', opacity: i === form.ladder.length - 1 ? 0.4 : 1 }}>↓</button>
                  <button onClick={() => removeRung(i)} style={{ background: 'none', border: 0, color: 'var(--muted-2)', cursor: 'pointer', fontSize: 14 }}>×</button>
                </div>
              ))}
            </div>
            <button className="btn" style={{ fontSize: 12 }} onClick={() => setPickerOpen(true)}>+ 加动作</button>
            {(!form.name.trim() || form.ladder.length < 2) && <div className="training-inline-hint">填写名称，并至少加入 2 个动作；顺序从退阶端到进阶端。</div>}
            <div style={{ display: 'flex', gap: 8, marginTop: 14 }}>
              <button className="btn primary" disabled={!form.name.trim() || form.ladder.length < 2} style={{ fontSize: 12 }} onClick={save}>{editing === 'new' ? '创建阶梯' : '保存'}</button>
              {editing !== 'new' && <button className="btn" style={{ fontSize: 12, color: 'var(--neg)' }} onClick={del}>删除</button>}
              <button className="btn" style={{ fontSize: 12 }} onClick={closeEd}>取消</button>
            </div>
            {pickerOpen && <ExercisePicker exercises={exercises} onPick={addRung} onClose={() => setPickerOpen(false)} />}
          </div>
        ) : (
          <div style={{ flex: 1, overflow: 'auto', padding: '10px 18px' }}>
            <TrLabel style={{ marginBottom: 6 }}>我的阶梯 ({userProgs.length})</TrLabel>
            {userProgs.length === 0 && <div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 10 }}>还没有自定义阶梯。点「+ 新建阶梯」,或在下方 C-PS 参考里「复制为自定义」。</div>}
            <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 16 }}>
              {userProgs.map(p => (
                <button key={p.id} onClick={() => openEdit(p)} style={{ textAlign: 'left', display: 'flex', alignItems: 'center', gap: 8, padding: '9px 11px', border: '1px solid var(--border)', borderRadius: 7, background: 'var(--panel-2)', cursor: 'pointer', font: 'inherit' }}>
                  <div style={{ flex: 1 }}>
                    <div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)' }}>{p.name}</div>
                    <div style={{ fontSize: 11, color: 'var(--muted)' }}>{p.category} · {(p.ladder || []).length} 个动作 · {(p.ladder || []).join(' → ').slice(0, 60)}</div>
                  </div>
                  <span style={{ fontSize: 10, color: 'var(--muted-2)' }}>✎</span>
                </button>
              ))}
            </div>
            <TrLabel style={{ marginBottom: 6 }}>C-PS 参考(只读 · 可复制)</TrLabel>
            <div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
              {cps.map((p, i) => (
                <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 10px', border: '1px solid var(--border)', borderRadius: 6 }}>
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div style={{ fontSize: 12.5, fontWeight: 500 }}>{p.pattern}</div>
                    <div style={{ fontSize: 10.5, color: 'var(--muted-2)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{(p.ladder || []).join(' → ')}</div>
                  </div>
                  <button className="btn" style={{ fontSize: 10.5, padding: '3px 8px', flexShrink: 0 }} onClick={() => forkCps(p)}>复制为自定义</button>
                </div>
              ))}
            </div>
          </div>
        )}
      </div>
  );
  if (!embedded) return <div className="training-tool-overlay" onClick={onClose}>{manager}</div>;
  return (
    <div className="training-workflow-page training-progressions-page" data-training-workflow-page="progressions">
      <div className="training-workflow-lead">
        <div><span className="training-eyebrow">PROGRESSION MAP</span><h2>进阶阶梯</h2><p>把同一动作模式从低门槛到高要求排成可复用路径，编排时一键升阶或退阶。</p></div>
        {!editing && <button className="btn primary" onClick={openNew}>＋ 新建阶梯</button>}
      </div>
      {manager}
    </div>
  );
}

// ── ProgramExecution (Today · iPad-friendly execution cards) ────────────────
function ProgramExecution({ program, athlete, date, exercises, seasons, trainingContextSeed = null, confirmedResultRows = [], patchAthleteMetrics = null, onPersistProgram = null, onBack }) {
  const FS = window.FieldDataStore;
  // Local program copy so manual evidence links reflect immediately after persist.
  const [programState, setProgramState] = trS(program);
  trE(() => { setProgramState(program); }, [program]);
  const sessions = programState.sessions || [];
  const [sessionIdx, setSessionIdx] = trS(0);
  const [actuals, setActuals] = trS({});   // { `${sessionId}__${blockId}`: [ {weight,reps,rir,rpe,done} ] }
  const [savedAt, setSavedAt] = trS(null);
  const [actualSaveState, setActualSaveState] = trS('idle');
  const [actualSaveError, setActualSaveError] = trS('');
  const actualSaveSeq = trR(0);
  const actualSaveQueue = trR(Promise.resolve());
  const [loaded, setLoaded] = trS(false);
  const [feedbackBySession, setFeedbackBySession] = trS({});   // { [sessionId]: sessionFeedback shape from store }
  const [feedbackDraft, setFeedbackDraft] = trS({ completed: false, sessionRPE: '', durationMin: '', athleteComment: '', coachComment: '' });
  const [feedbackSavedAt, setFeedbackSavedAt] = trS(null);
  const [feedbackError, setFeedbackError] = trS(null);

  trE(() => {
    let alive = true;
    (async () => {
      const existing = await FS.getTrainingLog(program.id, athlete.id, date);
      if (!alive) return;
      setActuals(existing?.actuals || {});
      setFeedbackBySession(existing?.sessionFeedback || {});
      setLoaded(true);
    })();
    return () => { alive = false; };
  }, [program.id, athlete.id, date]);

  const session = sessions[sessionIdx] || { blocks: [] };
  const keyOf = (block) => session.id + '__' + block.id;
  const oneRMOf = (block) => resolveOneRM(athlete, block.oneRMKey, seasons);
  const prescWeight = (block, s) => {
    if (s.weight != null && s.weight !== '') return +s.weight;
    const rm = oneRMOf(block);
    if (s.pct1rm && rm) return Math.round((+s.pct1rm / 100) * rm);
    return null;
  };
  // actual row for a block+set, defaulting to the prescription so the athlete just confirms/edits
  const rowOf = (block, i) => {
    const arr = actuals[keyOf(block)];
    if (arr && arr[i]) {
      const r = arr[i];
      return { weight: r.weight ?? '', reps: r.reps ?? '', rir: r.rir ?? '', rpe: r.rpe ?? '', meanVelocityMS: r.meanVelocityMS ?? '', velocitySource: r.velocitySource || '', externalSetId: r.externalSetId || '', done: !!r.done };
    }
    const s = block.sets[i] || {};
    return { weight: prescWeight(block, s) ?? '', reps: s.reps ?? '', rir: s.rir ?? '', rpe: s.rpe ?? '', meanVelocityMS: '', velocitySource: '', externalSetId: '', done: false };
  };

  const persist = async (next) => {
    const seq = ++actualSaveSeq.current;
    setActualSaveState('saving');
    setActualSaveError('');
    try {
      actualSaveQueue.current = actualSaveQueue.current.catch(() => {}).then(() => (
        FS.saveTrainingLog({ programId: program.id, athleteId: athlete.id, date, actuals: next })
      ));
      await actualSaveQueue.current;
      if (seq !== actualSaveSeq.current) return;
      setSavedAt(Date.now());
      setActualSaveState('saved');
    } catch (error) {
      if (seq !== actualSaveSeq.current) return;
      setActualSaveState('error');
      setActualSaveError(error?.message || '训练记录保存失败，请重试。');
    }
  };
  const patchRow = (block, i, patch) => {
    setActuals(prev => {
      const k = keyOf(block);
      const base = prev[k] ? prev[k].map(x => ({ ...x })) : block.sets.map((s, j) => ({ ...rowOf(block, j) }));
      base[i] = { ...base[i], ...patch };
      const next = { ...prev, [k]: base };
      persist(next);
      return next;
    });
  };

  // session totals: prescribed vs actual volume load
  const totals = trM(() => {
    let presc = 0, act = 0, doneSets = 0, totalSets = 0;
    session.blocks.forEach(block => {
      block.sets.forEach((s, i) => {
        totalSets += 1;
        const pw = prescWeight(block, s), pr = (s.reps != null && s.reps !== '') ? +s.reps : null;
        if (pw && pr) presc += pw * pr;
        const r = rowOf(block, i);
        if (r.done) doneSets += 1;
        const aw = (r.weight !== '' && r.weight != null) ? +r.weight : null, ar = (r.reps !== '' && r.reps != null) ? +r.reps : null;
        if (r.done && aw && ar) act += aw * ar;
      });
    });
    return { presc: Math.round(presc), act: Math.round(act), doneSets, totalSets };
  }, [session, actuals]);

  const bigCell = { ...trCell, padding: '9px 6px', fontSize: 15 };
  const showVbt = !!programState.prescriptionFields?.vbt
    || session.blocks.some(block => (block.sets || []).some(set => set.targetVelocityMS !== '' && set.targetVelocityMS != null));
  const executionGrid = showVbt ? '30px 1.5fr 1fr 1fr 1fr 1fr 44px' : '30px 1.5fr 1fr 1fr 1fr 44px';

  // ── Session feedback (完成/RPE/时长/评语) — 挂在当前 session 末尾，纯记录，不做 readiness/risk 推断
  const draftFromFeedback = (fb) => fb
    ? { completed: !!fb.completed, sessionRPE: fb.sessionRPE == null ? '' : String(fb.sessionRPE), durationMin: fb.durationMin == null ? '' : String(fb.durationMin), athleteComment: fb.athleteComment || '', coachComment: fb.coachComment || '' }
    : { completed: false, sessionRPE: '', durationMin: '', athleteComment: '', coachComment: '' };
  trE(() => {
    setFeedbackDraft(draftFromFeedback(feedbackBySession[session.id]));
  }, [session.id, feedbackBySession]);
  // 仅在切换 session 时清保存状态；保存成功后的 feedbackBySession 更新不得抹掉时间戳
  trE(() => {
    setFeedbackSavedAt(null);
    setFeedbackError(null);
  }, [session.id]);

  const rpeValid = feedbackDraft.sessionRPE === '' || (/^\d+$/.test(feedbackDraft.sessionRPE) && +feedbackDraft.sessionRPE >= 1 && +feedbackDraft.sessionRPE <= 10);
  const durationValid = feedbackDraft.durationMin === '' || (isFinite(+feedbackDraft.durationMin) && +feedbackDraft.durationMin > 0);
  const feedbackCanSave = rpeValid && durationValid;

  // ── Manual confirmed-result linking (Why this focus?) — 纯手动、显式；不做任何自动匹配/推荐
  // 唯一可链接来源：TrainingPlanningContext 传入的已确认(reviewed)结果 seed。未审核的原始数据不可链接。
  const linkedEvidenceRefs = session.linkedEvidenceRefs || [];
  const confirmedContext = (trainingContextSeed && trainingContextSeed.reviewedStatus === 'reviewed') ? trainingContextSeed : null;
  const alreadyLinked = confirmedContext ? linkedEvidenceRefs.some(r => r.refId === confirmedContext.id) : false;
  const [linkError, setLinkError] = trS(null);
  // REV-R3: generalized selector — all confirmed (reviewed) rows for THIS athlete,
  // across the five review types (cmj/sj/imtp/field/manual). Superset entry point;
  // the CMJ seed one-click path above stays as-is. Selector source is already
  // reviewed-only (ReviewedEvidenceRows viewmodel), so no unreviewed data appears.
  const [selectedRowId, setSelectedRowId] = trS('');
  // 归因兼容：EvidenceUIContract 行（sj/imtp/field）的 athleteId 嵌在 sourceRef 里，
  // 只有 cmj/manual 行有顶层 athleteId——两处都查，缺失时退 athleteName 精确匹配（镜像 Story/brief 纪律）。
  const athleteConfirmedRows = trM(
    () => (confirmedResultRows || []).filter(r => {
      if (!r || !athlete) return false;
      const rid = r.athleteId || r.sourceRef?.athleteId;
      return rid ? rid === athlete.id : (!!r.athleteName && r.athleteName === athlete.name);
    }),
    [confirmedResultRows, athlete?.id, athlete?.name],
  );
  const refFromRow = (row) => ({
    refId: row.id,
    testType: row.evidenceTypeLabel || row.sourceType || '',
    date: String(row.timestamp || '').slice(0, 10),
    label: `${row.athleteName} · ${row.primaryLabel}`,
    summary: row.secondaryLabel || '',
    linkedAt: new Date().toISOString(),
  });
  const linkSelectedResult = () => {
    if (!selectedRowId) return;
    const row = athleteConfirmedRows.find(r => r.id === selectedRowId);
    if (!row || linkedEvidenceRefs.some(r => r.refId === row.id)) return;
    persistSessionRefs([...linkedEvidenceRefs, refFromRow(row)]);
    setSelectedRowId('');
  };

  const persistSessionRefs = async (nextRefs) => {
    setLinkError(null);
    const nextSessions = sessions.map((s, i) => i === sessionIdx ? { ...s, linkedEvidenceRefs: nextRefs } : s);
    const nextProgram = { ...programState, sessions: nextSessions };
    try {
      if (onPersistProgram) await onPersistProgram(nextProgram);
      setProgramState(nextProgram);
    } catch (e) {
      setLinkError(e?.message || String(e));
    }
  };
  const linkConfirmedResult = () => {
    if (!confirmedContext || alreadyLinked) return;
    const ref = {
      refId: confirmedContext.id,
      testType: confirmedContext.sourceType || '',
      date: String(confirmedContext.sessionDate || '').slice(0, 10),
      label: `${confirmedContext.athleteName} · ${confirmedContext.acceptedClassification}`,
      summary: confirmedContext.reviewerTrainingFocus || '',
      linkedAt: new Date().toISOString(),
    };
    persistSessionRefs([...linkedEvidenceRefs, ref]);
  };
  const unlinkEvidenceRef = (refId) => persistSessionRefs(linkedEvidenceRefs.filter(r => r.refId !== refId));

  const saveFeedback = async () => {
    setFeedbackError(null);
    try {
      const saved = await FS.saveSessionFeedback(program.id, athlete.id, date, session.id, {
        completed: feedbackDraft.completed,
        sessionRPE: feedbackDraft.sessionRPE === '' ? null : +feedbackDraft.sessionRPE,
        durationMin: feedbackDraft.durationMin === '' ? null : +feedbackDraft.durationMin,
        athleteComment: feedbackDraft.athleteComment,
        coachComment: feedbackDraft.coachComment,
      });
      setFeedbackBySession(prev => ({ ...prev, [session.id]: saved.sessionFeedback[session.id] }));
      setFeedbackSavedAt(Date.now());
      const loadSync = window.TrainingLoadSync;
      if (loadSync && patchAthleteMetrics && FS.listTrainingLogsByAthlete) {
        try {
          const dayLogs = await FS.listTrainingLogsByAthlete(athlete.id, date, date);
          const aggregate = loadSync.aggregateDay(dayLogs, date);
          const syncPlan = loadSync.planProfileSync(athlete, date, aggregate);
          if (syncPlan.apply) patchAthleteMetrics(athlete.id, date, syncPlan.patch, syncPlan.sourceMeta);
        } catch (syncError) {
          console.warn('Training feedback saved but profile load sync failed:', syncError);
          setFeedbackError('训练反馈已保存，但个人档案中的日负荷未同步；请稍后重试。');
        }
      }
    } catch (e) {
      setFeedbackError(e?.message || String(e));
    }
  };

  return (
    <div className="training-execution-workspace" data-training-execution style={{ padding: '16px 20px', maxWidth: 980 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12, flexWrap: 'wrap' }}>
        <button className="btn" style={{ fontSize: 12 }} onClick={onBack}>← Programs</button>
        <div>
          <div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text)' }}>{program.name} · {athlete?.name}</div>
          <div style={{ fontSize: 11, color: 'var(--muted)' }}>{date} · 加载处方 → 训练中逐组填实际 · 自动保存</div>
        </div>
        <span data-training-actual-save-state={actualSaveState} style={{ marginLeft: 'auto', fontSize: 11, color: actualSaveState === 'error' ? 'var(--neg)' : savedAt ? 'var(--pos)' : 'var(--muted-2)' }}>
          {actualSaveState === 'saving' ? '保存中…' : actualSaveState === 'error' ? '保存失败' : savedAt ? '已保存 ✓' : (loaded ? '就绪' : '加载中…')}
        </span>
      </div>
      {actualSaveError && <div role="alert" style={{ margin: '-4px 0 10px', padding: '8px 10px', borderRadius: 6, background: 'color-mix(in srgb, var(--neg) 8%, transparent)', border: '1px solid color-mix(in srgb, var(--neg) 28%, transparent)', color: 'var(--neg)', fontSize: 11.5 }}>
        {actualSaveError} 最近一次修改未确认写入本地数据库。
      </div>}

      {/* session picker */}
      {sessions.length > 1 && (
        <div style={{ display: 'flex', gap: 6, marginBottom: 12, flexWrap: 'wrap' }}>
          {sessions.map((s, i) => (
            <button key={s.id} onClick={() => setSessionIdx(i)} style={{
              padding: '5px 11px', borderRadius: 6, cursor: 'pointer', fontSize: 12,
              background: i === sessionIdx ? 'var(--accent)' : 'var(--panel-hi)', color: i === sessionIdx ? '#fff' : 'var(--text-2)',
              border: `1px solid ${i === sessionIdx ? 'var(--accent)' : 'var(--border)'}`,
            }}>{program.structureType === 'periodized' && s.week ? `W${s.week} · ` : ''}{s.name}</button>
          ))}
        </div>
      )}

      {/* totals bar */}
      <div style={{ display: 'flex', gap: 18, padding: '10px 14px', background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 8, marginBottom: 12, fontSize: 12, flexWrap: 'wrap' }}>
        <span style={{ color: 'var(--muted)' }}>完成 <b style={{ color: 'var(--text)', fontFamily: 'var(--font-mono)' }}>{totals.doneSets}/{totals.totalSets}</b> 组</span>
        <span style={{ color: 'var(--muted)' }}>处方 VL <b style={{ color: 'var(--text)', fontFamily: 'var(--font-mono)' }}>{totals.presc}</b></span>
        <span style={{ color: 'var(--muted)' }}>实际 VL <b style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)' }}>{totals.act}</b></span>
        {totals.presc > 0 && <span style={{ color: 'var(--muted)' }}>完成率 <b style={{ color: 'var(--text)', fontFamily: 'var(--font-mono)' }}>{Math.round(totals.act / totals.presc * 100)}%</b></span>}
      </div>

      {/* Why this focus? · 训练依据 — 手动链接的已确认结果；纯记录，不改变任何训练计划内容 */}
      <div className="tr-card" style={{ padding: '12px 16px', marginBottom: 12 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
          <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--mac-text)' }}>训练依据 · Why this focus?</span>
          {confirmedContext
            ? (
              <button className={`btn${alreadyLinked ? ' tr-chip-info' : ''}`} onClick={linkConfirmedResult} disabled={alreadyLinked}
                style={{ marginLeft: 'auto', fontSize: 11, padding: '4px 9px', opacity: alreadyLinked ? 0.5 : 1, cursor: alreadyLinked ? 'default' : 'pointer' }}>
                {alreadyLinked ? '已链接 Linked' : '链接已确认结果 Link confirmed result'}
              </button>
            )
            : null}
        </div>
        {/* REV-R3 selector — manually link any confirmed result (5 review types) for this athlete. */}
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
          {athleteConfirmedRows.length === 0
            ? <span style={{ fontSize: 10.5, color: 'var(--mac-muted)' }}>该运动员暂无已确认结果 · No confirmed result for this athlete</span>
            : (
              <React.Fragment>
                <select value={selectedRowId} onChange={e => setSelectedRowId(e.target.value)}
                  style={{ flex: 1, minWidth: 180, fontSize: 11, padding: '4px 7px', borderRadius: 8, border: '1px solid var(--mac-stroke)', background: 'var(--mac-soft)', color: 'var(--mac-text)' }}>
                  <option value="">选择已确认结果… · Choose a confirmed result…</option>
                  {athleteConfirmedRows.map(row => (
                    <option key={row.id} value={row.id} disabled={linkedEvidenceRefs.some(r => r.refId === row.id)}>
                      {row.evidenceTypeLabel} · {row.primaryLabel}{row.timestamp ? ` · ${String(row.timestamp).slice(0, 10)}` : ''}
                    </option>
                  ))}
                </select>
                <button className="btn" onClick={linkSelectedResult} disabled={!selectedRowId}
                  style={{ fontSize: 11, padding: '4px 9px', opacity: selectedRowId ? 1 : 0.5, cursor: selectedRowId ? 'pointer' : 'default' }}>
                  链接 Link
                </button>
              </React.Fragment>
            )}
        </div>
        {linkError && <div style={{ fontSize: 11, color: 'var(--mac-red)', marginBottom: 8 }}>{linkError}</div>}
        {linkedEvidenceRefs.length === 0
          ? <div style={{ fontSize: 12, color: 'var(--mac-muted)' }}>暂无已确认结果链接 · No confirmed result linked to this session.</div>
          : (
            <div style={{ display: 'grid', gap: 0 }}>
              {linkedEvidenceRefs.map(ref => (
                <div key={ref.refId} className="tr-row" style={{ background: 'var(--mac-soft)', border: '1px solid var(--mac-stroke)', borderRadius: 12, padding: '9px 11px', marginBottom: 8 }}>
                  <div style={{ minWidth: 0, flex: 1 }}>
                    <div style={{ fontSize: 11.5, fontWeight: 600, color: 'var(--mac-text)' }}>{ref.label}</div>
                    <div style={{ fontSize: 10.5, color: 'var(--mac-muted)', marginTop: 1 }}>{ref.date || '—'}</div>
                    {ref.summary && <div style={{ fontSize: 11.5, color: 'var(--mac-text)', marginTop: 3 }}>{ref.summary}</div>}
                  </div>
                  <button className="btn" onClick={() => unlinkEvidenceRef(ref.refId)} style={{ fontSize: 10.5, padding: '3px 8px' }}>解链 Remove</button>
                </div>
              ))}
            </div>
          )}
      </div>

      {/* blocks */}
      {session.blocks.length === 0 && <div style={{ padding: 40, textAlign: 'center', color: 'var(--muted)', border: '1px dashed var(--border)', borderRadius: 8 }}>这一训练日还没有动作。</div>}
      {session.blocks.map((block, bi) => {
        const rm = oneRMOf(block);
        return (
          <div key={block.id} className="tr-card" style={{ padding: '12px 14px', marginBottom: 12 }}>
            <div className="tr-card-head tr-row" style={{ padding: 0, border: 0, marginBottom: 8, justifyContent: 'flex-start' }}>
              <span className="tr-badge" style={{ fontSize: 11, fontWeight: 700 }}>{trBlockLetter(bi)}</span>
              {block.group && <span style={{ fontSize: 11, fontWeight: 700, color: 'var(--accent)' }}>{block.group}</span>}
              <span style={{ fontSize: 14, fontWeight: 600 }}>{block.exerciseName}</span>
              {block.groupType !== 'straight' && <span style={{ fontSize: 10, color: 'var(--muted)' }}>{(TR_GROUP_TYPES.find(g => g.id === block.groupType) || {}).label}</span>}
              {trBlockAnnotation(block) && <span style={{ fontSize: 10.5, fontFamily: 'var(--font-mono)', color: 'var(--muted)' }}>{trBlockAnnotation(block)}</span>}
              {rm != null && <span style={{ fontSize: 10, color: 'var(--muted-2)', marginLeft: 'auto' }}>1RM {rm}kg</span>}
            </div>
            <div style={{ display: 'grid', gridTemplateColumns: executionGrid, gap: 7, alignItems: 'center', fontSize: 9.5, color: 'var(--muted)', marginBottom: 4 }}>
              <span>Set</span><span>处方 Prescribed</span><span style={{ textAlign: 'center' }}>实际kg</span><span style={{ textAlign: 'center' }}>实际reps</span><span style={{ textAlign: 'center' }}>RIR/RPE</span>{showVbt && <span style={{ textAlign: 'center' }}>实际m/s</span>}<span style={{ textAlign: 'center' }}>✓</span>
            </div>
            {block.sets.map((s, i) => {
              const r = rowOf(block, i);
              const pw = prescWeight(block, s);
              const extras = trPrescriptionExtras(s);
              const prescLabel = `${pw != null ? pw + 'kg' : '—'}${s.pct1rm ? ` (${s.pct1rm}%)` : ''} × ${s.reps || '—'}${s.rir !== '' && s.rir != null ? ` · RIR${s.rir}` : (s.rpe ? ` · RPE${s.rpe}` : '')}${extras.length ? ` · ${extras.join(' · ')}` : ''}`;
              return (
                <div key={i} style={{ display: 'grid', gridTemplateColumns: executionGrid, gap: 7, alignItems: 'center', marginBottom: 6, opacity: r.done ? 1 : 0.92 }}>
                  <span style={{ fontSize: 12, color: 'var(--muted-2)', fontFamily: 'var(--font-mono)', textAlign: 'center' }}>{i + 1}</span>
                  <span style={{ fontSize: 11.5, color: 'var(--text-2)', fontFamily: 'var(--font-mono)' }}>{prescLabel}</span>
                  <input value={r.weight} onChange={e => patchRow(block, i, { weight: e.target.value })} style={bigCell}/>
                  <input value={r.reps} onChange={e => patchRow(block, i, { reps: e.target.value })} style={bigCell}/>
                  <input value={r.rir !== '' && r.rir != null ? r.rir : r.rpe} onChange={e => patchRow(block, i, { rir: e.target.value })} placeholder="—" style={bigCell}/>
                  {showVbt && <input value={r.meanVelocityMS} onChange={e => patchRow(block, i, { meanVelocityMS: e.target.value, velocitySource: r.velocitySource || 'manual' })} placeholder="—" title={`速度来源: ${r.velocitySource || 'manual'}`} style={bigCell}/>}
                  <button onClick={() => patchRow(block, i, { done: !r.done })} style={{
                    width: 38, height: 38, borderRadius: 8, cursor: 'pointer', fontSize: 16, justifySelf: 'center',
                    background: r.done ? 'var(--pos)' : 'var(--panel-hi)', color: r.done ? '#fff' : 'var(--muted)',
                    border: `1px solid ${r.done ? 'var(--pos)' : 'var(--border)'}`,
                  }}>{r.done ? '✓' : '○'}</button>
                </div>
              );
            })}
          </div>
        );
      })}

      {/* Session Feedback · 本次训练反馈 — 纯记录，不做疲劳/状态推断 */}
      <div className="tr-card" style={{ padding: '14px 16px', marginTop: 4 }}>
        <div className="tr-card-head" style={{ padding: 0, border: 0, marginBottom: 10 }}>
          <span style={{ fontSize: 14, fontWeight: 600, color: 'var(--mac-text)' }}>本次训练反馈 · Session Feedback</span>
          {feedbackSavedAt && (
            <span className="tr-chip tr-chip-ok">{`已保存 ${new Date(feedbackSavedAt).toLocaleTimeString()} ✓`}</span>
          )}
        </div>

        <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
          <button onClick={() => setFeedbackDraft(d => ({ ...d, completed: !d.completed }))} style={{
            width: 34, height: 34, borderRadius: 8, cursor: 'pointer', fontSize: 15,
            background: feedbackDraft.completed ? 'var(--mac-green)' : 'var(--mac-soft)', color: feedbackDraft.completed ? '#fff' : 'var(--mac-muted)',
            border: `1px solid ${feedbackDraft.completed ? 'var(--mac-green)' : 'var(--mac-stroke)'}`,
          }}>{feedbackDraft.completed ? '✓' : '○'}</button>
          <span style={{ fontSize: 12, color: 'var(--mac-text)' }}>完成 Completed</span>
        </div>

        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 10 }}>
          <label>
            <TrLabel style={{ color: 'var(--mac-muted)' }}>Session RPE (1-10) · 本次RPE</TrLabel>
            <input type="number" min="1" max="10" step="1" value={feedbackDraft.sessionRPE}
              onChange={e => setFeedbackDraft(d => ({ ...d, sessionRPE: e.target.value }))}
              placeholder="—"
              style={{ ...trInput, marginTop: 3, borderColor: rpeValid ? 'var(--mac-stroke)' : 'var(--mac-red)' }}/>
            {!rpeValid && <div style={{ fontSize: 10.5, color: 'var(--mac-red)', marginTop: 3 }}>须为1-10整数 · must be an integer 1-10</div>}
          </label>
          <label>
            <TrLabel style={{ color: 'var(--mac-muted)' }}>时长 Duration (min)</TrLabel>
            <input type="number" min="0" step="1" value={feedbackDraft.durationMin}
              onChange={e => setFeedbackDraft(d => ({ ...d, durationMin: e.target.value }))}
              placeholder="—"
              style={{ ...trInput, marginTop: 3, borderColor: durationValid ? 'var(--mac-stroke)' : 'var(--mac-red)' }}/>
            {!durationValid && <div style={{ fontSize: 10.5, color: 'var(--mac-red)', marginTop: 3 }}>须为正数 · must be a positive number</div>}
          </label>
        </div>

        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 12 }}>
          <label>
            <TrLabel style={{ color: 'var(--mac-muted)' }}>运动员评语 Athlete Comment</TrLabel>
            <input value={feedbackDraft.athleteComment} onChange={e => setFeedbackDraft(d => ({ ...d, athleteComment: e.target.value }))}
              placeholder="今天感觉如何… / how it felt…" style={{ ...trInput, marginTop: 3 }}/>
          </label>
          <label>
            <TrLabel style={{ color: 'var(--mac-muted)' }}>教练评语 Coach Comment</TrLabel>
            <input value={feedbackDraft.coachComment} onChange={e => setFeedbackDraft(d => ({ ...d, coachComment: e.target.value }))}
              placeholder="教练备注… / coach notes…" style={{ ...trInput, marginTop: 3 }}/>
          </label>
        </div>

        {feedbackError && <div style={{ fontSize: 11.5, color: 'var(--mac-red)', marginBottom: 8 }}>{feedbackError}</div>}

        <button className="btn primary" disabled={!feedbackCanSave} onClick={saveFeedback}
          style={{ fontSize: 12, opacity: feedbackCanSave ? 1 : 0.5, cursor: feedbackCanSave ? 'pointer' : 'default' }}>
          保存反馈 Save Feedback
        </button>
      </div>
    </div>
  );
}

// ── AthleteTrainingCard (individual page link · 查看/追踪) ───────────────────
const trLogPct = (log) => {
  let done = 0, total = 0;
  Object.values(log.actuals || {}).forEach(arr => (arr || []).forEach(r => { total += 1; if (r.done) done += 1; }));
  return total ? Math.round(done / total * 100) : 0;
};

function AthleteTrainingCard({ athlete, onOpen }) {
  const FS = window.FieldDataStore;
  const [programs, setPrograms] = trS([]);
  const [logsByProg, setLogsByProg] = trS({});   // { [programId]: { latest, series:[{date,pct}] } }

  trE(() => {
    let alive = true;
    (async () => {
      if (!FS || !athlete) return;
      try {
        const ok = await FS.healthCheck(); if (!ok || !alive) return;
        await FS.init('default');
        const all = await FS.listPrograms();
        const mine = all.filter(p => p.mode === 'team' ? (p.athleteIds || []).includes(athlete.id) : p.athleteId === athlete.id);
        if (!alive) return;
        setPrograms(mine);
        const lb = {};
        for (const p of mine) {
          const logs = (await FS.listTrainingLogsByProgram(p.id)).filter(l => l.athleteId === athlete.id);
          logs.sort((a, b) => String(b.date).localeCompare(String(a.date)));
          if (logs[0]) lb[p.id] = { latest: logs[0], series: logs.slice(0, 8).reverse().map(l => ({ date: l.date, pct: trLogPct(l) })) };
        }
        if (alive) setLogsByProg(lb);
      } catch (e) { console.warn('AthleteTrainingCard load failed', e); }
    })();
    return () => { alive = false; };
  }, [athlete?.id]);

  if (!FS) return null;
  const Panel = window.Panel, Pill = window.Pill, ModuleArrow = window.ModuleArrow;

  // primary = program with the most recent log (else first assigned)
  const primary = programs.length
    ? programs.slice().sort((a, b) => String((logsByProg[b.id] || {}).latest?.date || '').localeCompare(String((logsByProg[a.id] || {}).latest?.date || '')))[0]
    : null;
  const lp = primary ? logsByProg[primary.id] : null;
  const pct = lp ? trLogPct(lp.latest) : null;
  const sessions = primary ? (primary.sessions || []).length : 0;
  const pctTone = pct == null ? 'neutral' : pct >= 80 ? 'pos' : pct >= 50 ? 'warn' : 'neg';
  const series = (lp && lp.series.length >= 2) ? lp.series : null;

  const inner = !primary ? (
    <div style={{ fontSize: 12, color: 'var(--muted)' }}>
      暂无指派给该运动员的训练计划。
      <button onClick={onOpen} style={{ background: 'none', border: 0, color: 'var(--accent)', cursor: 'pointer', fontSize: 12, padding: 0, marginLeft: 4 }}>去创建 →</button>
    </div>
  ) : (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 11 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 9, minWidth: 0 }}>
        <span style={{ fontSize: 9, fontWeight: 700, padding: '1px 5px', borderRadius: 3, color: '#fff', flexShrink: 0, background: primary.mode === 'team' ? '#a78bfa' : 'var(--accent)' }}>{primary.mode === 'team' ? '团队' : '个人'}</span>
        <span style={{ fontSize: 15, fontWeight: 600, color: 'var(--text)', minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{primary.name}</span>
      </div>
      <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
        {pct != null
          ? <><Pill tone={pctTone}>依从 {pct}%</Pill><Pill>最近 {lp.latest.date}</Pill></>
          : <><Pill>{sessions} 次训练</Pill><Pill>未执行</Pill></>}
      </div>
      {series && (
        <div>
          <div style={{ display: 'flex', alignItems: 'flex-end', gap: 4, height: 34 }}>
            {series.map((s, i) => {
              const c = s.pct >= 80 ? 'var(--pos)' : s.pct >= 50 ? 'var(--warn)' : 'var(--neg)';
              return <div key={i} title={`${s.date}: 依从 ${s.pct}%`} style={{ flex: 1, height: `${Math.max(8, s.pct)}%`, minHeight: 4, borderRadius: '3px 3px 0 0', background: c, opacity: i === series.length - 1 ? 1 : 0.45 }}/>;
            })}
          </div>
          <div style={{ fontSize: 9, color: 'var(--muted-2)', textAlign: 'right', marginTop: 2 }}>依从率 · 最近 {series.length} 次</div>
        </div>
      )}
    </div>
  );
  return Panel
    ? <Panel title="Training Plans · 训练计划" subtitle={`${athlete?.name} · ${programs.length} assigned`} rightAction={ModuleArrow && <ModuleArrow onClick={onOpen} title="进入训练计划" />}>{inner}</Panel>
    : <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10, padding: 14 }}>{inner}</div>;
}

Object.assign(window, { TrainingView, AthleteTrainingCard, ExercisePicker, trSmoothPath });
