// team.jsx  v10  —  团队/队伍仪表盘：运动员卡片矩阵 · Coach 模式 InsightFeed
// 职责：渲染全队概览、排行榜、ACWR 风险提示；InsightFeed 在 Coach 模式下显示自动洞察
// 依赖：charts.jsx · components.jsx · data.js（ACWR / metricScore01）

const { useState: tUseState, useMemo: tUseMemo, useEffect: tUseEffect, useRef: tUseRef } = React;

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

const ALGO_LIST = [
  { id: 'range',      name: 'Range mapping', short: 'Range',   formula: '(v − worst) / (best − worst) · 100' },
  { id: 'zscore',     name: 'Z-score',       short: 'Z-score', formula: 'clamp((z + 2) / 4) · 100' },
  { id: 'percentile', name: 'Percentile',    short: '%ile',    formula: 'rank(v) / N · 100' },
];

// ── helpers ────────────────────────────────────────────────────────────────
function pct(sortedArr, p) {
  if (!sortedArr.length) return 0;
  const idx = (p / 100) * (sortedArr.length - 1);
  const lo = Math.floor(idx), hi = Math.ceil(idx);
  return lo === hi ? sortedArr[lo] : sortedArr[lo] + (sortedArr[hi] - sortedArr[lo]) * (idx - lo);
}


// DATE-ROBUSTNESS guard (audit 2026-07-07). A malformed date string (e.g. the
// season key "2025/7/5 9") parses to NaN via `+new Date`, poisoning
// `Math.min/max(...dates)` so every point collapses to x=0 (data piles at the
// left). Every time-axis chart drops unplottable-date points before scaling.
// Mirror of charts.jsx trPlottablePoints; also guards ACWRChart below.
const trPlottableDate = (x) => !isNaN(+new Date(x));
const trPlottablePoints = (points) => (points || []).filter(p => p && trPlottableDate(p.x));

// ──────────────────────────────────────────────────────────────────────────
// Custom team trend SVG chart — multi-series with per-series ±SD bands
// series: [{ label, color, values:[{x:iso,y}], bands:[{x:iso,upper,lower}] }]
// ──────────────────────────────────────────────────────────────────────────
function TeamTrendChart({ series, height = 240, trendConfig }) {
  // Drop unplottable-date points up front so bad dates can't NaN-poison the scale.
  series = (series || []).map(s => ({
    ...s,
    values: trPlottablePoints(s.values),
    bands: s.bands ? trPlottablePoints(s.bands) : s.bands,
  }));
  const pad = { top: 28, right: 56, bottom: 38, left: 40 };
  const [w, setW] = tUseState(640);
  const [hover, setHover] = tUseState(null);
  const ref = tUseRef(null);

  tUseEffect(() => {
    if (!ref.current) return;
    const ro = new ResizeObserver(entries => {
      for (const e of entries) { if (e.contentRect.width > 160) setW(e.contentRect.width); }
    });
    ro.observe(ref.current);
    return () => ro.disconnect();
  }, []);

  const allDates = series.flatMap(s => s.values.map(v => +new Date(v.x)));
  const tMin = allDates.length ? Math.min(...allDates) : 0;
  const tMax = allDates.length ? Math.max(...allDates) : 1;
  const tSpan = Math.max(1, tMax - tMin);
  const W = w, H = height;
  const iW = Math.max(40, W - pad.left - pad.right);
  const iH = H - pad.top - pad.bottom;

  const xAt = (iso) => pad.left + (iW * (+new Date(iso) - tMin)) / tSpan;
  const yAt = (y)   => pad.top  + iH * (1 - (Math.max(0, Math.min(100, y)) / 100));

  // x-ticks: show up to 8 evenly-spaced dates
  const allIso = [...new Set(series.flatMap(s => s.values.map(v => v.x)))].sort();
  const step = Math.max(1, Math.ceil(allIso.length / 8));
  const xTicks = allIso.filter((_, i) => i % step === 0 || i === allIso.length - 1);

  const linePath = (values) => values.map((v, i) => `${i===0?'M':'L'}${xAt(v.x)},${yAt(v.y)}`).join(' ');
  const bandPath = (bands) => {
    if (!bands || bands.length < 2) return null;
    const top = bands.map(b => `${xAt(b.x)},${yAt(b.upper)}`).join(' L');
    const bot = [...bands].reverse().map(b => `${xAt(b.x)},${yAt(b.lower)}`).join(' L');
    return `M${top} L${bot} Z`;
  };

  return (
    <div ref={ref} style={{ width: '100%', position: 'relative' }}>
      <svg width={W} height={H} style={{ display: 'block', overflow: 'visible' }}>
        {/* y-grid 0/25/50/75/100 */}
        {[0, 25, 50, 75, 100].map(t => (
          <g key={t}>
            <line x1={pad.left} x2={W-pad.right} y1={yAt(t)} y2={yAt(t)} stroke="rgba(15,23,42,.05)" strokeWidth="1"/>
            <text x={pad.left-6} y={yAt(t)} textAnchor="end" dominantBaseline="middle"
              fill="var(--muted)" fontSize="10" fontFamily="var(--font-mono)">{t}</text>
          </g>
        ))}
        {/* x-axis */}
        <line x1={pad.left} x2={W-pad.right} y1={H-pad.bottom} y2={H-pad.bottom} stroke="rgba(15,23,42,.09)"/>
        {xTicks.map(iso => {
          const x = xAt(iso);
          return (
            <g key={iso}>
              <line x1={x} x2={x} y1={H-pad.bottom} y2={H-pad.bottom+4} stroke="rgba(15,23,42,.18)"/>
              <text x={x} y={H-pad.bottom+16} textAnchor="middle" fill="var(--muted)" fontSize="9" fontFamily="var(--font-mono)">
                {window.formatDate ? window.formatDate(iso, 'month') : iso.slice(0,7)}
              </text>
            </g>
          );
        })}
        {/* SD bands */}
        {series.map(s => {
          const bp = bandPath(s.bands);
          return bp ? <path key={s.label+'-b'} d={bp} fill={s.color} fillOpacity="0.12" stroke="none"/> : null;
        })}
        {/* benchmark line (option 3) — dashed at score 50 */}
        {trendConfig?.showBenchmarkLine && (
          <g>
            <line
              x1={pad.left} x2={W - pad.right}
              y1={yAt(50)} y2={yAt(50)}
              stroke="rgba(251,191,36,.55)" strokeWidth="1.5" strokeDasharray="5 4"
              style={{ pointerEvents: 'none' }}
            />
            <text x={W - pad.right + 4} y={yAt(50)} dominantBaseline="middle"
              fill="rgba(251,191,36,.8)" fontSize="9" fontFamily="var(--font-mono)">
              Ref 50
            </text>
          </g>
        )}
        {/* lines + dots */}
        {series.map((s, si) => {
          if (!s.values.length) return null;
          const last = s.values[s.values.length - 1];
          return (
            <g key={s.label}>
              <path d={linePath(s.values)} fill="none" stroke={s.color} strokeWidth="2.5"
                strokeLinejoin="round" strokeLinecap="round"
                style={{ filter: `drop-shadow(0 0 5px ${s.color}44)` }}/>
              {s.values.map((v, vi) => {
                const isHov = hover && hover.si === si && hover.vi === vi;
                return (
                  <g key={vi}>
                    <circle cx={xAt(v.x)} cy={yAt(v.y)} r={12} fill="transparent" style={{ cursor: 'pointer' }}
                      onMouseEnter={() => setHover({ si, vi, v, s, prev: vi > 0 ? s.values[vi-1] : null })}
                      onMouseLeave={() => setHover(null)}/>
                    <circle cx={xAt(v.x)} cy={yAt(v.y)} r={isHov ? 5 : 3.5}
                      fill="var(--panel)" stroke={s.color} strokeWidth={isHov ? 2.5 : 2}
                      style={{ pointerEvents: 'none', transition: 'r .12s' }}/>
                    {/* data labels (option 1) — show value above each point, skip if hovered */}
                    {trendConfig?.showDataLabels && !isHov && (
                      <text
                        x={xAt(v.x)} y={yAt(v.y) - 8}
                        textAnchor="middle" dominantBaseline="auto"
                        fill={s.color} fontSize="9" fontFamily="var(--font-mono)"
                        style={{ pointerEvents: 'none' }} opacity="0.9">
                        {Math.round(v.y)}
                      </text>
                    )}
                  </g>
                );
              })}
              <text x={xAt(last.x)+8} y={yAt(last.y)} dominantBaseline="middle"
                fill={s.color} fontSize="11" fontFamily="var(--font-mono)" fontWeight="600">
                {Math.round(last.y)}
              </text>
            </g>
          );
        })}
        {hover && (
          <line x1={xAt(hover.v.x)} x2={xAt(hover.v.x)} y1={pad.top} y2={H-pad.bottom}
            stroke={hover.s.color} strokeWidth="1" strokeDasharray="2 3" opacity="0.4"
            style={{ pointerEvents: 'none' }}/>
        )}
      </svg>
      {hover && (() => {
        const tx = xAt(hover.v.x), ty = yAt(hover.v.y);
        const band = hover.s.bands?.find(b => b.x === hover.v.x);
        const sd = band ? Math.round((band.upper - band.lower) / 2) : null;
        const delta = hover.prev ? Math.round(hover.v.y - hover.prev.y) : null;
        return (
          <div style={{
            position: 'absolute',
            left: Math.max(8, Math.min(W-8, tx)),
            top: ty > 80 ? ty - 12 : ty + 14,
            transform: ty > 80 ? 'translate(-50%,-100%)' : 'translate(-50%,0)',
            background: 'var(--panel-2)', border: `1px solid ${hover.s.color}`,
            borderRadius: 6, padding: '8px 10px', fontSize: 11,
            pointerEvents: 'none', boxShadow: '0 8px 20px rgba(0,0,0,.5)',
            minWidth: 150, zIndex: 5, color: 'var(--text)',
          }}>
            <div style={{ display:'flex', justifyContent:'space-between', gap:8, marginBottom:4 }}>
              <span style={{ fontSize: 10, color: hover.s.color, fontWeight: 600 }}>{hover.s.label}</span>
              <span style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>
                {window.formatDate ? window.formatDate(hover.v.x, 'medium') : hover.v.x}
              </span>
            </div>
            <div style={{ display:'flex', alignItems:'baseline', gap:6 }}>
              <span className="mono" style={{ fontSize: 18, fontWeight: 600, color: hover.s.color }}>{Math.round(hover.v.y)}</span>
              {sd != null && <span style={{ fontSize: 11, color: 'var(--muted)' }}>± {sd} SD</span>}
            </div>
            {delta != null && (
              <div style={{ marginTop: 4, fontSize: 11, fontFamily: 'var(--font-mono)',
                color: delta > 0 ? 'var(--pos)' : delta < 0 ? 'var(--neg)' : 'var(--muted)' }}>
                {delta > 0 ? '▲' : delta < 0 ? '▼' : '–'} {Math.abs(delta)} vs prev
              </div>
            )}
          </div>
        );
      })()}
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────────────
// ACWR chart — acute/chronic workload ratio with risk zones
// ──────────────────────────────────────────────────────────────────────────
function ACWRChart({ athletes, groups, seasons, squadStatsAll, algo, height = 200, annotations = null, onPointClick = null }) {
  const D = window.DASHBOARD_DATA;
  // Drop unplottable season keys (malformed dates) so they can't NaN-poison the
  // time scale below (allTs/tMin/tMax) and pile every point at the left.
  const sorted = tUseMemo(() => [...seasons].filter(trPlottableDate).sort((a, b) => +new Date(a) - +new Date(b)), [seasons]);
  const [w, setW] = tUseState(640);
  const [hover, setHover] = tUseState(null);
  const ref = tUseRef(null);

  tUseEffect(() => {
    if (!ref.current) return;
    const ro = new ResizeObserver(es => { for (const e of es) if (e.contentRect.width > 160) setW(e.contentRect.width); });
    ro.observe(ref.current);
    return () => ro.disconnect();
  }, []);

  const acwrData = tUseMemo(() => {
    // Prefer the real per-athlete acwr field if every athlete has it populated
    // for at least half the season; otherwise fall back to the old
    // overall-score proxy. This lets the chart light up automatically once a
    // user starts logging sRPE + duration in Data Entry.
    const realLoads = sorted.map(s => {
      const loads = athletes
        .map(a => (a.seasons[s] || {}).session_load)
        .filter(v => v != null && isFinite(v));
      return { s, n: loads.length, mean: loads.length ? loads.reduce((a,b) => a+b, 0) / loads.length : null };
    });
    const realFillRate = realLoads.filter(r => r.n > 0).length / Math.max(1, sorted.length);

    if (realFillRate >= 0.4) {
      // Session-count windows (4-session ATL / 12-session CTL).
      // After computing, deduplicate points within a 7-day window — keeps only
      // the most recent point per week. This prevents visual zigzag when an
      // athlete logs multiple sessions in quick succession while leaving
      // monthly-spaced data completely unchanged.
      const raw = realLoads.map((sv, i) => {
        const acuteWin   = realLoads.slice(Math.max(0, i - 3),  i + 1).filter(r => r.mean != null);
        const chronicWin = realLoads.slice(Math.max(0, i - 11), i + 1).filter(r => r.mean != null);
        if (!acuteWin.length || !chronicWin.length) return null;
        const atl = acuteWin.reduce((s, r) => s + r.mean, 0) / acuteWin.length;
        const ctl = chronicWin.reduce((s, r) => s + r.mean, 0) / chronicWin.length;
        return { x: sv.s, y: ctl > 0 ? Math.round((atl / ctl) * 1000) / 1000 : 1, real: true };
      }).filter(Boolean);
      const MS_7D = 7 * 86400000;
      return raw.reduce((acc, pt) => {
        if (!acc.length) return [pt];
        const gap = +new Date(pt.x) - +new Date(acc[acc.length - 1].x);
        if (gap < MS_7D) acc[acc.length - 1] = pt; // replace with more recent
        else acc.push(pt);
        return acc;
      }, []);
    }

    // Fallback: legacy score-based proxy
    const sessions = sorted.map(s => {
      const ss = squadStatsAll[s];
      if (!ss || !athletes.length) return null;
      const total = athletes.reduce((sum, a) => sum + D.computeOverallScore(a.seasons[s] || {}, groups, ss, algo), 0);
      return { x: s, y: total / athletes.length };
    }).filter(Boolean);

    return sessions.map((sv, i) => {
      const n4  = sessions.slice(Math.max(0, i - 3), i + 1);
      const n12 = sessions.slice(Math.max(0, i - 11), i + 1);
      const atl = n4.reduce((s,v)  => s + v.y, 0) / n4.length;
      const ctl = n12.reduce((s,v) => s + v.y, 0) / n12.length;
      return { x: sv.x, y: ctl > 0 ? Math.round((atl / ctl) * 1000) / 1000 : 1, real: false };
    });
  }, [athletes, groups, sorted, squadStatsAll, algo]);
  // True if the current chart is sourced from real sRPE×duration (not the proxy)
  const isReal = acwrData[0]?.real === true;

  const pad = { top: 24, right: 56, bottom: 38, left: 48 };
  const W = w, H = height;
  const iW = Math.max(40, W - pad.left - pad.right);
  const iH = H - pad.top - pad.bottom;

  const allTs = acwrData.map(v => +new Date(v.x));
  const tMin = allTs.length ? Math.min(...allTs) : 0;
  const tMax = allTs.length ? Math.max(...allTs) : 1;
  const tSpan = Math.max(1, tMax - tMin);

  const yMin = 0.4, yMax = 2.0;
  const xAt = (iso) => pad.left + (iW * (+new Date(iso) - tMin)) / tSpan;
  const yAt = (y)   => pad.top  + iH * (1 - (y - yMin) / (yMax - yMin));

  // Risk zone bands (y in ACWR units)
  const zones = [
    { from: 0.4,  to: 0.8,  color: 'rgba(248,113,113,.12)', label: 'Under' },
    { from: 0.8,  to: 1.3,  color: 'rgba(52,211,153,.1)',   label: 'Optimal' },
    { from: 1.3,  to: 1.5,  color: 'rgba(251,191,36,.1)',   label: 'Caution' },
    { from: 1.5,  to: 2.0,  color: 'rgba(248,113,113,.15)', label: 'High' },
  ];

  const step = Math.max(1, Math.ceil(acwrData.length / 8));
  const xTicks = acwrData.filter((_, i) => i % step === 0 || i === acwrData.length - 1).map(v => v.x);
  const yTicks = [0.5, 0.8, 1.0, 1.3, 1.5, 1.8];
  const linePath = acwrData.map((v, i) => `${i===0?'M':'L'}${xAt(v.x)},${yAt(v.y)}`).join(' ');

  return (
    <div ref={ref} style={{ width: '100%', position: 'relative' }}>
      <svg width={W} height={H} style={{ display: 'block', overflow: 'visible' }}>
        {/* annotations layer (events: injury, intervention, competition) */}
        {annotations && annotations.length > 0 && window.AnnotationFlags && (() => {
          const Flags = window.AnnotationFlags;
          return (
            <Flags annotations={annotations}
              xScale={(dateStr) => {
                const t = +new Date(dateStr);
                if (!isFinite(t) || t < tMin || t > tMax) return null;
                return xAt(dateStr);
              }}
              hostTop={pad.top}
              hostBottom={H - pad.bottom}
            />
          );
        })()}
        {/* risk zone fills */}
        {zones.map(z => {
          const y1 = yAt(Math.min(z.to, yMax)), y2 = yAt(Math.max(z.from, yMin));
          if (y1 >= y2) return null;
          return <rect key={z.label} x={pad.left} y={y1} width={iW} height={y2-y1} fill={z.color}/>;
        })}
        {/* zone separator lines */}
        {[0.8, 1.3, 1.5].map(v => (
          <line key={v} x1={pad.left} x2={W-pad.right} y1={yAt(v)} y2={yAt(v)}
            stroke="var(--chart-grid-strong)" strokeWidth="1" strokeDasharray="3 4"/>
        ))}
        {/* x axis */}
        <line x1={pad.left} x2={W-pad.right} y1={H-pad.bottom} y2={H-pad.bottom} stroke="var(--chart-grid-strong)"/>
        {/* y ticks */}
        {yTicks.map(t => (
          <g key={t}>
            <text x={pad.left-6} y={yAt(t)} textAnchor="end" dominantBaseline="middle"
              fill="var(--muted-2)" fontSize="10" fontFamily="var(--font-mono)">{t.toFixed(1)}</text>
          </g>
        ))}
        {/* zone labels on right */}
        {zones.map(z => {
          const mid = (z.from + z.to) / 2;
          if (mid < yMin || mid > yMax) return null;
          return (
            <text key={z.label} x={W-pad.right+6} y={yAt(mid)} dominantBaseline="middle"
              fill="var(--muted-2)" fontSize="9" fontFamily="var(--font-sans)">{z.label}</text>
          );
        })}
        {/* x ticks */}
        {xTicks.map(iso => (
          <g key={iso}>
            <line x1={xAt(iso)} x2={xAt(iso)} y1={H-pad.bottom} y2={H-pad.bottom+4} stroke="var(--chart-axis)"/>
            <text x={xAt(iso)} y={H-pad.bottom+16} textAnchor="middle" fill="var(--muted-2)" fontSize="9" fontFamily="var(--font-mono)">
              {window.formatDate ? window.formatDate(iso, 'month') : iso.slice(0,7)}
            </text>
          </g>
        ))}
        {/* line */}
        {acwrData.length > 1 && (
          <path d={linePath} fill="none" stroke="var(--accent)" strokeWidth="2.5"
            strokeLinejoin="round" strokeLinecap="round"
            style={{ filter: 'drop-shadow(var(--chart-line-shadow, none) rgba(59,130,246,.45))' }}/>
        )}
        {/* dots */}
        {acwrData.map((v, vi) => {
          const zone = zones.find(z => v.y >= z.from && v.y < z.to) || zones[zones.length-1];
          const dotColor = zone.label === 'Optimal' ? 'var(--pos)' : zone.label === 'Caution' ? 'var(--warn)' : 'var(--neg)';
          const isHov = hover && hover.vi === vi;
          return (
            <g key={vi}>
              <circle cx={xAt(v.x)} cy={yAt(v.y)} r={12} fill="transparent" style={{ cursor: onPointClick ? 'crosshair' : 'pointer' }}
                onMouseEnter={() => setHover({ vi, v, prev: vi > 0 ? acwrData[vi-1] : null, color: dotColor })}
                onMouseLeave={() => setHover(null)}
                onClick={onPointClick ? () => onPointClick(v.x) : undefined}/>
              <circle cx={xAt(v.x)} cy={yAt(v.y)} r={isHov ? 5.5 : 4}
                fill="var(--panel)" stroke={dotColor} strokeWidth={isHov ? 2.5 : 2}
                style={{ pointerEvents: 'none', transition: 'r .12s' }}/>
            </g>
          );
        })}
        {hover && (
          <line x1={xAt(hover.v.x)} x2={xAt(hover.v.x)} y1={pad.top} y2={H-pad.bottom}
            stroke="var(--accent)" strokeWidth="1" strokeDasharray="2 3" opacity="0.4"
            style={{ pointerEvents: 'none' }}/>
        )}
      </svg>
      {hover && (() => {
        const tx = xAt(hover.v.x), ty = yAt(hover.v.y);
        const delta = hover.prev ? Math.round((hover.v.y - hover.prev.y) * 1000) / 1000 : null;
        const zone = zones.find(z => hover.v.y >= z.from && hover.v.y < z.to) || zones[zones.length-1];
        return (
          <div style={{
            position: 'absolute',
            left: Math.max(8, Math.min(W-8, tx)),
            top: ty > 80 ? ty - 12 : ty + 14,
            transform: ty > 80 ? 'translate(-50%,-100%)' : 'translate(-50%,0)',
            background: 'var(--panel-2)', border: `1px solid ${hover.color}`,
            borderRadius: 6, padding: '8px 10px', fontSize: 11,
            pointerEvents: 'none', boxShadow: '0 8px 20px rgba(0,0,0,.5)',
            minWidth: 160, zIndex: 5, color: 'var(--text)',
          }}>
            <div style={{ display:'flex', justifyContent:'space-between', gap:8, marginBottom:4 }}>
              <span style={{ fontSize: 10, color: hover.color, fontWeight: 600 }}>ACWR · {zone.label}</span>
              <span style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>
                {window.formatDate ? window.formatDate(hover.v.x, 'medium') : hover.v.x}
              </span>
            </div>
            <span className="mono" style={{ fontSize: 20, fontWeight: 600, color: hover.color }}>{hover.v.y.toFixed(2)}</span>
            {delta != null && (
              <div style={{ marginTop: 4, fontSize: 11, fontFamily: 'var(--font-mono)',
                color: Math.abs(delta) < 0.05 ? 'var(--muted)' : delta > 0 ? 'var(--warn)' : 'var(--pos)' }}>
                {delta > 0 ? '▲' : '▼'} {Math.abs(delta).toFixed(3)} vs prev
              </div>
            )}
          </div>
        );
      })()}
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────────────
// Season trend panel — Score mode (mean±SD) + ACWR mode
// ──────────────────────────────────────────────────────────────────────────
function SeasonTrendPanel({ athletes, groups, seasons, squadStatsAll, algo, trendConfig }) {
  const D = window.DASHBOARD_DATA;
  const sortedSeasons = tUseMemo(() => [...seasons].sort((a, b) => +new Date(a) - +new Date(b)), [seasons]);
  const [mode, setMode] = tUseState('score'); // 'score' | 'acwr'
  const [visibleIds, setVisibleIds] = tUseState(() => new Set(groups.map(g => g.id)));

  const allSelected = visibleIds.size === groups.length;
  const toggleAll = () => setVisibleIds(allSelected ? new Set([groups[0]?.id]) : new Set(groups.map(g => g.id)));
  const toggleGroup = (id) => setVisibleIds(prev => {
    const next = new Set(prev);
    if (next.has(id)) { if (next.size > 1) next.delete(id); }
    else next.add(id);
    return next;
  });

  const series = tUseMemo(() => {
    return groups.filter(g => visibleIds.has(g.id)).map(g => {
      const pts = sortedSeasons.map(s => {
        const ss = squadStatsAll[s];
        if (!ss || !athletes.length) return null;
        const scores = athletes.map(a => D.computeGroupScore(a.seasons[s] || {}, g, ss, algo));
        const mean = scores.reduce((a,b) => a+b, 0) / scores.length;
        const sd = Math.sqrt(scores.reduce((sum, v) => sum + (v-mean)**2, 0) / scores.length);
        return { x: s, y: Math.round(mean), sd: Math.round(sd * 10) / 10 };
      }).filter(Boolean);
      return {
        label: g.label, color: g.accent,
        values: pts.map(v => ({ x: v.x, y: v.y })),
        bands: pts.map(v => ({ x: v.x, upper: Math.min(100, v.y + v.sd), lower: Math.max(0, v.y - v.sd) })),
      };
    });
  }, [athletes, groups, sortedSeasons, squadStatsAll, algo, visibleIds]);

  return (
    <section style={{
      background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10,
      padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: 12,
    }}>
      <div style={{ display:'flex', alignItems:'flex-start', justifyContent:'space-between', gap:12, flexWrap:'wrap' }}>
        <div>
          <div style={{ fontSize: 13, fontWeight: 600 }}>Team Fitness Trend</div>
          <div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 2 }}>
            {mode === 'score'
              ? `Team avg score ± 1 SD per group · ${_algoLabel(algo)}`
              : 'ACWR · Acute (4-session) / Chronic (12-session) load ratio · Proxy via overall score'}
          </div>
        </div>
        <div style={{ display:'flex', gap:6, alignItems:'center', flexWrap:'wrap' }}>
          {/* mode toggle */}
          <div style={{ display:'flex', borderRadius:6, overflow:'hidden', border:'1px solid var(--border)' }}>
            {[['score','Score'], ['acwr','ACWR']].map(([id, label]) => (
              <button key={id} onClick={() => setMode(id)}
                style={{
                  padding:'4px 11px', border:0, fontSize:12, cursor:'pointer',
                  background: mode === id ? 'var(--accent)' : 'var(--panel-hi)',
                  color: mode === id ? 'white' : 'var(--text-2)',
                  fontFamily:'var(--font-sans)', fontWeight: mode === id ? 600 : 400,
                  transition:'background .12s, color .12s',
                }}>
                {label}
              </button>
            ))}
          </div>

          {/* group toggles — only for Score mode */}
          {mode === 'score' && (
            <>
              <button onClick={toggleAll}
                style={{
                  padding:'3px 9px', borderRadius:999, fontSize:11, cursor:'pointer',
                  border:'1px solid var(--border)',
                  background: allSelected ? 'var(--accent-soft)' : 'transparent',
                  color: allSelected ? 'var(--accent-2)' : 'var(--muted)',
                  fontFamily:'var(--font-sans)', transition:'all .12s',
                }}>
                All
              </button>
              {groups.map(g => {
                const active = visibleIds.has(g.id);
                return (
                  <button key={g.id} onClick={() => toggleGroup(g.id)}
                    style={{
                      padding:'3px 9px', borderRadius:999, fontSize:11, cursor:'pointer',
                      border:`1px solid ${g.accent}`,
                      background: active ? g.accent+'33' : 'transparent',
                      color: active ? g.accent : 'var(--muted)',
                      fontFamily:'var(--font-sans)', transition:'all .12s',
                    }}>
                    {g.label}
                  </button>
                );
              })}
            </>
          )}
        </div>
      </div>

      {mode === 'score' ? (
        <>
          <TeamTrendChart series={series} trendConfig={trendConfig}/>
          <div style={{ display:'flex', gap:14, flexWrap:'wrap', fontSize:11, color:'var(--muted)', alignItems:'center' }}>
            {series.map(s => (
              <span key={s.label} style={{ display:'inline-flex', alignItems:'center', gap:6 }}>
                <span style={{ width:14, height:2, background:s.color, borderRadius:1 }}/>
                <span style={{ width:10, height:8, background:s.color, opacity:.2, borderRadius:1 }}/>
                {s.label}
              </span>
            ))}
            <span style={{ marginLeft:'auto', fontStyle:'italic', fontSize:10 }}>Shaded band = ±1 SD</span>
          </div>
        </>
      ) : (
        <>
          <ACWRChart athletes={athletes} groups={groups} seasons={seasons} squadStatsAll={squadStatsAll} algo={algo}/>
          <div style={{ display:'flex', gap:14, fontSize:10, color:'var(--muted)', flexWrap:'wrap' }}>
            {[
              { c:'rgba(248,113,113,.6)', l:'< 0.8 Undertraining' },
              { c:'rgba(52,211,153,.7)',  l:'0.8–1.3 Optimal' },
              { c:'rgba(251,191,36,.7)',  l:'1.3–1.5 Caution' },
              { c:'rgba(248,113,113,.9)', l:'> 1.5 High risk' },
            ].map(z => (
              <span key={z.l} style={{ display:'inline-flex', alignItems:'center', gap:5 }}>
                <span style={{ width:10, height:10, background:z.c, borderRadius:2 }}/>
                {z.l}
              </span>
            ))}
          </div>
        </>
      )}
    </section>
  );
}

// ──────────────────────────────────────────────────────────────────────────
// Box plot strip — replaces dot strip in DistributionPanel
// ──────────────────────────────────────────────────────────────────────────
function BoxStrip({ metric, athletes, season, squadStats, algo, accent, onSelectAthlete, prevSquadStats }) {
  const D = window.DASHBOARD_DATA;
  const [hover, setHover] = tUseState(null);
  const sv = squadStats[metric.id]?.values || [];

  const items = tUseMemo(() => athletes.map(a => {
    const raw = a.seasons[season]?.[metric.id];
    if (raw == null) return null;
    const score = Math.round(D.metricScore01(metric, raw, sv, algo) * 100);
    return { id: a.id, name: a.name, jersey: a.jersey, position: a.position, raw, score };
  }).filter(Boolean).sort((a,b) => a.score - b.score), [athletes, season, squadStats, algo]);

  if (metric.dir === 'neutral') {
    return (
      <div style={{ display:'grid', gridTemplateColumns:'120px 1fr 90px', alignItems:'center', gap:12, padding:'4px 0' }}>
        <div style={{ fontSize:12, color:'var(--text-2)' }}>{metric.label}</div>
        <div style={{ fontSize:11, color:'var(--muted)' }}>Neutral — scoring N/A</div>
        <div className="mono" style={{ fontSize:11, color:'var(--muted)', textAlign:'right' }}>—</div>
      </div>
    );
  }
  if (!items.length) {
    return (
      <div style={{ display:'grid', gridTemplateColumns:'120px 1fr 90px', alignItems:'center', gap:12, padding:'4px 0' }}>
        <div style={{ fontSize:12, color:'var(--text-2)' }}>{metric.label}</div>
        <div style={{ fontSize:11, color:'var(--muted)' }}>No data this session</div>
        <div/>
      </div>
    );
  }

  // Box statistics
  const scores = items.map(it => it.score);
  const n = scores.length;
  const q1 = pct(scores, 25), q2 = pct(scores, 50), q3 = pct(scores, 75);
  const iqr = q3 - q1;
  const loFence = Math.max(scores[0],   q1 - 1.5 * iqr);
  const hiFence = Math.min(scores[n-1], q3 + 1.5 * iqr);
  const mean = Math.round(scores.reduce((a,b)=>a+b,0) / n);

  // Risk zone from fixedRange
  const fr = metric.fixedRange;
  let riskScore = null, riskOnLeft = true;
  if (fr && sv.length > 0) {
    riskScore = Math.round(D.metricScore01(metric, fr.threshold, sv, algo) * 100);
    riskOnLeft = (metric.dir === 'higher' && fr.goodWhen === 'above') ||
                 (metric.dir === 'lower'  && fr.goodWhen === 'below');
  }

  // Previous period ghost IQR (scores based on prev-season squad distribution)
  let prevGhostQ1 = null, prevGhostQ3 = null;
  if (prevSquadStats && prevSquadStats[metric.id]?.values?.length >= 2) {
    const psv = prevSquadStats[metric.id].values;
    const prevScores = [...psv]
      .map(raw => Math.round(D.metricScore01(metric, raw, psv, algo) * 100))
      .sort((a, b) => a - b);
    prevGhostQ1 = pct(prevScores, 25);
    prevGhostQ3 = pct(prevScores, 75);
  }

  const H = 46, boxTop = 12, boxBot = 34;
  // golden-ratio jitter within box height
  const jY = (idx) => boxTop + 3 + ((boxBot - boxTop - 6) * ((idx * 0.618034) % 1));

  const hoverItem = hover ? items.find(it => it.id === hover) : null;
  const meanRaw = sv.length ? sv.reduce((a,b)=>a+b,0)/sv.length : null;

  return (
    <div style={{ display:'grid', gridTemplateColumns:'120px 1fr 90px', alignItems:'center', gap:12, padding:'4px 0' }}>
      <div style={{ fontSize:12, color:'var(--text-2)' }}>
        {metric.label}
        <span style={{ marginLeft:6, color:'var(--muted)', fontSize:10, fontFamily:'var(--font-mono)' }}>{metric.unit}</span>
      </div>

      <div style={{ position:'relative', height:H, userSelect:'none' }}>
        {/* track */}
        <div style={{ position:'absolute', inset:0, background:'rgba(15,23,42,.05)', borderRadius:4 }}/>
        {/* quartile guides */}
        {[25,50,75].map(p => (
          <div key={p} style={{ position:'absolute', left:`${p}%`, top:0, bottom:0, width:1, background:'rgba(15,23,42,.06)' }}/>
        ))}

        {/* risk zone */}
        {riskScore != null && (
          <div style={{
            position:'absolute',
            left:  riskOnLeft ? 0 : `${riskScore}%`,
            right: riskOnLeft ? `${100-riskScore}%` : 0,
            top:2, bottom:2,
            background:'rgba(248,113,113,.13)',
            borderLeft:  riskOnLeft ? 'none' : '2px dashed rgba(248,113,113,.55)',
            borderRight: riskOnLeft ? '2px dashed rgba(248,113,113,.55)' : 'none',
            borderRadius: riskOnLeft ? '3px 0 0 3px' : '0 3px 3px 0',
          }}/>
        )}

        {/* whisker line */}
        <div style={{
          position:'absolute', left:`${loFence}%`, right:`${100-hiFence}%`,
          top:'50%', height:2, background:'var(--box-stroke)', transform:'translateY(-50%)',
        }}/>
        {/* whisker caps */}
        {[loFence, hiFence].map(w => (
          <div key={w} style={{
            position:'absolute', left:`${w}%`, transform:'translateX(-50%)',
            top:boxTop+2, height:boxBot-boxTop-4, width:2, background:'var(--box-stroke)',
          }}/>
        ))}

        {/* Previous period ghost IQR box */}
        {prevGhostQ1 != null && prevGhostQ3 != null && (
          <div title="Previous session IQR" style={{
            position:'absolute',
            left:`${prevGhostQ1}%`, right:`${100-prevGhostQ3}%`,
            top:boxTop - 1, bottom:H - boxBot - 1,
            background:'transparent',
            border:`1.5px dashed ${accent}55`,
            borderRadius:3, opacity:0.7,
          }}/>
        )}

        {/* IQR box */}
        <div style={{
          position:'absolute',
          left:`${q1}%`, right:`${100-q3}%`,
          top:boxTop, bottom:H-boxBot,
          background:`${accent}1e`, border:`1.5px solid ${accent}77`, borderRadius:3,
        }}/>
        {/* median */}
        <div style={{
          position:'absolute', left:`${q2}%`, transform:'translateX(-50%)',
          top:boxTop-2, bottom:H-boxBot-2, width:2.5, background:accent, opacity:.9,
        }}/>
        {/* mean tick */}
        <div style={{
          position:'absolute', left:`${mean}%`, transform:'translateX(-50%)',
          top:-4, bottom:-4, width:2, background:'var(--text-2)', opacity:.55,
        }}/>

        {/* individual dots */}
        {items.map((it, idx) => {
          const isOut = it.score < loFence || it.score > hiFence;
          const isHov = hover === it.id;
          return (
            <div key={it.id}
              onClick={() => onSelectAthlete?.(it.id)}
              onMouseEnter={() => setHover(it.id)}
              onMouseLeave={() => setHover(null)}
              style={{
                position:'absolute', left:`${it.score}%`, top:jY(idx),
                transform:'translate(-50%,-50%)',
                width: isHov ? 11 : 7, height: isHov ? 11 : 7,
                borderRadius:99,
                background: isOut ? 'var(--neg)' : accent,
                border:'1.5px solid var(--panel)',
                cursor:'pointer', zIndex: isHov ? 10 : 1,
                transition:'width .1s, height .1s',
                boxShadow: isHov ? `0 0 0 3px ${accent}33` : 'none',
              }}
            />
          );
        })}

        {/* tooltip */}
        {hoverItem && (() => {
          const onRight = hoverItem.score > 65;
          const inRisk = riskScore != null && (riskOnLeft ? hoverItem.score < riskScore : hoverItem.score > riskScore);
          return (
            <div style={{
              position:'absolute', left:`${hoverItem.score}%`, top:-8,
              transform:`translate(${onRight?'-100%':'0'},-100%)`,
              marginLeft: onRight ? -8 : 8,
              background:'var(--panel-2)', border:`1px solid ${accent}`,
              borderRadius:6, padding:'7px 9px', minWidth:155,
              boxShadow:'0 8px 20px rgba(0,0,0,.5)',
              fontSize:11, zIndex:200, pointerEvents:'none',
              animation:'fade .12s ease both',
            }}>
              <div style={{ display:'flex', justifyContent:'space-between', alignItems:'baseline', gap:8, marginBottom:4 }}>
                <span style={{ fontSize:12, fontWeight:600, color:'var(--text)' }}>{hoverItem.name}</span>
                <span className="mono" style={{ fontSize:9, color:'var(--muted)' }}>#{String(hoverItem.jersey).padStart(2,'0')}</span>
              </div>
              <div style={{ display:'grid', gridTemplateColumns:'50px 1fr', gap:4, fontSize:10 }}>
                <span style={{ color:'var(--muted)', textTransform:'uppercase', letterSpacing:'.06em' }}>Raw</span>
                <span className="mono" style={{ color:'var(--text)', textAlign:'right' }}>
                  {formatValue(hoverItem.raw)} <span style={{ color:'var(--muted)' }}>{metric.unit}</span>
                </span>
                <span style={{ color:'var(--muted)', textTransform:'uppercase', letterSpacing:'.06em' }}>Score</span>
                <span className="mono" style={{ color:accent, textAlign:'right', fontWeight:600 }}>{hoverItem.score}</span>
                {meanRaw != null && (
                  <>
                    <span style={{ color:'var(--muted)', textTransform:'uppercase', letterSpacing:'.06em' }}>vs μ</span>
                    <span className="mono" style={{
                      color: ((metric.dir==='lower') ? hoverItem.raw < meanRaw : hoverItem.raw > meanRaw) ? 'var(--pos)' : 'var(--neg)',
                      textAlign:'right',
                    }}>
                      {hoverItem.raw > meanRaw ? '+' : ''}{(hoverItem.raw - meanRaw).toFixed(Math.abs(hoverItem.raw-meanRaw)>=10?1:2)}
                    </span>
                  </>
                )}
              </div>
              {riskScore != null && (
                <div style={{
                  marginTop:4, paddingTop:4, borderTop:'1px dashed var(--border)',
                  fontSize:10, color: inRisk ? 'var(--neg)' : 'var(--pos)',
                }}>
                  {inRisk ? '⚠ ' + window.t('Outside reference') : '✓ ' + window.t('Within reference')}
                  <span style={{ color:'var(--muted)', marginLeft:4 }}>
                    (threshold {fr.goodWhen} {formatValue(fr.threshold)}{metric.unit})
                  </span>
                </div>
              )}
              <div style={{ fontSize:9, color:'var(--muted)', marginTop:4, paddingTop:4, borderTop:'1px dashed var(--border)' }}>
                {hoverItem.position} · click to open report
              </div>
            </div>
          );
        })()}
      </div>

      <div className="mono" style={{ fontSize:11, color:'var(--muted)', textAlign:'right' }}>
        <span style={{ color:'var(--text-2)' }}>{Math.round(q1)}–{Math.round(q3)}</span>
        <span style={{ color:'var(--muted-2)', margin:'0 3px' }}>IQR</span>
      </div>
    </div>
  );
}

// TEAM-4 (2026-07-09): legacy TeamDashboard page deleted per ruling
// docs/decisions/2026-07-09-team-three-persona-direction.md §6③ — composite stones /
// ability heat map / roster 综合分 column / computeRiskLevel risk grading were red-line
// debt. Removed here: TeamRecentReviewedEvidence · TeamCoachDiscussionFrame ·
// TeamDashboard · TEAM_LEGACY_FLAG · TeamTopBar · TeamStone · TeamHero · RiskMatrix ·
// SquadHeatmap · ROSTER_* consts · rosterFmt · SquadLeaderboard ·
// computeLensMatrix. TeamWorkbenchView (below) is the sole team page.

// ─────────────────────────────────────────────────────────────────────────
// TM-R1 · Team Workbench (coach communication workbench)
// 1:1 with mockups/proposals/team-workbench-mockup.html — fixed core (team head
// + optional metric row + action bar) + bento module grid (Browse/Customize).
// Assembly only: modules come from window.TeamModules (registry = extension
// point); each module's data is a pure buildData over caller-prepared input.
// Reuses the ST-1 athlete-story bento Customize interaction classes verbatim
// (interaction-patterns baseline). Legacy TeamDashboard deleted (TEAM-4, 2026-07-09).
//
// CSS: mockup-specific card styles (.tw-tchip/.tw-qrow/.tw-arow/…) are injected
// as a scoped <style> here (constraint: no HTML edits in this slice). The
// reviewer may lift them into Performance Dashboard.html at wiring time.
// ─────────────────────────────────────────────────────────────────────────


const TEAM_WORKBENCH_CSS = `
[data-team-workbench] { --tw-hair: var(--border, rgba(22,28,40,.08)); --tw-hair2: color-mix(in srgb, var(--border, rgba(22,28,40,.08)) 60%, transparent); }
[data-team-workbench] .tw-head { display:flex; align-items:baseline; gap:14px; padding-bottom:14px; border-bottom:1px solid var(--tw-hair); flex-wrap:wrap; }
[data-team-workbench] .tw-head h2 { font-size:19px; font-weight:700; letter-spacing:-.015em; color:var(--text); }
[data-team-workbench] .tw-head .tw-range { font-family:var(--font-mono,monospace); font-size:11px; color:var(--muted); }
[data-team-workbench] .tw-head .tw-actions { margin-left:auto; display:flex; gap:8px; flex-wrap:wrap; }
[data-team-workbench] .tw-persona { display:flex; align-items:center; gap:8px; padding:12px 0 0; flex-wrap:wrap; }
[data-team-workbench] .tw-persona-label { font-size:10.5px; letter-spacing:.08em; text-transform:uppercase; color:var(--muted); font-weight:600; margin-right:2px; }
[data-team-workbench] .tw-chip { position:relative; border:1px solid var(--tw-hair); background:var(--panel,#fff); color:var(--text-2); border-radius:999px; padding:5px 13px; font-size:12px; font-weight:600; cursor:pointer; transition:background .2s, border-color .2s, color .2s; }
[data-team-workbench] .tw-chip:hover { background:color-mix(in srgb, var(--panel) 88%, var(--bg) 12%); }
[data-team-workbench] .tw-chip.is-active { background:color-mix(in srgb, var(--accent) 12%, transparent); border-color:color-mix(in srgb, var(--accent) 40%, var(--tw-hair)); color:var(--accent); }
[data-team-workbench] .tw-chip-suffix { font-size:9.5px; font-weight:600; margin-left:6px; opacity:.8; }
[data-team-workbench] .tw-chip-dot { display:inline-block; width:5px; height:5px; border-radius:50%; background:var(--accent); margin-left:5px; vertical-align:1px; }
[data-team-workbench] .tw-persona-reset { border:0; background:none; color:var(--muted); font-size:11px; cursor:pointer; padding:4px 6px; text-decoration:underline; text-underline-offset:2px; }
[data-team-workbench] .tw-persona-reset:hover { color:var(--accent); }
[data-team-workbench] .tw-persona-div { width:1px; align-self:stretch; margin:2px 4px; background:var(--tw-hair); }
[data-team-workbench] .tw-chip-custom { color:var(--muted); }
[data-team-workbench] .tw-chip-custom[aria-pressed="true"] { background:color-mix(in srgb, var(--accent) 12%, transparent); border-color:color-mix(in srgb, var(--accent) 40%, var(--tw-hair)); color:var(--accent); }
[data-team-workbench] .tw-stats { display:grid; grid-template-columns:repeat(4,1fr) 26px; padding:16px 0 18px; border-bottom:1px solid var(--tw-hair); position:relative; gap:0; }
[data-team-workbench] .tw-stat { padding:0 18px; border-left:1px solid var(--tw-hair2); min-width:0; }
[data-team-workbench] .tw-stat:first-child { border-left:0; padding-left:0; }
[data-team-workbench] .tw-stat .tw-l { font-size:10.5px; color:var(--muted); margin-bottom:5px; }
[data-team-workbench] .tw-stat .tw-v { font-family:var(--font-mono,monospace); font-size:22px; font-weight:600; font-variant-numeric:tabular-nums; color:var(--text); }
[data-team-workbench] .tw-stat .tw-v small { font-size:11px; color:var(--muted); font-weight:400; }
[data-team-workbench] .tw-stats-edit { border:1px solid var(--tw-hair); background:none; border-radius:7px; width:24px; height:22px; color:var(--muted); cursor:pointer; align-self:center; }
[data-team-workbench] .tw-stats-pop { position:absolute; top:100%; right:0; z-index:20; margin-top:4px; background:var(--panel); border:1px solid var(--border); border-radius:9px; padding:8px; display:grid; gap:4px; box-shadow:0 12px 40px rgba(20,28,45,.18); min-width:190px; }
[data-team-workbench] .tw-stats-pop label { display:flex; align-items:center; gap:7px; font-size:12px; color:var(--text-2); cursor:pointer; padding:3px 4px; }
[data-team-workbench] .tw-card { border:1px solid var(--tw-hair); border-radius:13px; background:var(--panel,#fff); padding:14px 16px; min-width:0; }
[data-team-workbench] .tw-card-h { display:flex; align-items:center; gap:8px; margin-bottom:10px; flex-wrap:wrap; }
[data-team-workbench] .tw-card-h b { font-size:12.5px; font-weight:700; color:var(--text); }
[data-team-workbench] .tw-card-h small { color:var(--muted); font-size:10px; }
[data-team-workbench] .tw-card-h .tw-more { margin-left:auto; color:var(--muted); font-size:10.5px; cursor:pointer; }
[data-team-workbench] .tw-qrow { display:flex; align-items:center; gap:10px; padding:9px 2px; border-bottom:1px solid var(--tw-hair2); font-size:12px; cursor:pointer; transition:background .25s; }
[data-team-workbench] .tw-qrow:hover { background:color-mix(in srgb, var(--panel) 90%, var(--bg) 10%); }
[data-team-workbench] .tw-qrow:last-child { border-bottom:0; }
[data-team-workbench] .tw-tchip { font-size:8.5px; font-weight:700; border-radius:5px; padding:2px 6px; background:color-mix(in srgb, var(--accent) 16%, transparent); color:var(--accent); flex:0 0 auto; font-family:var(--font-mono,monospace); }
[data-team-workbench] .tw-tchip.tw-m { background:color-mix(in srgb, #956400 16%, transparent); color:#956400; }
[data-team-workbench] .tw-qrow .tw-who { font-weight:650; flex:0 0 auto; color:var(--text); }
[data-team-workbench] .tw-qrow .tw-what { color:var(--text-2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
[data-team-workbench] .tw-qrow .tw-when { margin-left:auto; font-family:var(--font-mono,monospace); font-size:9.5px; color:var(--muted); flex:0 0 auto; }
[data-team-workbench] .tw-qrow .tw-used { font-size:9px; color:var(--accent); flex:0 0 auto; }
[data-team-workbench] .tw-arow { display:flex; align-items:baseline; gap:8px; padding:8px 2px; border-bottom:1px solid var(--tw-hair2); font-size:11.5px; color:var(--text-2); }
[data-team-workbench] .tw-arow:last-child { border-bottom:0; }
[data-team-workbench] .tw-arow .tw-rule { font-family:var(--font-mono,monospace); font-size:8.5px; color:var(--muted); margin-left:auto; flex:0 0 auto; }
[data-team-workbench] .tw-adot { width:6px; height:6px; border-radius:2px; background:var(--accent); flex:0 0 auto; align-self:center; }
[data-team-workbench] .tw-chart svg { width:100%; display:block; }
[data-team-workbench] .tw-foot { font-size:9.5px; color:var(--muted); margin-top:6px; }
[data-team-workbench] .tw-legend { display:flex; gap:14px; font-size:9.5px; color:var(--muted); margin-top:6px; }
[data-team-workbench] .tw-legend i { display:inline-block; width:10px; height:8px; border-radius:2px; margin-right:4px; vertical-align:-1px; }
[data-team-workbench] .tw-empty { color:var(--muted); font-size:11.5px; padding:8px 2px; }
/* TEAM-2 (2026-07-09) confirmed-status + swc-movers */
[data-team-workbench] .tw-avatar { flex:0 0 auto; width:24px; height:24px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:9px; font-weight:700; font-family:var(--font-mono,monospace); background:color-mix(in srgb, var(--accent) 14%, transparent); color:var(--accent); }
[data-team-workbench] .tw-cs-body { display:flex; flex-direction:column; gap:1px; min-width:0; flex:1 1 auto; }
[data-team-workbench] .tw-cs-body .tw-who { font-weight:650; color:var(--text); display:flex; align-items:baseline; gap:5px; }
[data-team-workbench] .tw-jersey, [data-team-workbench] .tw-swc-n { font-family:var(--font-mono,monospace); font-size:9px; font-weight:500; color:var(--muted); }
[data-team-workbench] .tw-cs-sub { font-size:10px; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
[data-team-workbench] .tw-cs-tag { flex:0 0 auto; align-self:center; font-size:9px; font-weight:600; border-radius:5px; padding:2px 7px; background:color-mix(in srgb, var(--accent) 12%, transparent); color:var(--accent); }
[data-team-workbench] .tw-swc-row .tw-swc-list { white-space:normal; }
[data-team-workbench] .tw-swc-metric { font-size:10px; }
[data-team-workbench] .tw-swc-count { flex:0 0 auto; align-self:center; font-family:var(--font-mono,monospace); font-size:12px; font-weight:700; font-variant-numeric:tabular-nums; }
/* TEAM-2b (2026-07-09) metric-compare · test-coverage · attendance · data-gaps */
[data-team-workbench] .tw-mc-pick { display:flex; align-items:center; gap:6px; margin-bottom:10px; flex-wrap:wrap; }
[data-team-workbench] .tw-mc-chip { font-size:11px; font-weight:600; border-radius:6px; padding:3px 9px; background:color-mix(in srgb, var(--accent) 12%, transparent); color:var(--accent); }
[data-team-workbench] .tw-mc-prov { font-size:8.5px; font-weight:700; border-radius:4px; padding:1px 5px; background:var(--force-bg, #ecebfb); color:var(--force-fg, #4f46c9); }
[data-team-workbench] .tw-mc-sel { font-size:11px; border:1px solid var(--tw-hair); border-radius:6px; padding:3px 6px; background:var(--panel); color:var(--text-2); cursor:pointer; max-width:170px; }
[data-team-workbench] .tw-mc-row { display:flex; align-items:center; gap:8px; padding:6px 2px; font-size:11.5px; }
[data-team-workbench] .tw-mc-name { flex:0 0 96px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--text); font-weight:600; }
[data-team-workbench] .tw-mc-name small { font-family:var(--font-mono,monospace); font-size:9px; color:var(--muted); font-weight:500; margin-left:3px; }
[data-team-workbench] .tw-mc-bar { flex:1 1 auto; height:9px; background:color-mix(in srgb, var(--tw-hair) 60%, transparent); border-radius:5px; min-width:20px; overflow:hidden; }
[data-team-workbench] .tw-mc-bar i { display:block; height:100%; background:color-mix(in srgb, var(--accent) 55%, transparent); border-radius:5px; }
[data-team-workbench] .tw-mc-val { flex:0 0 auto; font-family:var(--font-mono,monospace); font-size:11px; font-variant-numeric:tabular-nums; color:var(--text); min-width:56px; text-align:right; }
[data-team-workbench] .tw-mc-val small { color:var(--muted); font-size:9px; margin-left:2px; }
[data-team-workbench] .tw-mc-d { flex:0 0 auto; font-family:var(--font-mono,monospace); font-size:9.5px; min-width:44px; text-align:right; }
[data-team-workbench] .tw-tc-grid { width:100%; border-collapse:collapse; font-size:11px; }
[data-team-workbench] .tw-tc-grid th { font-size:9.5px; font-weight:600; color:var(--muted); text-align:center; padding:4px 3px; border-bottom:1px solid var(--tw-hair); }
[data-team-workbench] .tw-tc-grid th:first-child { text-align:left; }
[data-team-workbench] .tw-tc-grid td { padding:4px 3px; border-bottom:1px solid var(--tw-hair2); text-align:center; }
[data-team-workbench] .tw-tc-grid td:first-child { text-align:left; color:var(--text); font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:90px; }
[data-team-workbench] .tw-tc-cell { display:inline-block; width:16px; height:16px; border-radius:4px; }
[data-team-workbench] .tw-tc-cell.on { background:color-mix(in srgb, #1a8f4c 62%, transparent); }
[data-team-workbench] .tw-tc-cell.off { background:color-mix(in srgb, var(--muted) 30%, transparent); }
[data-team-workbench] .tw-af-tiles { display:flex; gap:10px; margin-bottom:8px; flex-wrap:wrap; }
[data-team-workbench] .tw-af-tile { flex:1 1 70px; min-width:70px; }
[data-team-workbench] .tw-af-tile .tw-af-n { font-family:var(--font-mono,monospace); font-size:20px; font-weight:600; color:var(--text); font-variant-numeric:tabular-nums; }
[data-team-workbench] .tw-af-tile .tw-af-n small { font-size:11px; color:var(--muted); font-weight:400; }
[data-team-workbench] .tw-af-tile .tw-af-l { font-size:9.5px; color:var(--muted); margin-top:2px; }
[data-team-workbench] .tw-af-zero { font-size:10.5px; color:var(--text-2); }
[data-team-workbench] .tw-af-zero b { color:var(--muted); font-weight:600; font-size:9.5px; }
[data-team-workbench] .tw-dg-row { display:flex; align-items:center; gap:8px; padding:7px 2px; border-bottom:1px solid var(--tw-hair2); font-size:11.5px; cursor:pointer; }
[data-team-workbench] .tw-dg-row:last-child { border-bottom:0; }
[data-team-workbench] .tw-dg-row:hover { background:color-mix(in srgb, var(--panel) 90%, var(--bg) 10%); }
[data-team-workbench] .tw-dg-kind { flex:0 0 auto; font-family:var(--font-mono,monospace); font-size:8px; font-weight:700; border-radius:4px; padding:2px 5px; background:color-mix(in srgb, var(--accent) 12%, transparent); color:var(--accent); }
[data-team-workbench] .tw-dg-name { flex:0 0 auto; font-weight:600; color:var(--text); }
[data-team-workbench] .tw-dg-fact { color:var(--text-2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
[data-team-workbench] .tw-dg-days { margin-left:auto; flex:0 0 auto; font-family:var(--font-mono,monospace); font-size:9.5px; color:var(--muted); }
/* UI-R team finalization: approved balanced workbench. Registry/content contracts stay unchanged. */
[data-team-workbench] { width:100%; max-width:100%; min-width:0; background:#f6f7f9; color:#283241; }
[data-team-workbench] .tw-frame { width:100%; max-width:1440px; min-width:0; margin:0 auto; padding:21px 24px 38px; }
[data-team-workbench] .tw-surface { background:transparent; border:0; border-radius:0; padding:0; }
[data-team-workbench] .tw-head { min-height:48px; align-items:center; padding:0 0 15px; }
[data-team-workbench] .tw-head-copy { min-width:260px; }
[data-team-workbench] .tw-head-title { display:flex; align-items:baseline; gap:10px; }
[data-team-workbench] .tw-head h2 { font-size:22px; }
[data-team-workbench] .tw-head-copy p { margin:6px 0 0; color:#8a929e; font-size:10.5px; }
[data-team-workbench] .tw-head .tw-actions { align-items:center; }
[data-team-workbench] .tw-head .btn { min-height:32px; border-color:#e0e3e8; border-radius:8px; background:#fff; color:#4b5563; font-size:10.5px; }
[data-team-workbench] .tw-head .btn.primary { border-color:#2d374b; background:#2d374b; color:#fff; }
[data-team-workbench] .tw-persona { min-height:48px; padding:9px 0; border-bottom:1px solid var(--tw-hair); }
[data-team-workbench] .tw-persona .tw-chip { min-height:29px; border-radius:7px; padding:5px 11px; background:#fff; }
[data-team-workbench] .tw-persona .tw-chip.is-active { background:#eceef8; border-color:#d9ddeb; color:#4f5b9b; }
[data-team-workbench] .tw-persona-count { margin-left:5px; color:inherit; font:9px var(--font-mono,monospace); opacity:.72; }
[data-team-workbench] .tw-chip-custom { margin-left:auto; }
[data-team-workbench] .tw-stats { grid-template-columns:repeat(4,minmax(0,1fr)) 26px; gap:10px; padding:15px 0 17px; }
[data-team-workbench] .tw-stat { min-height:80px; padding:13px 15px; border:1px solid #e2e5e9; border-radius:10px; background:#fff; }
[data-team-workbench] .tw-stat:first-child { padding-left:15px; border-left:1px solid #e2e5e9; }
[data-team-workbench] .tw-stat .tw-l { color:#7f8792; font-size:9.5px; }
[data-team-workbench] .tw-stat .tw-v { font-size:24px; letter-spacing:-.04em; }
[data-team-workbench] .tw-balanced-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); grid-template-areas:'discussion discussion coverage' 'workload trend trend' 'metric metric movers'; grid-auto-rows:minmax(212px,auto); gap:13px; align-items:stretch; }
[data-team-workbench] .tw-balanced-grid > [data-team-module='discussion-queue'] { grid-area:discussion; }
[data-team-workbench] .tw-balanced-grid > [data-team-module='test-coverage'] { grid-area:coverage; }
[data-team-workbench] .tw-balanced-grid > [data-team-module='workload-overview'] { grid-area:workload; }
[data-team-workbench] .tw-balanced-grid > [data-team-module='squad-trend'] { grid-area:trend; }
[data-team-workbench] .tw-balanced-grid > [data-team-module='metric-compare'] { grid-area:metric; }
[data-team-workbench] .tw-balanced-grid > [data-team-module='swc-movers'] { grid-area:movers; }
[data-team-workbench] .athlete-story-bento-module { min-width:0; }
[data-team-workbench] .tw-card { height:100%; border-color:#e0e3e8; border-radius:11px; padding:15px 16px; box-shadow:0 1px 0 rgba(31,41,55,.015); }
[data-team-workbench] .tw-card-h { padding-bottom:9px; border-bottom:1px solid #eef0f3; }
[data-team-workbench] .tw-card-h b { font-size:12px; }
[data-team-workbench] .tw-qrow { min-height:42px; padding:8px 3px; }
[data-team-workbench] .tw-load-list { display:grid; gap:9px; padding:5px 0 2px; }
[data-team-workbench] .tw-load-row { display:grid; grid-template-columns:88px minmax(40px,1fr) 50px; align-items:center; gap:8px; color:#4c5664; font-size:9.5px; }
[data-team-workbench] .tw-load-row > span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
[data-team-workbench] .tw-load-row > i { height:7px; overflow:hidden; border-radius:999px; background:#edf0f4; }
[data-team-workbench] .tw-load-row > i > b { display:block; height:100%; border-radius:inherit; background:#6fa9eb; }
[data-team-workbench] .tw-load-row > strong { color:#364152; font:600 9.5px var(--font-mono,monospace); text-align:right; }
[data-team-workbench] .tw-mc-row { min-height:30px; }
[data-team-workbench] .tw-foot { color:#959ca7; }
[data-team-workbench] .tw-module-summary { display:flex; align-items:center; justify-content:center; gap:9px; margin:18px 0 0; color:#9097a2; font-size:9.5px; }
[data-team-workbench] .tw-module-summary::before, [data-team-workbench] .tw-module-summary::after { content:''; flex:1; height:1px; background:#e2e5e9; }
@media (max-width:1100px) { [data-team-workbench] .tw-balanced-grid { grid-template-columns:repeat(2,minmax(0,1fr)); grid-template-areas:none; } [data-team-workbench] .tw-balanced-grid > [data-team-module] { grid-area:auto; grid-column:span 1 !important; } [data-team-workbench] .tw-balanced-grid > [data-team-module='discussion-queue'], [data-team-workbench] .tw-balanced-grid > [data-team-module='squad-trend'], [data-team-workbench] .tw-balanced-grid > [data-team-module='metric-compare'] { grid-column:span 2 !important; } }
@media (max-width:760px) { [data-team-workbench] .tw-frame { padding:14px 12px 28px; } [data-team-workbench] .tw-head { align-items:flex-start; } [data-team-workbench] .tw-head .tw-actions { margin-left:0; width:100%; } [data-team-workbench] .tw-stats { grid-template-columns:repeat(2,minmax(0,1fr)); } [data-team-workbench] .tw-balanced-grid { display:grid; grid-template-columns:1fr; } [data-team-workbench] .tw-balanced-grid > [data-team-module] { grid-column:1 !important; } }
`;

// Fixed team metric row registry (mockup: 已确认数/反馈率/平均RPE/待讨论). Each
// value is read from already-built module data — no new science compute here.
const TEAM_STAT_METRICS = [
  { id: 'confirmed', label: '已确认结果（本周）', unit: '' },
  { id: 'feedbackRate', label: '训练反馈率', unit: ' %' },
  { id: 'avgRPE', label: '平均 RPE', unit: ' /10' },
  { id: 'pendingDiscussion', label: '待讨论', unit: '' },
];

function teamFmtWeekRange(season) {
  const text = season == null ? '' : String(season).trim();
  if (!text) return '';
  const end = new Date(text);
  if (Number.isNaN(+end)) return text;
  const start = new Date(end.getFullYear(), end.getMonth(), end.getDate() - 6);
  const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
  return `${iso(start)} – ${iso(end).slice(5)}`;
}

// Squad average RPE across this season's per-athlete acwr-adjacent feedback is
// not derivable without new compute; the metric row instead surfaces the squad
// avg from the discussion/training facts already built. When a value is missing
// it renders "—" (never fabricated).
function TeamStatSelector({ selected, onChange }) {
  const [open, setOpen] = tUseState(false);
  const ref = tUseRef(null);
  tUseEffect(() => {
    const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener('mousedown', h);
    return () => document.removeEventListener('mousedown', h);
  }, []);
  const toggle = (id) => {
    const has = selected.includes(id);
    onChange(has ? selected.filter(x => x !== id) : [...selected, id]);
  };
  return (
    <div ref={ref} style={{ gridColumn: '5', justifySelf: 'center', position: 'relative' }}>
      <button type="button" className="tw-stats-edit" title="选择呈现指标 · Select metrics"
        onClick={() => setOpen(o => !o)}>＋</button>
      {open && (
        <div className="tw-stats-pop">
          {TEAM_STAT_METRICS.map(m => (
            <label key={m.id}>
              <input type="checkbox" checked={selected.includes(m.id)} onChange={() => toggle(m.id)} />
              {m.label}
            </label>
          ))}
        </div>
      )}
    </div>
  );
}

// ── mockup 1:1 module renderers ───────────────────────────────────────────
// Each takes the pure buildData output for its id and renders the mockup form.

function TeamDiscussionQueueCard({ data, onOpenRow }) {
  const rows = Array.isArray(data?.rows) ? data.rows : [];
  const visibleRows = rows.slice(0, 4);
  const fmt = (v) => {
    if (!v) return '—';
    const d = new Date(v);
    if (Number.isNaN(+d)) return String(v).slice(5, 10);
    return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
  };
  const chipLabel = (row) => {
    const st = row.sourceType;
    if (st === 'manual') return { label: 'Manual', manual: true };
    if (st === 'field') return { label: 'Field', manual: false };
    const map = { cmj: 'CMJ', sj: 'SJ', imtp: 'IMTP' };
    return { label: map[st] || (row.evidenceTypeLabel || 'Result').slice(0, 5), manual: false };
  };
  return (
    <div className="tw-card" data-team-module-content="discussion-queue" style={{ height: '100%' }}>
      <div className="tw-card-h">
        <b>讨论队列 · Confirmed Results</b>
        <small>本周 {rows.length} 条 · 点击行展开来源/审核/报告动作</small>
      </div>
      {rows.length ? visibleRows.map(({ row, trainingLinkCount }) => {
        const chip = chipLabel(row);
        return (
          <div key={row.id} className="tw-qrow" onClick={() => onOpenRow && onOpenRow(row)}
            title="点击展开 View / Report / Source">
            <span className={`tw-tchip${chip.manual ? ' tw-m' : ''}`}>{chip.label}</span>
            <span className="tw-who">{row.athleteName || 'Unknown athlete'}</span>
            <span className="tw-what">{row.primaryLabel || row.secondaryLabel || '—'}</span>
            {trainingLinkCount > 0 && (
              <span className="tw-used" title={`已被引用于训练×${trainingLinkCount}`}>↳训练×{trainingLinkCount}</span>
            )}
            <span className="tw-when">{fmt(row.timestamp)}</span>
          </div>
        );
      }) : (
        <div className="tw-empty">本周暂无已确认结果。运动科学家确认后将出现在此。</div>
      )}
      {rows.length > visibleRows.length && <div className="tw-foot">显示 {visibleRows.length} / {rows.length} 条 · 查看全部讨论</div>}
    </div>
  );
}

function TeamAttentionListCard({ data }) {
  const items = Array.isArray(data?.items) ? data.items : [];
  return (
    <div className="tw-card" data-team-module-content="attention-list" style={{ height: '100%' }}>
      <div className="tw-card-h"><b>待讨论清单</b><small>事实规则</small></div>
      {items.length ? items.map((it, i) => (
        <div key={i} className="tw-arow">
          <span className="tw-adot"></span>{it.text}
          <span className="tw-rule">{it.rule}</span>
        </div>
      )) : (
        <div className="tw-empty">当前无待讨论事实项。</div>
      )}
    </div>
  );
}

function TeamSquadTrainingCard({ data, weekLogsByAthlete, athletes }) {
  // Per-weekday session bars from the caller-supplied week logs (fact only).
  const dayCounts = tUseMemo(() => {
    const counts = [0, 0, 0, 0, 0, 0, 0];
    const store = weekLogsByAthlete || {};
    (athletes || []).forEach(a => {
      (store[a.id] || []).forEach(l => {
        const d = new Date(l.date);
        if (Number.isNaN(+d)) return;
        const idx = (d.getDay() + 6) % 7; // Mon=0 … Sun=6
        counts[idx] += 1;
      });
    });
    return counts;
  }, [weekLogsByAthlete, athletes]);
  const max = Math.max(1, ...dayCounts);
  const names = ['一', '二', '三', '四', '五', '六', '日'];
  const total = data?.sessions ?? dayCounts.reduce((s, v) => s + v, 0);
  const rate = data?.feedbackRate;
  return (
    <div className="tw-card" data-team-module-content="squad-training" style={{ height: '100%' }}>
      <div className="tw-card-h"><b>全队训练</b><small>本周</small></div>
      <div className="tw-chart">
        <svg viewBox="0 0 240 74">
          <line x1="0" y1="66" x2="240" y2="66" stroke="var(--tw-hair)"/>
          <g fill="color-mix(in srgb, var(--accent) 60%, transparent)">
            {dayCounts.map((c, i) => {
              const h = Math.round((c / max) * 54);
              const y = 66 - h;
              return <rect key={i} x={10 + i * 32} y={y} width="20" height={Math.max(2, h)} rx="3">
                <title>周{names[i]} · {c} sessions</title>
              </rect>;
            })}
          </g>
        </svg>
      </div>
      <div className="tw-foot">{total} sessions{rate != null ? ` · 反馈 ${rate}%` : ''} · hover查看逐日</div>
    </div>
  );
}

function TeamWorkloadOverviewCard({ data }) {
  const points = Array.isArray(data?.points) ? data.points : [];
  const visiblePoints = points.slice(0, 5);
  const maxLoad = Math.max(1, ...visiblePoints.map(p => p.load));
  return (
    <div className="tw-card" data-team-module-content="workload-overview" style={{ height: '100%' }}>
      <div className="tw-card-h"><b>负荷监控</b><small>近 7 日 · sRPE × 时长</small></div>
      {points.length ? <>
        <div className="tw-load-list" data-real-training-load>
          {visiblePoints.map((p) => (
            <div className="tw-load-row" key={p.id} title={`${p.sessions} sessions`}>
              <span>{p.name}</span><i><b style={{ width: `${Math.max(3, Math.round((p.load / maxLoad) * 100))}%` }} /></i><strong>{Math.round(p.load).toLocaleString()}</strong>
            </div>
          ))}
        </div>
        <div className="tw-foot">覆盖 {data?.athletesWithLoad ?? points.length}/{data?.totalAthletes ?? points.length} 人 · {data?.sessions ?? 0} sessions</div>
      </> : <div className="tw-empty">暂无含 RPE 与时长的训练反馈。保存训练反馈后显示。</div>}
    </div>
  );
}

function TeamSquadTrendCard({ data }) {
  const points = Array.isArray(data?.points) ? data.points : [];
  const max = Math.max(1, ...points.map(p => p.load));
  const W = 520;
  const startMs = Date.parse(`${data?.fromDate || ''}T00:00:00Z`);
  const endMs = Date.parse(`${data?.toDate || ''}T00:00:00Z`);
  const dateSpan = endMs - startMs;
  const xAt = (point, i) => {
    const value = Date.parse(`${point.date}T00:00:00Z`);
    return Number.isFinite(value) && dateSpan > 0
      ? 20 + ((value - startMs) / dateSpan) * (W - 40)
      : (points.length > 1 ? 20 + (i / (points.length - 1)) * (W - 40) : W / 2);
  };
  const yAt = (v) => 74 - (v / max) * 54;
  const path = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${xAt(p, i).toFixed(1)},${yAt(p.load).toFixed(1)}`).join(' ');
  return (
    <div className="tw-card" data-team-module-content="squad-trend" style={{ height: '100%' }}>
      <div className="tw-card-h"><b>队伍训练负荷趋势</b><small>近 28 日 · sRPE × 时长</small></div>
      {points.length ? <><div className="tw-chart" data-real-training-load-trend>
        <svg viewBox="0 0 520 84">
          <line x1="0" y1="74" x2="520" y2="74" stroke="var(--tw-hair)"/>
          {points.length > 1 && <path d={path} fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinecap="round"/>}
          <g fill="var(--accent)">
            {points.map((p, i) => (
              <circle key={p.date} cx={xAt(p, i)} cy={yAt(p.load)} r="2.5">
                <title>{p.date} · {Math.round(p.load)} AU · {p.sessions} sessions · n={p.n}</title>
              </circle>
            ))}
          </g>
        </svg>
      </div><div className="tw-foot">累计 {Math.round(data?.totalLoad ?? 0)} AU · {data?.sessions ?? 0} sessions · hover 查看参与人数</div></>
        : <div className="tw-empty">近 28 日暂无有效 sRPE × 时长记录。</div>}
    </div>
  );
}

function TeamWeeklyConfirmedCard({ data }) {
  const confirmed = data?.confirmed ?? 0;
  return (
    <div className="tw-card" data-team-module-content="weekly-confirmed" style={{ height: '100%' }}>
      <div className="tw-card-h"><b>本周已确认</b><small>来源可溯</small></div>
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
        <span style={{ fontFamily: 'var(--font-mono,monospace)', fontSize: 28, fontWeight: 600, color: 'var(--text)' }}>{confirmed}</span>
        <span style={{ fontSize: 11, color: 'var(--muted)' }}>条已确认结果</span>
      </div>
      <div className="tw-foot">来源可用 {data?.sourceAvailable ?? 0} · 来源缺失 {data?.sourceMissing ?? 0}</div>
    </div>
  );
}

// TEAM-2 (2026-07-09): avatar initials from a display name (first 2 word-heads,
// or first 2 chars for CJK). Presentation only.
function twInitials(name) {
  const t = (name || '').trim();
  if (!t) return '?';
  const parts = t.split(/\s+/).filter(Boolean);
  if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
  return t.slice(0, 2).toUpperCase();
}

// TEAM-2: 人审确认状态 — rows only for athletes with an in-file injury/screen
// record. Status tag uses the RECORD's own vocabulary (never derived); no risk
// grading. Row click deep-links to that athlete's injury/movement-screen view.
function TeamConfirmedStatusCard({ data, onOpenAthleteRecord }) {
  const rows = Array.isArray(data?.rows) ? data.rows : [];
  const withoutRecord = data?.withoutRecord ?? 0;
  return (
    <div className="tw-card" data-team-module-content="confirmed-status" style={{ height: '100%' }}>
      <div className="tw-card-h"><b>人审确认状态</b><small>在案伤病/筛查 · 人审结论</small></div>
      {rows.length ? rows.map((r) => {
        const rec = r.records[0] || {};
        const view = rec.kind === 'screen' ? 'movement-screen' : 'injury';
        const statusText = [rec.status, rec.phase].filter(Boolean).join(' · ') || '在案';
        const sub = [rec.fact, rec.date, rec.reviewer ? `审核人 ${rec.reviewer}` : '']
          .filter(Boolean).join(' · ');
        return (
          <div key={r.athleteId} className="tw-qrow" title="点击查看该运动员记录"
            onClick={() => onOpenAthleteRecord && onOpenAthleteRecord(r.athleteId, view)}>
            <span className="tw-avatar" aria-hidden="true">{twInitials(r.name)}</span>
            <span className="tw-cs-body">
              <span className="tw-who">{r.name}{r.jersey != null ? <small className="tw-jersey">#{r.jersey}</small> : null}</span>
              <span className="tw-cs-sub">{sub}</span>
            </span>
            <span className="tw-cs-tag">{statusText}</span>
          </div>
        );
      }) : (
        <div className="tw-empty">全队无在案伤病/筛查记录。</div>
      )}
      {rows.length > 0 && <div className="tw-foot">其余 {withoutRecord} 人无在案记录</div>}
    </div>
  );
}

// TEAM-2: SWC 显著变化 — non-trivial meaningful changes per athlete. Rows sorted
// by declined count desc (then total desc). Counter shows count only (N↓/N↑), NO
// severity class. Raw signed Δ per metric with ↑/↓ (improved=pos, declined=neg).
function TeamSwcMoversCard({ data, onOpenAthlete }) {
  const rows = Array.isArray(data?.rows) ? data.rows : [];
  const visibleRows = rows.slice(0, 3);
  const fmtDelta = (d) => {
    if (typeof d !== 'number' || !isFinite(d)) return '';
    const s = Math.abs(d) >= 100 ? d.toFixed(0) : Math.abs(d) >= 1 ? d.toFixed(1) : d.toFixed(2);
    return d > 0 ? `+${s}` : s;
  };
  return (
    <div className="tw-card" data-team-module-content="swc-movers" style={{ height: '100%' }}>
      <div className="tw-card-h"><b>SWC 显著变化</b><small>原始 Δ · 无严重度分级</small></div>
      {rows.length ? visibleRows.map((r) => (
        <div key={r.athleteId} className="tw-qrow tw-swc-row" title="点击查看该运动员"
          onClick={() => onOpenAthlete && onOpenAthlete(r.athleteId)}>
          <span className="tw-avatar" aria-hidden="true">{twInitials(r.name)}</span>
          <span className="tw-cs-body">
            <span className="tw-who">{r.name}<small className="tw-swc-n">{r.total} 项变化</small></span>
            <span className="tw-cs-sub tw-swc-list">
              {r.details.slice(0, 3).map((d, i, details) => (
                <span key={i} className="tw-swc-metric" style={{ color: d.improved ? 'var(--pos)' : 'var(--neg)' }}>
                  {d.label} {d.improved ? '↑' : '↓'}{fmtDelta(d.deltaRaw)}{d.unit ? ` ${d.unit}` : ''}{i < details.length - 1 ? ' · ' : ''}
                </span>
              ))}
            </span>
          </span>
          <span className="tw-swc-count" style={{ color: r.declined > 0 ? 'var(--neg)' : 'var(--pos)' }}>
            {r.declined > 0 ? `${r.declined}↓` : `${r.improved}↑`}
          </span>
        </div>
      )) : (
        <div className="tw-empty">本周无超过 SWC 阈值的指标变化。</div>
      )}
      {rows.length > 0 && <div className="tw-foot">按下降项数排序 · 无严重度分级 · 需人工查看 · 显示 {visibleRows.length} / {rows.length} 人</div>}
    </div>
  );
}

// TEAM-2b: signed-Δ formatter shared by metric-compare (mirrors swc-movers).
function twFmtDelta(d) {
  if (typeof d !== 'number' || !isFinite(d)) return '';
  const s = Math.abs(d) >= 100 ? d.toFixed(0) : Math.abs(d) >= 1 ? d.toFixed(1) : d.toFixed(2);
  return d > 0 ? `+${s}` : s;
}
// Value formatter: mono, sensible precision, RAW (no normalization).
function twFmtValue(v) {
  if (typeof v !== 'number' || !isFinite(v)) return '—';
  if (Math.abs(v) >= 100) return v.toFixed(0);
  if (Math.abs(v) >= 10) return v.toFixed(1);
  return v.toFixed(2);
}

// TEAM-2b: 指标横向对比 — pick one catalog metric; rows = athlete × horizontal bar
// (raw value scaled to visible max FOR LAYOUT ONLY) + mono raw value+unit + signed
// Δ. NO normalization of the displayed number (red line, mirrors MB-3). Δ judgment
// color is dir-aware (dir==='lower' ? delta<0 : delta>0 is the improving sign).
// Selected metric persists in component state. Force entries show a 力板 tag.
function TeamMetricCompareCard({ data, selectedMetricId, onSelectMetric, onOpenAthlete }) {
  const options = Array.isArray(data?.options) ? data.options : [];
  const selected = data?.selected || null;
  const rows = Array.isArray(data?.rows) ? data.rows : [];
  const visibleRows = rows.slice(0, 4);
  const max = data?.max || 0;
  const dir = selected?.dir;
  const deltaColor = (d) => {
    if (typeof d !== 'number' || d === 0) return 'var(--muted)';
    const improving = dir === 'lower' ? d < 0 : d > 0;
    return improving ? 'var(--pos)' : 'var(--neg)';
  };
  return (
    <div className="tw-card" data-team-module-content="metric-compare" style={{ height: '100%' }}>
      <div className="tw-card-h"><b>指标横向对比</b><small>选一指标 · 队内横比</small></div>
      {selected ? (
        <>
          <div className="tw-mc-pick">
            <span className="tw-mc-chip">{selected.label}{selected.unit ? ` · ${selected.unit}` : ''}</span>
            {selected.provenance && <span className="tw-mc-prov" title={selected.provenance}>{selected.provenance}</span>}
            <select className="tw-mc-sel" title="切换指标" value={selected.id}
              onChange={(e) => onSelectMetric && onSelectMetric(e.target.value)}>
              {options.map((o) => (
                <option key={o.id} value={o.id}>{o.label}{o.provenance ? ` · ${o.provenance}` : ''}</option>
              ))}
            </select>
          </div>
          {rows.length ? visibleRows.map((r) => {
            const w = max > 0 ? Math.max(2, Math.round((Math.abs(r.value) / max) * 100)) : 2;
            return (
              <div key={r.athleteId} className="tw-mc-row" title="点击查看该运动员"
                onClick={() => onOpenAthlete && onOpenAthlete(r.athleteId)} style={{ cursor: 'pointer' }}>
                <span className="tw-mc-name">{r.name}{r.jersey != null ? <small>#{r.jersey}</small> : null}</span>
                <span className="tw-mc-bar"><i style={{ width: `${w}%` }} /></span>
                <span className="tw-mc-val">{twFmtValue(r.value)}{selected.unit ? <small>{selected.unit}</small> : null}</span>
                <span className="tw-mc-d" style={{ color: deltaColor(r.delta) }}>{r.delta == null ? '' : twFmtDelta(r.delta)}</span>
              </div>
            );
          }) : (
            <div className="tw-empty">该指标暂无可绘制的运动员数据。</div>
          )}
          <div className="tw-foot">近值 + 相邻两次 Δ · 排序按值 · 无归一化</div>
        </>
      ) : (
        <div className="tw-empty">暂无任何运动员的可比指标数据。</div>
      )}
    </div>
  );
}

// TEAM-2b: 测试覆盖 — athlete × 4 test-type grid. Cell = green (≥1 session in the
// coverage window) / gray (none). BINARY fact colors only — no overdue tint. Hover =
// latest date or 无记录. Rows for ALL athletes (absence is the signal).
function TeamTestCoverageCard({ data }) {
  const columns = Array.isArray(data?.columns) ? data.columns : ['CMJ', 'SJ', 'IMTP', '体测'];
  const rows = Array.isArray(data?.rows) ? data.rows : [];
  const visibleRows = rows.slice(0, 5);
  const windowDays = data?.windowDays ?? 30;
  return (
    <div className="tw-card" data-team-module-content="test-coverage" style={{ height: '100%' }}>
      <div className="tw-card-h"><b>测试覆盖</b><small>运动员 × 测试类型</small></div>
      {rows.length ? (
        <table className="tw-tc-grid">
          <thead>
            <tr><th>运动员</th>{columns.map((c) => <th key={c}>{c}</th>)}</tr>
          </thead>
          <tbody>
            {visibleRows.map((r) => (
              <tr key={r.athleteId}>
                <td title={r.name}>{r.name}</td>
                {r.cells.map((cell) => (
                  <td key={cell.key}>
                    <span className={`tw-tc-cell ${cell.covered ? 'on' : 'off'}`}
                      title={cell.date ? `最近 ${cell.date}` : '无记录'} />
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      ) : (
        <div className="tw-empty">暂无运动员。</div>
      )}
      <div className="tw-foot">显示 {visibleRows.length} / {rows.length} 人 · 绿=时间窗内有会话 · 灰=无 · 纯事实无判断 · 窗口=赛季日前 {windowDays} 天</div>
    </div>
  );
}

// TEAM-2b: 出勤 / 可用 — three fact tiles + 零记录 list. NO attendance rate/score.
function TeamAttendanceFactsCard({ data, onOpenAthlete }) {
  const total = data?.total ?? 0;
  const trained = data?.trainedThisWeek ?? 0;
  const sessions = data?.sessions ?? 0;
  const injured = data?.injured ?? 0;
  const zeroLog = Array.isArray(data?.zeroLog) ? data.zeroLog : [];
  return (
    <div className="tw-card" data-team-module-content="attendance-facts" style={{ height: '100%' }}>
      <div className="tw-card-h"><b>出勤 / 可用</b><small>本周 · 诚实计数</small></div>
      <div className="tw-af-tiles">
        <div className="tw-af-tile"><div className="tw-af-n">{trained}<small>/{total}</small></div><div className="tw-af-l">本周记录训练</div></div>
        <div className="tw-af-tile"><div className="tw-af-n">{sessions}</div><div className="tw-af-l">总课次</div></div>
        <div className="tw-af-tile"><div className="tw-af-n">{injured}</div><div className="tw-af-l">在案伤病 (人)</div></div>
      </div>
      {zeroLog.length ? (
        <div className="tw-af-zero">
          <b>零记录：</b>
          {zeroLog.map((z, i) => (
            <span key={z.athleteId} onClick={() => onOpenAthlete && onOpenAthlete(z.athleteId)}
              style={{ cursor: 'pointer' }} title="点击查看">
              {z.name}{i < zeroLog.length - 1 ? '、' : ''}
            </span>
          ))}
        </div>
      ) : (
        <div className="tw-af-zero"><b>零记录：</b>无（全员本周有记录）</div>
      )}
      <div className="tw-foot">仅事实计数 · 无出勤率评分</div>
    </div>
  );
}

// TEAM-2b: 数据缺口 — factual gap rows sorted by daysStale desc. mono kind chip.
// Row click → athlete individual page (no athlete-scoped collection view is
// cleanly reachable from teamProps; individual page is the honest deep link).
function TeamDataGapsCard({ data, onOpenAthlete }) {
  const rows = Array.isArray(data?.rows) ? data.rows : [];
  const KIND_LABEL = { 'force-stale': '力板', 'seasons-empty': '体测', 'feedback-missing': '反馈' };
  return (
    <div className="tw-card" data-team-module-content="data-gaps" style={{ height: '100%' }}>
      <div className="tw-card-h"><b>数据缺口</b><small>完备性扫描</small></div>
      {rows.length ? rows.map((r, i) => (
        <div key={`${r.athleteId}-${r.kind}-${i}`} className="tw-dg-row" title="点击查看该运动员"
          onClick={() => onOpenAthlete && onOpenAthlete(r.athleteId)}>
          <span className="tw-dg-kind">{KIND_LABEL[r.kind] || r.kind}</span>
          <span className="tw-dg-name">{r.name}</span>
          <span className="tw-dg-fact">{r.factText}</span>
          {r.daysStale != null && <span className="tw-dg-days">{r.daysStale}d</span>}
        </div>
      )) : (
        <div className="tw-empty">当前无数据缺口事实项。</div>
      )}
      {rows.length > 0 && <div className="tw-foot">按缺口时长排序</div>}
    </div>
  );
}

const TEAM_MODULE_RENDERERS = {
  'discussion-queue': (props) => <TeamDiscussionQueueCard {...props} />,
  'confirmed-status': (props) => <TeamConfirmedStatusCard {...props} />,
  'swc-movers': (props) => <TeamSwcMoversCard {...props} />,
  'attention-list': (props) => <TeamAttentionListCard {...props} />,
  'squad-training': (props) => <TeamSquadTrainingCard {...props} />,
  'workload-overview': (props) => <TeamWorkloadOverviewCard {...props} />,
  'squad-trend': (props) => <TeamSquadTrendCard {...props} />,
  'weekly-confirmed': (props) => <TeamWeeklyConfirmedCard {...props} />,
  // TEAM-2b — 体能/行政组
  'metric-compare': (props) => <TeamMetricCompareCard {...props} />,
  'test-coverage': (props) => <TeamTestCoverageCard {...props} />,
  'attendance-facts': (props) => <TeamAttendanceFactsCard {...props} />,
  'data-gaps': (props) => <TeamDataGapsCard {...props} />,
};

function TeamWorkbench(props) {
  // TEAM-4 (2026-07-09): legacy ?teamlegacy=1 branch deleted (ruling §6③); the
  // workbench renders unconditionally — a stale ?teamlegacy=1 URL is inert.
  return <TeamWorkbenchView {...props} />;
}

function TeamWorkbenchView({
  athletes = [], seasons = [], season, onSeasonChange,
  onOpenTeamReport,
  reviewedEvidenceRows,
  onOpenReviewedEvidenceReport,
  onOpenReviewedEvidenceSource,
  // TEAM-2: already spread through by AppTeamRenderer — pull the ones the new
  // head-coach modules need (deep links + SWC compute inputs). No app.jsx change
  // is required for these; onOpenAthleteView is the one new threaded handler.
  groups = [], squadStatsAll = {},
  onSelectAthlete,
  onOpenAthleteView,
  onOpenRoster,
  // TEAM-2b: force session stores for metric-compare / test-coverage / data-gaps.
  // cmjStore + imtpStore already reach here via teamProps; sjStore is threaded in
  // TEAM-2b (app.jsx one-line addition) so metric-compare covers all force origins.
  cmjStore = {}, sjStore = {}, imtpStore = {},
}) {
  const registry = (typeof window !== 'undefined' && window.TeamModules) || null;
  const prefsRepo = (typeof window !== 'undefined' && window.VizPanelPrefsRepo) || null;

  const defaultOrder = tUseMemo(
    () => (registry ? registry.DEFAULT_MODULE_ORDER.map(m => ({ ...m })) : []),
    [registry]
  );

  // ── TEAM-1: persona view presets ────────────────────────────────────────────
  // Each persona persists its own tweaks under a 'team:<persona>' prefs domain;
  // the legacy plain 'team' domain (pre-persona) is migrated once into headcoach.
  const personas = tUseMemo(() => (registry ? registry.listPersonas() : []), [registry]);
  const defaultPersona = registry ? registry.DEFAULT_PERSONA_ID : 'sc';
  const personaDomain = (id) => `team:${id}`;
  // Preset modules for a persona (fresh mutable copy). Falls back to the team
  // default order if the registry lacks the persona.
  const presetModules = React.useCallback((id) => {
    const p = registry ? registry.getPersonaPreset(id) : null;
    return p ? p.modules.map(m => ({ ...m })) : defaultOrder.map(m => ({ ...m }));
  }, [registry, defaultOrder]);

  // One-time legacy migration: if a pre-persona 'team' domain layout exists and
  // differs from the default order, adopt it as headcoach's saved state (only if
  // headcoach has none yet). Never write back to the legacy 'team' key afterward.
  const migrateLegacyOnce = React.useCallback(() => {
    if (!prefsRepo || !registry) return;
    const legacy = prefsRepo.load('team');
    const legacyMods = Array.isArray(legacy?.modules) ? legacy.modules : [];
    const sameAsDefault = JSON.stringify(legacyMods) === JSON.stringify(defaultOrder);
    if (!legacyMods.length || sameAsDefault) return;
    const existing = prefsRepo.load(personaDomain(defaultPersona));
    const hasHeadcoach = Array.isArray(existing?.modules)
      && JSON.stringify(existing.modules) !== JSON.stringify(defaultOrder);
    if (hasHeadcoach) return;
    prefsRepo.save(personaDomain(defaultPersona), { modules: legacyMods });
  }, [prefsRepo, registry, defaultOrder, defaultPersona]);

  // Resolved initial persona (last-selected, keep-valid guard mirroring _season).
  const initialPersona = React.useMemo(() => {
    if (!prefsRepo || !registry) return defaultPersona;
    migrateLegacyOnce();
    const saved = prefsRepo.load('team-persona');
    const id = saved && saved.persona;
    return (id && registry.isValidPersona(id)) ? id : defaultPersona;
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  const [persona, setPersonaState] = tUseState(initialPersona);

  // Working prefs for the active persona: saved tweaks if present, else the
  // preset. "Present" = a saved 'team:<persona>' layout that differs from the
  // team default (a bare default means nothing was ever tweaked → use preset).
  const loadPersonaPrefs = React.useCallback((id) => {
    if (!prefsRepo) return { modules: presetModules(id) };
    const saved = prefsRepo.load(personaDomain(id));
    const mods = Array.isArray(saved?.modules) ? saved.modules : [];
    const untouched = !mods.length || JSON.stringify(mods) === JSON.stringify(defaultOrder);
    return untouched ? { modules: presetModules(id) } : { modules: mods };
  }, [prefsRepo, presetModules, defaultOrder]);

  const [prefs, setPrefs] = tUseState(() => loadPersonaPrefs(initialPersona));

  // Switch persona: persist the selection, load that persona's working prefs.
  const switchPersona = React.useCallback((id) => {
    if (!registry || !registry.isValidPersona(id)) return;
    setPersonaState(id);
    if (prefsRepo) prefsRepo.save('team-persona', { persona: id });
    setPrefs(loadPersonaPrefs(id));
  }, [registry, prefsRepo, loadPersonaPrefs]);

  const [customizing, setCustomizing] = tUseState(false);
  const [selectedStats, setSelectedStats] = tUseState(TEAM_STAT_METRICS.map(m => m.id));
  const [briefOpen, setBriefOpen] = tUseState(false);
  const [loadLogsByAthlete, setLoadLogsByAthlete] = tUseState({});
  const [programs, setPrograms] = tUseState([]);
  // TEAM-2b: metric-compare selected catalog metric id (component state; the
  // module persists its choice for the session. Prefs persistence is out of scope
  // — the VizPanelPrefs shape is layout-only, so a metric key is kept in state).
  const [compareMetricId, setCompareMetricId] = tUseState(null);

  const commitPrefs = React.useCallback((nextModules) => {
    const next = { modules: nextModules };
    if (prefsRepo) setPrefs(prefsRepo.save(personaDomain(persona), next));
    else setPrefs(next);
  }, [prefsRepo, persona]);

  const activeModules = Array.isArray(prefs?.modules) ? prefs.modules : defaultOrder;

  // Deviation: the active persona's working prefs differ from its preset (order,
  // size, or set). Drives the chip 已调整 dot + the 重置为预设 reset action.
  const deviated = tUseMemo(
    () => JSON.stringify(activeModules) !== JSON.stringify(presetModules(persona)),
    [activeModules, persona, presetModules]
  );
  const resetToPreset = React.useCallback(() => {
    const mods = presetModules(persona);
    if (prefsRepo) prefsRepo.save(personaDomain(persona), { modules: mods });
    setPrefs({ modules: mods });
  }, [persona, presetModules, prefsRepo]);
  const activeIds = activeModules.map(m => m.id);
  const sizeSpan = registry ? registry.SIZE_COLUMN_SPAN : { S: 1, M: 2, L: 3 };
  const sizeGrades = registry ? registry.SIZES : ['S', 'M', 'L'];

  // ── caller-prepared deterministic inputs: this week's training logs per
  // athlete + review-database programs (for the ↳训练 go-to count / rules).
  const sortedSeasons = tUseMemo(() => [...seasons].sort((a, b) => +new Date(a) - +new Date(b)), [seasons]);
  const weekRange = tUseMemo(() => {
    const end = season || sortedSeasons[sortedSeasons.length - 1];
    const d = end ? new Date(end) : null;
    if (!d || Number.isNaN(+d)) return { fromDate: null, toDate: null };
    const start = new Date(d.getFullYear(), d.getMonth(), d.getDate() - 6);
    const iso = (x) => `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, '0')}-${String(x.getDate()).padStart(2, '0')}`;
    return { fromDate: iso(start), toDate: iso(d) };
  }, [season, sortedSeasons]);

  const loadRange = tUseMemo(() => {
    if (!weekRange.toDate) return { fromDate: null, toDate: null };
    const end = new Date(`${weekRange.toDate}T00:00:00Z`);
    if (Number.isNaN(+end)) return { fromDate: null, toDate: null };
    const start = new Date(end);
    start.setUTCDate(start.getUTCDate() - 27);
    return { fromDate: start.toISOString().slice(0, 10), toDate: weekRange.toDate };
  }, [weekRange]);

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

  const weekLogsByAthlete = tUseMemo(() => Object.fromEntries(athletes.map((athlete) => [
    athlete.id,
    (loadLogsByAthlete[athlete.id] || []).filter((log) => log && log.date >= weekRange.fromDate && log.date <= weekRange.toDate),
  ])), [athletes, loadLogsByAthlete, weekRange]);

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

  const rows = tUseMemo(() => Array.isArray(reviewedEvidenceRows) ? reviewedEvidenceRows : [], [reviewedEvidenceRows]);

  // ── TEAM-2: caller-preloaded human-confirmed records (confirmed-status). Async
  // lives here (mirrors AthleteStoryInjuryStatusCard/MovementLatestCard store
  // calls); buildData stays pure over the resulting maps.
  const [injuriesByAthlete, setInjuriesByAthlete] = tUseState({});
  const [screensByAthlete, setScreensByAthlete] = tUseState({});
  tUseEffect(() => {
    let alive = true;
    const FS = window.FieldDataStore;
    if (!FS || typeof FS.listInjuriesByAthlete !== 'function') {
      setInjuriesByAthlete({}); setScreensByAthlete({});
      return () => { alive = false; };
    }
    (async () => {
      try {
        const ok = await FS.healthCheck();
        if (!ok || !alive) return;
        await FS.init('default');
        const inj = {}, scr = {};
        await Promise.all(athletes.map(async (a) => {
          try { inj[a.id] = await FS.listInjuriesByAthlete(a.id); } catch { inj[a.id] = []; }
          try { scr[a.id] = await FS.listScreenResultsByAthlete(a.id); } catch { scr[a.id] = []; }
        }));
        if (alive) { setInjuriesByAthlete(inj); setScreensByAthlete(scr); }
      } catch { if (alive) { setInjuriesByAthlete({}); setScreensByAthlete({}); } }
    })();
    return () => { alive = false; };
  }, [athletes]);

  // ── TEAM-2: per-athlete SWC rows (swc-movers). Reuses window.buildSWCRows
  // (swc.jsx) verbatim — same compute as the individual SWC panel; no new stat.
  const swcByAthlete = tUseMemo(() => {
    const build = typeof window !== 'undefined' && window.buildSWCRows;
    if (typeof build !== 'function' || !groups.length || !seasons.length) return {};
    const out = {};
    athletes.forEach((a) => {
      try { out[a.id] = (build(a, groups, seasons, squadStatsAll) || {}).rows || []; }
      catch { out[a.id] = []; }
    });
    return out;
  }, [athletes, groups, seasons, squadStatsAll]);

  // Pure buildData input contract (caller-prepared).
  const moduleInput = tUseMemo(() => ({
    athletes,
    reviewedRows: rows,
    trainingLogsByAthlete: weekLogsByAthlete,
    weekRange,
    loadLogsByAthlete,
    loadRange,
    programs,
    injuriesByAthlete,
    screensByAthlete,
    swcByAthlete,
    // TEAM-2b: MB-1 catalog handle + force stores + the active season anchor for
    // metric-compare / test-coverage / data-gaps. buildData stays pure over these.
    metricCatalog: (typeof window !== 'undefined' && window.AthleteMetricCatalog) || null,
    selectedMetricId: compareMetricId,
    cmjStore, sjStore, imtpStore,
    activeSeasonDate: season || sortedSeasons[sortedSeasons.length - 1] || null,
    fieldDataStore: window.FieldDataStore || null,
  }), [athletes, rows, weekLogsByAthlete, weekRange, loadLogsByAthlete, loadRange, programs, injuriesByAthlete, screensByAthlete, swcByAthlete,
       compareMetricId, cmjStore, sjStore, imtpStore, season, sortedSeasons]);

  // Build every active + stat-feeding module deterministically.
  const dataById = tUseMemo(() => {
    if (!registry) return {};
    const ids = new Set([...activeIds, 'discussion-queue', 'weekly-confirmed', 'squad-training']);
    const out = {};
    ids.forEach(id => { out[id] = registry.buildModuleData(id, moduleInput); });
    return out;
  }, [registry, activeIds.join('|'), moduleInput]);

  // Metric-row values, read from module data (no new compute).
  const statValues = tUseMemo(() => {
    const wc = dataById['weekly-confirmed'] || {};
    const st = dataById['squad-training'] || {};
    const attn = dataById['attention-list'] || registry?.buildModuleData('attention-list', moduleInput) || {};
    const rpeVals = athletes
      .map(a => { const s = a.seasons && a.seasons[season]; return s && typeof s.avgRPE === 'number' ? s.avgRPE : null; })
      .filter(v => v != null);
    return {
      confirmed: wc.confirmed ?? 0,
      feedbackRate: st.feedbackRate,
      avgRPE: rpeVals.length ? Math.round((rpeVals.reduce((s, v) => s + v, 0) / rpeVals.length) * 10) / 10 : null,
      pendingDiscussion: Array.isArray(attn.items) ? attn.items.length : 0,
    };
  }, [dataById, athletes, season, moduleInput, registry]);

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

  const renderModuleContent = (id) => {
    const render = TEAM_MODULE_RENDERERS[id];
    if (!render) return null;
    return render({
      data: dataById[id],
      onOpenRow: (row) => {
        if (row?.sourceType === 'cmj' && typeof onOpenReviewedEvidenceReport === 'function') {
          onOpenReviewedEvidenceReport(row);
          return;
        }
        if (typeof onOpenReviewedEvidenceSource === 'function') onOpenReviewedEvidenceSource(row);
      },
      weekLogsByAthlete,
      athletes,
      // TEAM-2 deep links: confirmed-status → athlete injury/movement-screen view;
      // swc-movers → athlete individual page. Fall back to onSelectAthlete when the
      // threaded view-opener is absent.
      onOpenAthleteRecord: (athleteId, view) => {
        if (typeof onOpenAthleteView === 'function') onOpenAthleteView(athleteId, view);
        else if (typeof onSelectAthlete === 'function') onSelectAthlete(athleteId);
      },
      onOpenAthlete: (athleteId) => { if (typeof onSelectAthlete === 'function') onSelectAthlete(athleteId); },
      // TEAM-2b metric-compare: selected metric id + setter (session state).
      selectedMetricId: compareMetricId,
      onSelectMetric: (id) => setCompareMetricId(id),
    });
  };

  const rangeLabel = teamFmtWeekRange(season || sortedSeasons[sortedSeasons.length - 1]);
  const squadName = 'Squad';
  const strictScLayout = persona === 'sc' && !deviated && !customizing;

  return (
    <main id="print-root" data-team-workbench style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
      <style>{TEAM_WORKBENCH_CSS}</style>
      <div className="tw-frame" style={{ flex: 1, minWidth: 0 }}>
        <div className="frame tw-surface">
          {/* 固定核心 · 队伍头 */}
          <div className="tw-head">
            <div className="tw-head-copy">
              <div className="tw-head-title"><h2>{squadName}</h2>{rangeLabel && <span className="tw-range">{rangeLabel}</span>}</div>
              <p>真实训练负荷、审核结果与测试覆盖在同一个工作区内，并保留各自数据来源。</p>
            </div>
            <div className="tw-actions">
              <button type="button" className="btn" title="上一周" onClick={() => {
                const i = sortedSeasons.indexOf(season);
                if (i > 0 && onSeasonChange) onSeasonChange(sortedSeasons[i - 1]);
              }}>‹</button>
              <button type="button" className="btn" title="下一周" onClick={() => {
                const i = sortedSeasons.indexOf(season);
                if (i >= 0 && i < sortedSeasons.length - 1 && onSeasonChange) onSeasonChange(sortedSeasons[i + 1]);
              }}>›</button>
              <button type="button" className="btn" onClick={onOpenRoster}>队伍成员　{athletes.length}</button>
              <button type="button" className="btn" onClick={onOpenTeamReport}>团队报告</button>
              <button type="button" className="btn primary" onClick={() => setBriefOpen(true)}>生成教练周简报</button>
            </div>
          </div>

          {/* TEAM-1 · 视角预设切换 */}
          <div className="tw-persona" role="group" aria-label="视角预设">
            <span className="tw-persona-label">视角</span>
            {personas.map(p => {
              const active = p.id === persona;
              const showDot = active && deviated;
              return (
                <button key={p.id} type="button"
                  className={`tw-chip${active ? ' is-active' : ''}`}
                  aria-pressed={active}
                  onClick={() => switchPersona(p.id)}>
                  {p.label}<span className="tw-persona-count">{registry?.getPersonaPreset(p.id)?.modules?.length || 0}</span>{showDot && <span className="tw-chip-suffix">已调整</span>}
                  {showDot && <span className="tw-chip-dot" aria-hidden="true" />}
                </button>
              );
            })}
            {deviated && (
              <button type="button" className="tw-persona-reset" onClick={resetToPreset}
                title="恢复该视角的预设布局">重置为预设</button>
            )}
            <span className="tw-persona-div" aria-hidden="true" />
            <button type="button" className="tw-chip tw-chip-custom" aria-pressed={customizing}
              onClick={() => setCustomizing(v => !v)}>
              {customizing ? '完成' : '⊞ 自定义模块'}
            </button>
          </div>

          {/* 可选指标行 */}
          <div className="tw-stats">
            {TEAM_STAT_METRICS.filter(m => selectedStats.includes(m.id)).slice(0, 4).map(m => {
              const v = statValues[m.id];
              return (
                <div key={m.id} className="tw-stat">
                  <div className="tw-l">{m.label}</div>
                  <div className="tw-v">{v == null ? '—' : v}{v != null && m.unit ? <small>{m.unit}</small> : null}</div>
                </div>
              );
            })}
            <TeamStatSelector selected={selectedStats} onChange={setSelectedStats} />
          </div>

          {/* Bento */}
          <div className={`athlete-story-bento-shell${customizing ? ' is-customizing' : ''}`} style={{ marginTop: 18 }}>
            <section className={`athlete-story-bento${strictScLayout ? ' tw-balanced-grid' : ''}`} aria-label="Team Workbench modules" style={strictScLayout ? undefined : {
              display: 'grid', gridTemplateColumns: '1.5fr 1fr 1fr', gridAutoRows: 'minmax(120px, auto)',
              gap: 14, alignItems: 'stretch',
            }}>
              {activeModules.map((mod, index) => {
                const meta = registry ? registry.getModule(mod.id) : null;
                const content = renderModuleContent(mod.id);
                if (!content) return null;
                const span = sizeSpan[mod.size] || 1;
                const rowSpan = mod.id === 'discussion-queue' && !strictScLayout ? 2 : 1;
                return (
                  <article key={mod.id}
                    className={`athlete-story-bento-module${customizing ? ' athlete-story-bento-module-editing' : ''}`}
                    data-team-module={mod.id}
                    style={strictScLayout ? { minWidth: 0 } : { gridColumn: `span ${span}`, gridRow: `span ${rowSpan}`, minWidth: 0 }}>
                    {customizing && (
                      <div className="athlete-story-bento-tools" role="group" aria-label={`Customize ${meta ? meta.label : mod.id}`}>
                        <span className="athlete-story-bento-tools-label">{meta ? meta.label : mod.id}</span>
                        <div className="athlete-story-bento-tools-actions">
                          <button type="button" title="上移" aria-label="Move up" disabled={index === 0}
                            onClick={() => moveModule(index, -1)}>↑</button>
                          <button type="button" title="下移" aria-label="Move down" disabled={index === activeModules.length - 1}
                            onClick={() => moveModule(index, 1)}>↓</button>
                          <span className="athlete-story-bento-size">
                            {sizeGrades.map(sz => (
                              <button key={sz} type="button" title={`Size ${sz}`} aria-pressed={mod.size === sz}
                                className={mod.size === sz ? 'is-active' : ''}
                                onClick={() => setModuleSize(index, sz)}>{sz}</button>
                            ))}
                          </span>
                          <button type="button" title="隐藏模块" aria-label="Hide module"
                            onClick={() => hideModule(mod.id)}>×</button>
                        </div>
                      </div>
                    )}
                    {content}
                  </article>
                );
              })}
            </section>

            {!customizing && (
              <div className="tw-module-summary">当前{personas.find(p => p.id === persona)?.label || '角色'}视角：{activeModules.length} / {registry?.listModules()?.length || activeModules.length} 模块</div>
            )}

            {customizing && (
              <aside className="athlete-story-module-library" aria-label="Module library">
                <h3>模块库 / Module Library</h3>
                <p>点击添加未启用的模块。新模块只需在 TeamModules 注册表登记，无需改装配框架。</p>
                <div className="athlete-story-module-library-list">
                  {libraryModules.length === 0 && (
                    <div className="athlete-story-module-library-empty">全部模块已启用</div>
                  )}
                  {libraryModules.map(m => (
                    <div className="athlete-story-module-library-item" key={m.id}>
                      <span>{m.label}</span>
                      <button type="button" onClick={() => addModule(m.id)}>添加 / Add</button>
                    </div>
                  ))}
                </div>
              </aside>
            )}
          </div>

          {/* 可选库脚注（documented backlog） */}
          <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 14, fontSize: 10.5, color: 'var(--muted)', flexWrap: 'wrap' }}>
            可选库（后续切片）：数值排序表（中性化排行）· 热力图 · 对比矩阵 · InsightFeed（逐条中性化后）· 出勤板
          </div>
        </div>
      </div>

      {briefOpen && window.WeeklyBriefPreviewModal && (
        <window.WeeklyBriefPreviewModal
          athletes={athletes}
          confirmedResultRows={rows}
          onClose={() => setBriefOpen(false)}
        />
      )}
    </main>
  );
}




function GapAnalysisPanel({ groups, athletes, season, seasons, squadStatsAll, algo }) {
  const D = window.DASHBOARD_DATA;
  const sortedSeasons = tUseMemo(() => [...seasons].sort((a, b) => +new Date(a) - +new Date(b)), [seasons]);
  const currIdx = sortedSeasons.indexOf(season);
  const prevSeason = currIdx > 0 ? sortedSeasons[currIdx-1] : null;
  const currSS = squadStatsAll[season] || {};
  const prevSS = prevSeason ? (squadStatsAll[prevSeason] || null) : null;

  const groupData = tUseMemo(() => {
    if (!athletes.length) return [];
    return groups.map(g => {
      const curr = Math.round(athletes.reduce((s,a) => s + D.computeGroupScore(a.seasons[season]||{},g,currSS,algo),0)/athletes.length);
      const prev = prevSS ? Math.round(athletes.reduce((s,a) => s + D.computeGroupScore(a.seasons[prevSeason]||{},g,prevSS,algo),0)/athletes.length) : null;
      return { g, curr, delta: prev != null ? curr - prev : null };
    }).sort((a,b) => a.curr - b.curr);
  }, [groups, athletes, season, prevSeason, squadStatsAll, algo]);

  const half = Math.ceil(groupData.length / 2);
  const focusAreas = groupData.slice(0, half);
  const strengths = [...groupData.slice(half)].reverse();

  const GapRow = ({ item }) => {
    const { g, curr, delta } = item;
    return (
      <div style={{ display:'flex', alignItems:'center', gap:10, padding:'8px 10px', borderRadius:6, background:'rgba(15,23,42,.04)' }}>
        <span style={{ width:3, height:20, borderRadius:1, background:g.accent, flexShrink:0 }}/>
        <div style={{ flex:1, minWidth:0 }}>
          <div style={{ fontSize:12, color:'var(--text-2)', marginBottom:4 }}>{g.label}</div>
          <div style={{ height:4, background:'rgba(15,23,42,.08)', borderRadius:2, position:'relative' }}>
            <div style={{ position:'absolute', left:0, top:0, bottom:0, width:`${curr}%`, borderRadius:2, background:scoreColor(curr), opacity:.85 }}/>
          </div>
        </div>
        <div style={{ display:'flex', alignItems:'center', gap:6, flexShrink:0 }}>
          <span className="mono" style={{ fontSize:14, fontWeight:600, color:scoreColor(curr), minWidth:28, textAlign:'right' }}>{curr}</span>
          {delta != null && (
            <span className="mono" style={{ fontSize:11, fontWeight:500, color: delta>0?'var(--pos)':delta<0?'var(--neg)':'var(--muted)', minWidth:32, textAlign:'right' }}>
              {delta>0?'+':''}{delta}
            </span>
          )}
        </div>
      </div>
    );
  };

  return (
    <section style={{ background:'var(--panel)', border:'1px solid var(--border)', borderRadius:10, padding:'14px 16px', display:'flex', flexDirection:'column', gap:12 }}>
      <div>
        <div style={{ fontSize:13, fontWeight:600 }}>Gap Analysis</div>
        <div style={{ fontSize:11, color:'var(--muted)', marginTop:2 }}>
          Team strengths vs focus areas · {_algoLabel(algo)}
          {prevSeason && <span style={{ color:'var(--muted-2)', marginLeft:6 }}>· Δ vs {window.formatDate?window.formatDate(prevSeason,'short'):prevSeason}</span>}
        </div>
      </div>
      <div style={{ display:'grid', gridTemplateColumns:'minmax(0,1fr) minmax(0,1fr)', gap:12 }}>
        <div style={{ border:'1px solid rgba(248,113,113,.25)', borderRadius:8, background:'rgba(248,113,113,.04)', overflow:'hidden' }}>
          <div style={{ padding:'8px 10px', borderBottom:'1px solid rgba(248,113,113,.15)', fontSize:11, fontWeight:600, color:'var(--neg)', display:'flex', alignItems:'center', gap:6 }}>
            <span>↓</span> {window.t('Focus Areas')}
          </div>
          <div style={{ padding:'8px', display:'flex', flexDirection:'column', gap:4 }}>
            {focusAreas.map(item => <GapRow key={item.g.id} item={item}/>)}
          </div>
        </div>
        <div style={{ border:'1px solid rgba(52,211,153,.25)', borderRadius:8, background:'rgba(52,211,153,.04)', overflow:'hidden' }}>
          <div style={{ padding:'8px 10px', borderBottom:'1px solid rgba(52,211,153,.15)', fontSize:11, fontWeight:600, color:'var(--pos)', display:'flex', alignItems:'center', gap:6 }}>
            <span>↑</span> {window.t('Strengths')}
          </div>
          <div style={{ padding:'8px', display:'flex', flexDirection:'column', gap:4 }}>
            {strengths.map(item => <GapRow key={item.g.id} item={item}/>)}
          </div>
        </div>
      </div>
    </section>
  );
}

function DistributionPanel({ athletes, season, groups, group, squadStats, matrix, activeAlgo, onSelectAthlete, focusGroupId, onFocusGroup, prevSquadStats }) {
  if (!group) return null;
  return (
    <section style={{ background:'var(--panel)', border:'1px solid var(--border)', borderRadius:10, padding:'14px 16px', display:'flex', flexDirection:'column', gap:12 }}>
      <div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', gap:12, flexWrap:'wrap' }}>
        <div style={{ display:'flex', alignItems:'center', gap:10 }}>
          <span style={{ width:4, height:18, borderRadius:2, background:group.accent, boxShadow:`0 0 8px ${group.accent}66` }}/>
          <div>
            <div style={{ fontSize:13, fontWeight:600 }}>{group.label} · per-metric distribution</div>
            <div style={{ fontSize:11, color:'var(--muted)', marginTop:2 }}>
              Box = Q1–Q3 · line = median · tick = mean · dots = each athlete · red zone = outside reference
            </div>
          </div>
        </div>
        <div style={{ display:'flex', gap:4, flexWrap:'wrap' }}>
          {groups.map(g => (
            <button key={g.id} onClick={() => onFocusGroup(g.id)}
              style={{
                padding:'3px 8px', borderRadius:4, fontSize:11, cursor:'pointer',
                border:`1px solid ${focusGroupId===g.id ? g.accent : 'var(--border)'}`,
                background: focusGroupId===g.id ? g.accent+'22' : 'transparent',
                color: focusGroupId===g.id ? g.accent : 'var(--text-2)',
                fontFamily:'var(--font-sans)', transition:'all .12s',
              }}>
              {g.label}
            </button>
          ))}
        </div>
      </div>
      <div style={{ display:'flex', flexDirection:'column', gap:12 }}>
        {group.metrics.map(m => (
          <BoxStrip key={m.id}
            metric={m} athletes={athletes} season={season}
            squadStats={squadStats} algo={activeAlgo}
            accent={group.accent} onSelectAthlete={onSelectAthlete}
            prevSquadStats={prevSquadStats}
          />
        ))}
      </div>
      {/* legend */}
      <div style={{ display:'flex', gap:16, fontSize:10, color:'var(--muted)', paddingTop:4, flexWrap:'wrap' }}>
        <span style={{ display:'inline-flex', alignItems:'center', gap:5 }}>
          <span style={{ width:12, height:8, background:`${group.accent}22`, border:`1px solid ${group.accent}77`, borderRadius:2 }}/>
          Q1–Q3 box
        </span>
        {prevSquadStats && (
          <span style={{ display:'inline-flex', alignItems:'center', gap:5 }}>
            <span style={{ width:12, height:8, background:'transparent', border:`1.5px dashed ${group.accent}55`, borderRadius:2 }}/>
            Prev session Q1–Q3
          </span>
        )}
        <span style={{ display:'inline-flex', alignItems:'center', gap:5 }}>
          <span style={{ width:2, height:10, background:group.accent }}/>
          Median
        </span>
        <span style={{ display:'inline-flex', alignItems:'center', gap:5 }}>
          <span style={{ width:2, height:10, background:'var(--text-2)', opacity:.55 }}/>
          Mean
        </span>
        <span style={{ display:'inline-flex', alignItems:'center', gap:5 }}>
          <span style={{ width:7, height:7, borderRadius:99, background:'var(--neg)' }}/>
          Outlier / Outside reference
        </span>
      </div>
    </section>
  );
}

// ── P3-3: 全队预警矩阵 ────────────────────────────────────────────────────
// Rows = risk dimensions, Columns = athletes. Each cell is a traffic light block.
// Gives coach a 30-second "who needs attention" overview.
const RISK_CELL_COLORS = {
  gray:  { bg: 'var(--panel-2)',              text: 'var(--muted)',   border: 'var(--border)' },
  green: { bg: 'rgba(16,185,129,.10)',         text: '#059669',        border: 'rgba(16,185,129,.25)' },
  amber: { bg: 'rgba(245,158,11,.13)',         text: '#b45309',        border: 'rgba(245,158,11,.35)' },
  red:   { bg: 'rgba(239,68,68,.13)',          text: '#dc2626',        border: 'rgba(239,68,68,.35)' },
};

// Kept in TEAM-4: consumed by GapAnalysisPanel above (not part of the deleted
// legacy TeamDashboard tree).
function scoreColor(s) {
  if (s >= 70) return '#34d399';
  if (s >= 55) return '#60a5fa';
  if (s >= 40) return '#fbbf24';
  return '#f87171';
}

// TEAM-4 (2026-07-09): TeamDashboard removed from the window exposure with the
// legacy deletion; TeamWorkbench + ACWRChart remain live consumers' handles.
Object.assign(window, { TeamWorkbench, ACWRChart });
