// ============================================================================
//  PLAYBOOK APP — Mission Gold visual language throughout
//  TEIKO の間取り（紘志さん承認 2026-06-11）:
//    TODAY（今日）／ TREND（体調の推移）／ FUTURE（予定）／ PROFILE（自分）／ HISTORY（漕歴）
//  HISTORY は共有棟（/soushi）を埋め込み。TREND・PROFILE は D1 実データのみ（NO FAKE NUMBERS）。
// ============================================================================

const { PHASES, CAMPAIGNS, DAY_MENU, QUOTES,
  SEASON_START, SEASON_END,
  iso, parseISO, daysBetween, findPhase, dayInPhase,
  weekDates, fmtJp, weekdayJp, weekdayEn,
} = window.PB;
// 選手の識別情報は playbook-data.jsx（window.PB.ATHLETE / MY_WALL）から差し込む。
// これにより playbook-app.jsx は全選手で共通（テンプレート化）。
const ATH = window.PB.ATHLETE || { id: "athlete", jp: "", en: "ATHLETE", sub: "" };
const MY_WALL = window.PB.MY_WALL || [];

// JARA承認済みメニューの動的マージ（shell の /jara で承認した計画が再デプロイ不要で反映される）
function useLiveMenu(groupsCsv) {
  const [tick, setTick] = React.useState(0);
  React.useEffect(() => {
    let dead = false;
    fetch(`/api/menu?g=${encodeURIComponent(groupsCsv)}`, { credentials: "same-origin", cache: "no-store" })
      .then((r) => (r.ok ? r.json() : null))
      .then((j) => {
        if (dead || !j || !j.days) return;
        const ks = Object.keys(j.days);
        if (!ks.length) return;
        Object.assign(DAY_MENU, j.days);  // 静的メニューより承認済みが優先
        setTick((t) => t + 1);
      })
      .catch(() => {});
    return () => { dead = true; };
  }, []);
  return tick;
}


// メニュー文字列中の「S&C」を、クリックでS&Cメニュー(SCModal)を開くリンクにする。
// window.SC_WC3 が読み込まれている選手ページでのみ有効。
function scText(p) {
  if (!window.SC_WC3 || (window.SC_WC3.athletes || []).indexOf(ATH.id) < 0) return p; // 対象選手のみ
  if (!p || String(p).indexOf("S&C") < 0) return p;
  const s = String(p), i = s.indexOf("S&C");
  return (
    <React.Fragment>
      {s.slice(0, i)}
      <a className="pb-sc-link" onClick={(e) => { e.stopPropagation(); window.dispatchEvent(new Event("rrpg-open-sc")); }}>S&amp;C</a>
      {s.slice(i + 3)}
    </React.Fragment>
  );
}

// S&Cプログラム モーダル（window.SC_WC3 を表示）。"rrpg-open-sc" イベントで開く。
function SCModal() {
  const [open, setOpen] = React.useState(false);
  React.useEffect(() => {
    const h = () => { setOpen(true); try { document.body.style.overflow = "hidden"; } catch (e) {} };
    window.addEventListener("rrpg-open-sc", h);
    return () => window.removeEventListener("rrpg-open-sc", h);
  }, []);
  if (!open || !window.SC_WC3) return null;
  const P = window.SC_WC3;
  const close = () => { setOpen(false); try { document.body.style.overflow = ""; } catch (e) {} };
  return (
    <div className="pb-sc-ov" onClick={close}>
      <div className="pb-sc-modal" onClick={(e) => e.stopPropagation()}>
        <button className="pb-sc-x" onClick={close} aria-label="閉じる">✕</button>
        <div className="pb-sc-head">
          <div className="pb-sc-title">{P.title}</div>
          <div className="pb-sc-sub">{P.sub}</div>
          <div className="pb-sc-meta pb-mono">{P.span} · {P.race}</div>
        </div>
        <div className="pb-sc-ref pb-mono">
          <div><b>1RM</b> {(P.oneRM || []).join(" / ")}</div>
          <div><b>換算</b> {P.ratios}</div>
          <div><b>RIR</b> {P.rir}</div>
        </div>
        {(P.weeks || []).map((w, wi) => (
          <div key={wi} className="pb-sc-week">
            <div className="pb-sc-wk-head">
              <span className="pb-sc-wk-label">{w.label}</span>
              <span className="pb-sc-wk-dates pb-mono">{w.dates} · {w.split}{w.merged ? (" · " + w.merged) : ""}</span>
            </div>
            <div className="pb-sc-wk-theme">{w.theme}</div>
            {(w.days || []).map((d, di) => (
              <div key={di} className="pb-sc-day">
                <div className="pb-sc-day-head">
                  <span className="pb-sc-day-label">{d.label}</span>
                  <span className="pb-sc-day-date pb-mono">{d.date}</span>
                  <span className="pb-sc-day-theme">{d.theme}</span>
                </div>
                {d.sub && <div className="pb-sc-day-sub">⚠ {d.sub}</div>}
                {(d.rows || []).map((r, ri) => (
                  <div key={ri} className="pb-sc-ex">
                    <div className="pb-sc-ex-name">{r.ex}</div>
                    <div className="pb-sc-ex-spec pb-mono">
                      <span className="pb-sc-load">{r.load}</span>
                      <span className="pb-sc-sr">{r.sr}</span>
                      <span className="pb-sc-rir">RIR {r.rir}</span>
                    </div>
                    {r.note && <div className="pb-sc-ex-note">{r.note}</div>}
                  </div>
                ))}
                <div className="pb-sc-vol pb-mono">⌛ {d.vol}</div>
                {d.tail && <div className="pb-sc-tail">{d.tail}</div>}
              </div>
            ))}
            {w.footer && <div className="pb-sc-foot pb-mono">{w.footer}</div>}
          </div>
        ))}
        <div className="pb-sc-bottom pb-mono">{P.foot}</div>
      </div>
    </div>
  );
}

// メニューを練習回数で分ける（「／」区切り）。2回=AM/PM、3回=EM/AM/PM。1つなら単独表示。
// keyChip=true なら高強度(Z3/RP/TT等)の行の横に KEY チップを置く（1日の真ん中ではなく該当行に）
function MenuAMPM({ menu, keyChip }) {
  if (!menu) return null;
  const parts = String(menu).split("／").map((s) => s.trim()).filter(Boolean);
  if (parts.length < 2) {
    return <span>{scText(parts[0] || String(menu))}{keyChip && <span className="pb-ampm-key">KEY</span>}</span>;
  }
  const shown = parts.length > 3 ? [parts[0], parts[1], parts.slice(2).join(" ／ ")] : parts;
  const labels = shown.length >= 3 ? ["EM", "AM", "PM"] : ["AM", "PM"];
  let ki = -1;
  if (keyChip) {
    ki = shown.findIndex((p) => /Z3|RP|TT|測定|2km|レース|スタート/.test(p));
    if (ki < 0) ki = 0;
  }
  return (
    <React.Fragment>
      {shown.map((p, i) => (
        <div key={i} className="pb-ampm">
          <span className="pb-ampm-l">{labels[Math.min(i, labels.length - 1)]}</span>
          <span className="pb-ampm-t">{scText(p)}{i === ki && <span className="pb-ampm-key">KEY</span>}</span>
        </div>
      ))}
    </React.Fragment>
  );
}

// ─────────────────────────────────────────────────────────────────────────────
//  Helpers / Hooks
// ─────────────────────────────────────────────────────────────────────────────
const IS_EMBED = (() => {
  try { return new URLSearchParams(location.search).get("embed") === "1"; }
  catch { return false; }
})();

// 旧タブ名（road/code/state）を TEIKO の部屋名へ読み替え（ブックマーク・localStorage 互換）
const TAB_ALIAS = { road: "course", code: "course", state: "wake", cover: "today", grid: "wake" }; // grid は 2026-07-03 に PAST へ統合
function normTab(t) { return TAB_ALIAS[t] || t; }
// 画面内でタブを指すときはこの定数から差し込む（下タブの EN／JP と必ず一致させる）
const TAB_LABEL = { today: "TODAY（今日）", wake: "TREND（体調の推移）", course: "FUTURE（予定）", tuning: "PROFILE（自分）", hall: "HISTORY（漕歴）" };
// タブ切替はイベント方式（ページ全体の再読み込みはしない）
function goTab(key) { window.dispatchEvent(new CustomEvent("pb-set-tab", { detail: key })); }
// 保存失敗の文言（通信／ログイン切れ／入力値 の3分類＋次の行動。カード・ポップアップ・sRPE で共通）
function failMsg(e) {
  const st = e && e.status;
  if (st === 401 || st === 403) return "ログインが切れています。このページを開き直して（ログインし直して）から、もう一度「記録する」を押してください。";
  if (st === 400 || st === 422) return "入力内容に問題があります。値を確認して、もう一度「記録する」を押してください。";
  if (st == null || st === 0) return "通信できませんでした。電波の良い場所で、もう一度「記録する」を押してください。";
  return "保存できませんでした。少し待ってから、もう一度「記録する」を押してください（続く場合はコーチに連絡）。";
}
// fetch の応答を JSON にする。失敗時は status 付きの Error を投げる（failMsg で分類する）
function jsonOrThrow(r) {
  if (r.ok) return r.json();
  return r.text().then((t) => { const e = new Error(t || String(r.status)); e.status = r.status; throw e; });
}
// 朝チェック保存成功の共通通知（カード・ポップアップどちらで保存しても発火）。受け手は再取得せず detail で更新する
function emitHooperRecorded(detail) {
  window.dispatchEvent(new CustomEvent("pb-hooper-recorded", { detail: detail }));
}
// 朝チェックの状態通知（三箇条のしきい値を「今日の値＋直近履歴」に当てて気づきを促すだけ。練習内容の指示は出さない＝コーチの判断領域。2026-09-07 紘志指示）。rec=今日の値 / hist=直近履歴（date昇順）
//  ルール: 疲労感7+ ／ 睡眠の質が悪い日が2日連続 ／ 筋肉痛7+ → 高強度は Z1 に置換の目安
//  ※ 睡眠はこの画面では 1=ぐっすり…7=眠れず（大きいほど悪い）なので、三箇条の「質 4-」＝ここでは 4 以上
//  ※ 「やる気 3- が3日連続」は朝チェックに項目が無いため判定しない
//  戻り値: null=判定不能（未記録） / []=該当なし / ["疲労感",…]=該当項目名
function hooperAdvice(rec, hist, todayISO) {
  if (!rec || rec.sleep == null) return null;
  const hits = [];
  if (rec.fatigue >= 7) hits.push("疲労感");
  if (rec.soreness >= 7) hits.push("筋肉痛・張り");
  const y = todayISO ? dateShift(todayISO, -1) : null;
  const yRow = y ? (hist || []).find((r) => String(r.date) === y) : null;
  if (rec.sleep >= 4 && yRow && yRow.sleep >= 4) hits.push("睡眠の質（2日連続）");
  return hits;
}
function AdviceLine({ rec, hist, date }) {
  const hits = hooperAdvice(rec, hist, date);
  if (!hits) return null;
  if (!hits.length) return <div className="pb-advice pb-advice-ok">今朝の数値は普段の範囲です</div>;
  return <div className="pb-advice pb-advice-ng">数値が高い日が続いています（{hits.join("・")}）。今日の練習前にコーチに伝えてください</div>;
}
// 体温入力（体調不良「あり」のときだけ表示）。value=文字列 / onChange(str)
const TEMP_QUICK = ["36.5", "37.0", "37.5", "38.0"];
function parseTemp(t) {
  const n = parseFloat(String(t == null ? "" : t).replace(/[．，]/g, ".").replace(/[０-９]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0xfee0)));
  return Number.isFinite(n) && n >= 32 && n <= 43 ? Math.round(n * 10) / 10 : null;
}
function TempRow({ value, onChange }) {
  return (
    <div className="pb-temp-row">
      <span className="pb-ci-row-label">体温（任意）</span>
      <input className="pb-mono" type="text" inputMode="decimal" placeholder="36.5" value={value} onChange={(e) => onChange(e.target.value)} />
      <span className="pb-mono">℃</span>
      {TEMP_QUICK.map((t) => (
        <button key={t} type="button" className={`pb-ci-btn pb-mono ${value === t ? "pb-ci-btn-on" : ""}`} onClick={() => onChange(value === t ? "" : t)}>{t}</button>
      ))}
    </div>
  );
}
// 「コーチに連絡」— status API の contact_url があり、体調不良「あり」のときだけ出す
function ContactBtn({ url }) {
  if (!url) return null;
  return <a className="pb-contact-btn" href={url} target="_blank" rel="noopener noreferrer">コーチに連絡</a>;
}
// TODAY の「Z1 xx–xx bpm ／ Z3 xx– bpm」用。ZoneTable と同じ優先順位（実測LT → intervals自動）で帯を作る
function useZoneBands() {
  const [bands, setBands] = React.useState(null);
  React.useEffect(() => {
    let dead = false;
    const opt = { credentials: "same-origin", cache: "no-store" };
    Promise.all([
      fetch("/api/tuning/list?id=" + encodeURIComponent(ATH.id), opt).then((r) => (r.ok ? r.json() : null)).catch(() => null),
      fetch("/api/athlete/" + encodeURIComponent(ATH.id) + "/hrzones", opt).then((r) => (r.ok ? r.json() : null)).catch(() => null),
    ]).then(([tl, hz]) => {
      if (dead) return;
      const hrCur = {};
      ((tl && tl.rows) || []).filter((r) => r.category === "hrzone").forEach((r) => { if (!hrCur[r.item]) hrCur[r.item] = r; });
      const ltB = (hrCur.LT1 && hrCur.LT2) ? ltBands(hrCur.LT1.value, hrCur.LT2.value, (hrCur.HRmax ? hrCur.HRmax.value : (hz && hz.maxHr)), hz && hz.restingHr) : null;
      setBands(ltB || (hz && hz.bands) || null);
    });
    return () => { dead = true; };
  }, []);
  return bands;
}
// 選手の所在地時刻での「今日」（UTC+offset の日付）。useTab の保存日にも使う
function localTodayISO() {
  const tz = (window.PB && window.PB.TZ_OFFSET_HOURS != null) ? Number(window.PB.TZ_OFFSET_HOURS) : 9;
  return new Date(Date.now() + tz * 3600000).toISOString().slice(0, 10);
}

// localStorage の pb-tab は「タブ|保存日」で持つ。日付が変わっていたら TODAY で起動（同日は前回タブ維持）
function saveTab(t) {
  try { localStorage.setItem("pb-tab", t + "|" + localTodayISO()); } catch {}
}
function loadSavedTab() {
  try {
    const raw = localStorage.getItem("pb-tab") || "";
    if (!raw) return "today";
    const parts = raw.split("|");
    if (parts.length < 2) return "today";           // 旧形式（日付なし）は TODAY 扱い
    if (parts[1] !== localTodayISO()) return "today"; // 日付が変わった → TODAY
    return normTab(parts[0] || "today");
  } catch { return "today"; }
}
function useTab() {
  const [tab, setTabState] = React.useState(() => {
    // ?tab= は起動時に1回だけ読み、読んだら URL から消す（再読み込みで固定されないように）
    try {
      const sp = new URLSearchParams(location.search);
      const urlTab = sp.get("tab");
      if (urlTab) {
        sp.delete("tab");
        const q = sp.toString();
        try { history.replaceState(null, "", location.pathname + (q ? "?" + q : "") + location.hash); } catch {}
        const t = normTab(urlTab);
        saveTab(t);
        return t;
      }
    } catch {}
    return loadSavedTab();
  });
  const setTab = (t) => {
    setTabState(t);
    saveTab(t);
    window.scrollTo({ top: 0, behavior: "instant" });
  };
  React.useEffect(() => {
    const handler = (e) => {
      if (e.data && e.data.type === "rrpg-set-tab" && e.data.tab) {
        setTabState(normTab(e.data.tab));
        saveTab(normTab(e.data.tab));
        window.scrollTo({ top: 0, behavior: "instant" });
      }
    };
    // 画面内のボタンからの切替（pb-set-tab）。ページ再読み込みはしない
    const onSetTab = (e) => {
      const t = normTab(e && e.detail);
      if (!t) return;
      setTabState(t);
      saveTab(t);
      window.scrollTo({ top: 0, behavior: "instant" });
    };
    window.addEventListener("message", handler);
    window.addEventListener("pb-set-tab", onSetTab);
    return () => { window.removeEventListener("message", handler); window.removeEventListener("pb-set-tab", onSetTab); };
  }, []);
  return [tab, setTab];
}

function useToday() {
  // 「今日」は閲覧端末ではなく選手の所在地時刻で決める（遠征中=欧州CEST 等）。
  // window.PB.TZ_OFFSET_HOURS（_gen.mjs が EU_TZ から付与・既定9=JST）を使い、
  // UTC+offset の日付を求める＝日本から見ても欧州滞在中の選手は欧州日付になる。
  // 「今日」は実日付のまま返す（SEASON_END で止めない）。
  // フェーズ・シーズン帯の計算だけ seasonClamp() でシーズン内に丸め、シーズン外は「シーズン外」と表示する。
  return localTodayISO();
}
function inSeason(dateISO) { return dateISO >= SEASON_START && dateISO <= SEASON_END; }
function seasonClamp(dateISO) { return dateISO < SEASON_START ? SEASON_START : dateISO > SEASON_END ? SEASON_END : dateISO; }
// フェーズ検索（シーズン外は null）
function phaseOf(dateISO) { return inSeason(dateISO) ? (findPhase(dateISO) || null) : null; }

// ── 次レース判定（開始〜終了の期間中は RACE DAY／NOW、終了後に DONE）──────────
// CAMPAIGNS に endDate が無い場合は datesEn（例 "JUN 26 — 28" / "AUG 30 — SEP 2"）から終了日を推定。
const MON_IDX = { JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6, JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12 };
function campaignEnd(c) {
  if (!c) return "";
  if (c.endDate) return c.endDate;
  const start = c.targetDate || "";
  try {
    const parts = String(c.datesEn || "").split(/[—–-]/);
    if (parts.length >= 2) {
      const tail = parts[parts.length - 1].trim();
      const m = tail.match(/^([A-Z]{3})?\s*(\d{1,2})/i);
      if (m) {
        const y = start.slice(0, 4);
        const sm = Number(start.slice(5, 7));
        const mo = m[1] ? (MON_IDX[m[1].toUpperCase()] || sm) : sm;
        const end = y + "-" + String(mo).padStart(2, "0") + "-" + String(Number(m[2])).padStart(2, "0");
        if (end >= start) return end;
      }
    }
  } catch {}
  return start;
}
// "before"（開催前）/ "now"（開催中）/ "done"（終了後）
function campaignState(c, today) {
  if (!c) return "before";
  if (today < c.targetDate) return "before";
  if (today <= campaignEnd(c)) return "now";
  return "done";
}
// 次レース＝終了日がまだ過ぎていない直近の大会（開催中を含む）。全部終わっていれば最後の大会
function nextCampaign(today) {
  return CAMPAIGNS.find((c) => campaignEnd(c) >= today) || CAMPAIGNS[CAMPAIGNS.length - 1];
}

function useDailyFuel(intervalMs = 6000) {
  const fuels = window.DAILY_FUEL || [];
  // 55件からランダムに10件を選ぶ（ページを開くたびに入れ替え）
  const picks = React.useMemo(() => {
    const idx = fuels.map((_, i) => i);
    for (let i = idx.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [idx[i], idx[j]] = [idx[j], idx[i]];
    }
    return idx.slice(0, Math.min(10, idx.length)).map((k) => fuels[k]);
  }, []);
  const [i, setI] = React.useState(0);
  React.useEffect(() => {
    if (picks.length <= 1) return;
    const t = setInterval(() => setI((x) => (x + 1) % picks.length), intervalMs);
    return () => clearInterval(t);
  }, [picks.length]);
  return { fuel: picks[i] || { quote: "", sub: "", src: "" }, idx: i, picks, setI };
}

// ─────────────────────────────────────────────────────────────────────────────
//  HOST FLAG — 開催国の旗を TODAY タブのヒーローにうっすら（GER / CAN / JPN）
// ─────────────────────────────────────────────────────────────────────────────
function hostOf(peak) {
  const w = (peak && peak.whereEn) || "";
  if (/SUI/.test(w)) return "SUI";
  if (/GER/.test(w)) return "GER";
  if (/CAN/.test(w)) return "CAN";
  if (/NED/.test(w)) return "NED";
  if (/ESP/.test(w)) return "ESP";
  if (/JPN/.test(w)) return "JPN";
  return null;
}
function HostFlag({ code }) {
  return (
    <svg className="pb-flag-svg" viewBox="0 0 120 80" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
      {code === "SUI" && (
        <g>
          <rect x="0" y="0" width="120" height="80" fill="#d4202a" />
          <rect x="50" y="22" width="20" height="36" fill="#f7f3ec" />
          <rect x="42" y="30" width="36" height="20" fill="#f7f3ec" />
        </g>
      )}
      {code === "NED" && (
        <g>
          <rect y="0" width="120" height="26.7" fill="#ae1c28" />
          <rect y="26.7" width="120" height="26.6" fill="#f7f3ec" />
          <rect y="53.3" width="120" height="26.7" fill="#21468b" />
        </g>
      )}
      {code === "ESP" && (
        <g>
          <rect y="0" width="120" height="20" fill="#aa151b" />
          <rect y="20" width="120" height="40" fill="#f1bf00" />
          <rect y="60" width="120" height="20" fill="#aa151b" />
        </g>
      )}
      {code === "GER" && (
        <g>
          <rect y="0" width="120" height="26.7" fill="#141414" />
          <rect y="26.7" width="120" height="26.7" fill="#d4202a" />
          <rect y="53.4" width="120" height="26.6" fill="#f5c518" />
        </g>
      )}
      {code === "CAN" && (
        <g>
          <rect x="0" width="30" height="80" fill="#d4202a" />
          <rect x="90" width="30" height="80" fill="#d4202a" />
          <polygon points="60,28 63,38 73,38 65,45 68,56 60,49 52,56 55,45 47,38 57,38" fill="#d4202a" />
        </g>
      )}
      {code === "JPN" && <circle cx="60" cy="40" r="22" fill="#d4202a" />}
    </svg>
  );
}

// ─────────────────────────────────────────────────────────────────────────────
//  TOP BAR
// ─────────────────────────────────────────────────────────────────────────────
function TopBar({ tab, setTab, today }) {
  const d = parseISO(today);
  // 次のレース（CAMPAIGNS の直近未来）までのカウントダウン
  const nextRace = nextCampaign(today);
  const nextLabel = nextRace.short || "NEXT";
  const nd = daysBetween(today, nextRace.targetDate);
  const rs = campaignState(nextRace, today);
  const ndText = rs === "now" ? "NOW" : rs === "done" ? "DONE" : `D-${nd}`;
  return (
    <div className="pb-topbar">
      <div className="pb-tb-l">
        <div className="pb-blade" aria-label="日本代表ブレード" />
        <div>
          <div className="pb-tb-name">{ATH.en}</div>
          <div className="pb-tb-sub">{ATH.sub}</div>
        </div>
      </div>
      <div className="pb-tb-tabs">
        {[
          // 並び順 2026-07-03 紘志さん指定。旧GRIDタブを統合し、タブ名も PAST→GRID に改名（アイコンも旧GRIDのladder）
          // [key, EN, icon, JP] — JP は下タブの日本語補助（.pb-tab-jp）。TAB_LABEL と揃える
          ["today", "TODAY", "compass", "今日"],
          ["wake", "TREND", "ladder", "体調の推移"],  // 2026-09-05 GRID→TREND（体調の推移が入っている場所だと分かる名前に。選手で試行）
          ["course", "FUTURE", "route", "予定"],
          ["tuning", "PROFILE", "user", "自分"],
          ["hall", "HISTORY", "flag", "漕歴"],
        ].map(([key, en, icon, jp]) => (
          <button
            key={key}
            className={`pb-tab ${tab === key ? "pb-tab-on" : ""}`}
            onClick={() => setTab(key)}
          >
            <i className={`ti ti-${icon} pb-tab-ic`} aria-hidden="true"></i>
            <span className="pb-tab-en">{en}</span><span className="pb-tab-jp">{jp}</span>
          </button>
        ))}
      </div>
      <div className="pb-tb-r">
        <div className="pb-tb-meta">
          <div className="pb-tb-k">TODAY</div>
          <div className="pb-tb-v pb-mono">
            {d.getFullYear()}.{String(d.getMonth() + 1).padStart(2, "0")}.{String(d.getDate()).padStart(2, "0")} · {weekdayEn[d.getDay()]}
          </div>
        </div>
        <div className="pb-tb-meta pb-tb-meta-em">
          <div className="pb-tb-k">{nextLabel}</div>
          <div className="pb-tb-v pb-mono">
            <span className="pb-red">{ndText}</span>
          </div>
        </div>
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────────────────
//  COVER — poster mode
// ─────────────────────────────────────────────────────────────────────────────
function Cover({ today, setTab }) {
  const u23 = Math.max(0, daysBetween(today, "2026-07-23"));
  const wuc = Math.max(0, daysBetween(today, "2026-08-13"));
  const ic  = Math.max(0, daysBetween(today, "2026-08-26"));
  const seasonDays = daysBetween(SEASON_START, SEASON_END) + 1;
  const elapsed = Math.max(0, daysBetween(SEASON_START, seasonClamp(today)));
  const remaining = Math.max(0, seasonDays - elapsed);

  return (
    <div className="pb-cover">
      <div className="pb-grain" />

      <div className="pb-cover-stamp">
        <div className="pb-stamp-l">
          <div className="pb-mono pb-eyebrow">FILE №</div>
          <div className="pb-mono">MTM-2026/SUM</div>
        </div>
        <div className="pb-stamp-r">
          <div className="pb-mono pb-eyebrow">2km TT / 500m</div>
          <div className="pb-mono">7:19 — 1:50</div>
        </div>
      </div>

      <div className="pb-cover-name">
        <div className="pb-cover-name-row">MITOMA</div>
        <div className="pb-cover-name-row pb-cover-outline">UTAHA</div>
      </div>
      <div className="pb-cover-jp">テスト 太郎 ／ てすと たろう ／ デモ</div>

      <div className="pb-cover-headline">
        <div className="pb-cover-h-line">
          <span className="pb-red">{remaining}</span>&nbsp;DAYS
        </div>
        <div className="pb-cover-h-line pb-cover-h-dim">
          TO <span className="pb-strike">REST</span>.
        </div>
        <div className="pb-cover-h-line">
          {remaining}&nbsp;DAYS TO <span className="pb-red">BURN</span>.
        </div>
      </div>

      <div className="pb-cover-foot">
        <div className="pb-cover-foot-col">
          <div className="pb-eyebrow">CAMPAIGN</div>
          <div className="pb-cover-foot-h">2026 SUMMER</div>
          <div className="pb-cover-foot-sub">5月25日 → 8月30日 ／ 14週 ／ 98日</div>
        </div>
        <div className="pb-cover-foot-col">
          <div className="pb-eyebrow">MISSION</div>
          <div className="pb-cover-foot-h">3 STARTS · 3 FLAGS</div>
          <div className="pb-cover-foot-sub">U23 → WUC → インカレ</div>
        </div>
        <div className="pb-cover-foot-col">
          <div className="pb-eyebrow">PEAK</div>
          <div className="pb-cover-foot-h">ONE FULL · TWO ECHOES</div>
          <div className="pb-cover-foot-sub">ピークはU23に集中させる</div>
        </div>
      </div>

      <div className="pb-cover-targets">
        {[
          { en: "U23 WORLDS",     jp: "U23🇩🇪",   d: u23, primary: true },
          { en: "WUC",            jp: "WUC🇨🇦",   d: wuc },
          { en: "INTERCOLLEGIATE", jp: "インカレ🇯🇵", d: ic },
        ].map((t) => (
          <div key={t.en} className={`pb-target ${t.primary ? "pb-target-p" : ""}`}>
            <div className="pb-target-l">
              <div className="pb-target-en">{t.en}</div>
              <div className="pb-target-jp">{t.jp}</div>
            </div>
            <div className="pb-target-r">
              <div className="pb-target-d pb-mono">D-{t.d}</div>
            </div>
          </div>
        ))}
      </div>

      <button className="pb-cover-cta" onClick={() => setTab("today")}>
        <span className="pb-mono">→</span>&nbsp;ENTER TODAY
      </button>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────────────────
//  TODAY — daily dashboard
// ─────────────────────────────────────────────────────────────────────────────
function Today({ today }) {
  const seasonOut = !inSeason(today);
  const phase = phaseOf(today);
  const dn = phase ? dayInPhase(phase, today) : 0;
  const phaseDay = phase ? `DAY ${dn} / ${phase.days}` : "";
  const phasePct = phase ? Math.min(100, (dn / phase.days) * 100) : 0;

  const todayInfo = DAY_MENU[today] || {};

  // primary countdown — to the soonest upcoming peak race（開催中は NOW）
  const nextPeak = nextCampaign(today);
  const nextState = campaignState(nextPeak, today);
  const nextD = Math.max(0, daysBetween(today, nextPeak.targetDate));
  const host = hostOf(nextPeak);

  const { fuel, idx: fuelIdx, picks: fuelPicks, setI: setFuelI } = useDailyFuel();

  // 予定X分（メニュー文字列の 60' 等の合計）／ 実績Y分（sRPE カードが pb-srpe-minutes で知らせる。取れない間は「—」）
  const planMin = (String(todayInfo.menu || "").match(/(\d{2,3})'/g) || []).reduce((sum, m) => sum + parseInt(m, 10), 0);
  const [actualMin, setActualMin] = React.useState(null);
  React.useEffect(() => {
    const h = (ev) => setActualMin(ev.detail && Number.isFinite(ev.detail.minutes) ? ev.detail.minutes : null);
    window.addEventListener("pb-srpe-minutes", h);
    return () => window.removeEventListener("pb-srpe-minutes", h);
  }, []);
  // Z1／Z3 の基準心拍（PROFILE の ZoneTable と同じ計算。値が無ければ出さない）
  const zb = useZoneBands();
  const z1Txt = zb && zb.Z1 ? fmtHrBand(zb.Z1) : "", z3Txt = zb && zb.Z3 ? fmtHrBand(zb.Z3) : "";
  // 明日の予定（DAY_MENU → フェーズ概要 → OFF／シーズン外）
  const tmr = dateShift(today, 1);
  const tmrInfo = DAY_MENU[tmr] || {};
  const tmrPhase = phaseOf(tmr);
  const tmrMenu = tmrInfo.menu || (tmrPhase ? tmrPhase.desc : inSeason(tmr) ? "OFF" : "シーズン外");

  // 朝チェックが未入力の間だけ最上部に「今日の記録へ ↓」（CheckIn が pb-checkin-pending で知らせ、保存成功 pb-hooper-recorded で消す）
  const [needCheck, setNeedCheck] = React.useState(false);
  React.useEffect(() => {
    const onPending = (ev) => setNeedCheck(!!ev.detail);
    const onRec = () => setNeedCheck(false);
    window.addEventListener("pb-checkin-pending", onPending);
    window.addEventListener("pb-hooper-recorded", onRec);
    return () => { window.removeEventListener("pb-checkin-pending", onPending); window.removeEventListener("pb-hooper-recorded", onRec); };
  }, []);
  function jumpToCheckIn() {
    const el = document.querySelector(".pb-t-ci");
    if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
  }

  return (
    <div className="pb-today">
      <div className="pb-grain" />
      {needCheck && (
        <div className="pb-jump-row" style={{ margin: "6px 0 10px" }}>
          <button type="button" className="pb-jump-chip" onClick={jumpToCheckIn}>今日の記録へ ↓</button>
        </div>
      )}

      {/* PRIMARY: countdown hero（背景に開催国の旗をうっすら） */}
      <div className="pb-t-hero">
        {host && (
          <div className="pb-t-hero-flag" aria-hidden="true">
            <HostFlag code={host} />
            <div className="pb-t-hero-flag-cap pb-mono">HOST ── {host}</div>
          </div>
        )}
        <div className="pb-t-hero-l">
          <div className="pb-eyebrow"><span className="pb-bullet" /> PRIMARY OBJECTIVE</div>
          <div className="pb-t-hero-h">{nextPeak.nameEn}</div>
          <div className="pb-t-hero-jp">{nextPeak.nameJp} ／ {nextPeak.where}</div>
          <div className="pb-t-hero-role pb-mono">{nextPeak.role}</div>
        </div>
        <div className="pb-t-hero-r">
          <div className="pb-t-cd-num">{nextState === "now" ? "NOW" : nextState === "done" ? "DONE" : nextD}</div>
          <div className="pb-t-cd-stack">
            <div className="pb-t-cd-unit">{nextState === "now" ? "RACE DAY" : nextState === "done" ? "FINISHED" : "DAYS"}</div>
            <div className="pb-t-cd-sub pb-mono">{nextPeak.datesEn}</div>
          </div>
        </div>
      </div>

      {/* 3 targets row */}
      <div className="pb-t-targets">
        {CAMPAIGNS.map((c) => {
          const d = Math.max(0, daysBetween(today, c.targetDate));
          const cs = campaignState(c, today);
          const done = cs === "done";
          const isNext = c.id === nextPeak.id;
          return (
            <div key={c.id} className={`pb-t-tgt ${isNext ? "pb-t-tgt-active" : ""} ${done ? "pb-t-tgt-done" : ""}`}>
              <div className="pb-t-tgt-rank">P{c.rank}</div>
              <div className="pb-t-tgt-mid">
                <div className="pb-t-tgt-name">{c.nameEn}</div>
                <div className="pb-t-tgt-where">{c.where}</div>
                <div className="pb-t-tgt-meta pb-mono">{c.datesEn}</div>
              </div>
              <div className="pb-t-tgt-d">
                <span className="pb-mono pb-t-tgt-d-num">{done ? "✓" : cs === "now" ? "NOW" : `D-${d}`}</span>
              </div>
            </div>
          );
        })}
      </div>

      {/* TODAY focus card（フル幅・午前/午後に分割） */}
      <div className="pb-t-todaycard pb-t-todaycard-full">
        <div className="pb-t-today-head">
          <div className="pb-eyebrow pb-eyebrow-red">TODAY ／ 今日のメニュー</div>
          <div className="pb-t-today-date2 pb-mono">{today} ・ {weekdayJp[parseISO(today).getDay()]}曜</div>
        </div>
        <div className="pb-t-today-menu pb-t-today-menu-big">
          <MenuAMPM menu={todayInfo.menu || (phase ? phase.desc : seasonOut ? "シーズン外" : "OFF")} />
        </div>
        {todayInfo.key && <div className="pb-tag pb-tag-key">KEY SESSION ／ 高強度</div>}
        {todayInfo.race && <div className="pb-tag pb-tag-race">RACE DAY ／ 本戦</div>}
        {todayInfo.tag && !todayInfo.key && !todayInfo.race && <div className="pb-tag">{todayInfo.tag}</div>}
        {!todayInfo.menu && !todayInfo.race && (
          <div className="pb-cond-foot pb-mono">{seasonOut ? "いまはシーズン外（計画期間の前後）です ／ " : "詳細メニューはコーチ設定待ち ／ "}実施した練習は「{TAB_LABEL.wake}」に自動記録</div>
        )}
        <div className="pb-plan-line pb-mono">予定 {todayInfo.menu && planMin > 0 ? `${planMin}分` : "—"} ／ 実績 {actualMin != null ? `${actualMin}分` : "—"}</div>
        {z1Txt && z3Txt && <div className="pb-cond-foot pb-mono">Z1 {z1Txt} bpm ／ Z3 {z3Txt} bpm</div>}

        {phase && (
          <div className="pb-t-today-phase">
            <div className="pb-t-today-phase-head">
              <span className="pb-mono pb-eyebrow">CURRENT PHASE</span>
              <span className="pb-mono pb-t-today-phase-day">{phaseDay}</span>
            </div>
            <div className="pb-t-today-phase-row">
              <div className="pb-t-today-phase-emoji">{phase.emoji}</div>
              <div>
                <div className="pb-t-today-phase-name">{phase.labelEn}</div>
                <div className="pb-t-today-phase-jp">{phase.label} ／ {phase.desc}</div>
              </div>
            </div>
            <div className="pb-bar">
              <div className={`pb-bar-fill pb-bar-${phase.kind}`} style={{ width: `${phasePct}%` }} />
            </div>
          </div>
        )}

        {/* 明日の予定 ＋ 今週へ（FUTURE タブへイベント切替） */}
        <div className="pb-cond-foot pb-mono">明日: {tmrMenu}{tmrInfo.race ? "（レース）" : tmrInfo.key ? "（KEY）" : ""}</div>
        <button type="button" className="pb-tl-guide-btn pb-mono" onClick={() => goTab("course")}>今週の予定 →</button>
      </div>

      {/* CHECK-IN — Hooper → 体重 → sRPE の順（朝の流れ） */}
      <CheckIn />

      {/* CONDITION — 実データ（intervals.icu 連携後に点灯、数字の見せかけ無し） */}
      <Cond nextD={nextState === "done" ? null : nextD} />

      {/* DAILY FUEL — 名言は一番下に */}
      <div className="pb-t-quote pb-t-quote-bottom">
        <div className="pb-eyebrow pb-eyebrow-red">DAILY FUEL ／ 燃料</div>
        <blockquote className="pb-fuel-quote">{fuel.quote}</blockquote>
        {fuel.src && <div className="pb-fuel-src">— {fuel.src}</div>}
        {fuel.sub && <div className="pb-fuel-sub">{fuel.sub}</div>}
        <div className="pb-t-quote-dots pb-fuel-dots">
          {fuelPicks.map((_, i) => (
            <div key={i} className={`pb-dot ${i === fuelIdx ? "pb-dot-on" : ""}`} onClick={() => setFuelI(i)} />
          ))}
        </div>
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────────────────
//  COURSE 用: 週カレンダー＋シーズン帯（2026-06-11 に TODAY タブから移設。中身は同一）
// ─────────────────────────────────────────────────────────────────────────────
function WeekBlock({ today }) {
  const phase = phaseOf(today);
  const seasonStart = SEASON_START;
  const seasonEnd = SEASON_END;
  // シーズン帯の進み具合だけシーズン内に丸める（今日そのものは実日付のまま）
  const elapsed = Math.max(0, daysBetween(seasonStart, seasonClamp(today)));
  const total = daysBetween(seasonStart, seasonEnd) + 1;
  const seasonPct = Math.max(0, Math.min(100, (elapsed / total) * 100));

  // 週送り（0 = 今週、+1 = 翌週 …）
  const [weekOffset, setWeekOffset] = React.useState(0);
  const weekAnchor = (() => {
    const d = parseISO(today);
    d.setDate(d.getDate() + weekOffset * 7);
    return iso(d);
  })();
  const week = weekDates(weekAnchor);
  const weekLabel =
    weekOffset === 0 ? "THIS WEEK" :
    weekOffset === 1 ? "NEXT WEEK" :
    weekOffset === -1 ? "LAST WEEK" :
    weekOffset > 0 ? `+${weekOffset} WEEKS` : `${weekOffset} WEEKS`;
  const weekLabelJp =
    weekOffset === 0 ? "今週" :
    weekOffset === 1 ? "翌週" :
    weekOffset === -1 ? "前週" :
    weekOffset > 0 ? `${weekOffset}週先` : `${Math.abs(weekOffset)}週前`;
  const viewPct = Math.max(0, Math.min(100,
    (daysBetween(seasonStart, week[0]) / total) * 100));

  return (
    <div>
      <div className="pb-t-week">
        <div className="pb-section-head">
          <div className="pb-section-num">01</div>
          <div className="pb-section-titles">
            <div className="pb-section-en">{weekLabel}</div>
            <div className="pb-section-jp">{weekLabelJp}の漕ぎ ／ {fmtJp(week[0])} — {fmtJp(week[6])}</div>
          </div>
          <div className="pb-week-nav">
            <button className="pb-week-btn" onClick={() => setWeekOffset(weekOffset - 1)} aria-label="前の週">◀</button>
            <button className="pb-week-btn pb-week-btn-now" onClick={() => setWeekOffset(0)} disabled={weekOffset === 0}>今週</button>
            <button className="pb-week-btn" onClick={() => setWeekOffset(weekOffset + 1)} aria-label="次の週">▶</button>
          </div>
        </div>
        <div className="pb-week-grid">
          {week.map((d) => {
            const info = DAY_MENU[d] || {};
            const ph = findPhase(d);
            const wd = parseISO(d).getDay();
            const isToday = d === today;
            const isPast = d < today;
            return (
              <div key={d} className={`pb-day ${isToday ? "pb-day-today" : ""} ${isPast ? "pb-day-past" : ""} ${info.key ? "pb-day-key" : ""} ${info.race ? "pb-day-race" : ""}`}>
                <div className="pb-day-bar" style={{ background: ph ? `var(--c-${ph.kind})` : "transparent" }} />
                <div className="pb-day-head">
                  <div className="pb-day-wd">{weekdayEn[wd]}</div>
                  <div className="pb-day-date pb-mono">{fmtJp(d)}</div>
                </div>
                <div className="pb-day-menu">
                  <MenuAMPM menu={info.menu || (ph ? ph.label : "—")} keyChip={!!info.key} />
                </div>
                {info.tag && <div className="pb-day-event">{info.tag}</div>}
                {info.race && <div className="pb-day-tag pb-day-tag-race">RACE</div>}
                {isToday && <div className="pb-day-tag pb-day-tag-now">NOW</div>}
              </div>
            );
          })}
        </div>
        <div className="pb-week-legend">
          <span className="pb-mono">PHASE:</span>
          {(() => {
            const phs = [...new Set(week.map(d => findPhase(d)).filter(Boolean).map(p => p.id))]
              .map(id => PHASES.find(p => p.id === id));
            return phs.map(p => (
              <span key={p.id} className={`pb-week-leg pb-week-leg-${p.kind}`}>
                {p.emoji} {p.label}
              </span>
            ));
          })()}
        </div>
      </div>

      {/* Season progress strip */}
      <div className="pb-t-season">
        <div className="pb-t-season-head">
          <div className="pb-eyebrow pb-eyebrow-red">SEASON PROGRESS</div>
          <div className="pb-mono">{elapsed} / {total} DAYS · {Math.round(seasonPct)}%</div>
        </div>
        <div className="pb-ribbon-wrap">
          <div className="pb-ribbon">
            {PHASES.map((b, i) => {
              const isCurrent = phase && b.id === phase.id;
              return (
                <div key={i} className={`pb-block pb-block-${b.kind} ${isCurrent ? "pb-block-current" : ""}`}
                  style={{ flex: b.weight }}>
                  <div className="pb-block-top" />
                  <div className="pb-block-label">{b.peak ? `★ ${b.label}` : b.label}</div>
                  <div className="pb-block-pop" role="tooltip">
                    <div className="pb-block-pop-name">{b.peak ? `★ ${b.label}` : b.label}</div>
                    <div className="pb-block-pop-meta pb-mono">{b.start.slice(5).replace("-","/")} 〜 {b.end.slice(5).replace("-","/")} · {b.days}日</div>
                  {b.desc && <div className="pb-block-pop-desc">{b.desc}</div>}
                  </div>
                </div>
              );
            })}
            <div className="pb-season-fill" style={{ width: `${seasonPct}%` }} />
            <div className="pb-now-marker" style={{ left: `${seasonPct}%` }}>
              <div className="pb-now-arrow" />
              <div className="pb-now-line" />
              <div className="pb-now-label">NOW</div>
            </div>
            {weekOffset !== 0 && (
              <div className="pb-view-marker" style={{ left: `${viewPct}%` }}>
                <div className="pb-view-line" />
                <div className="pb-view-label">{weekLabelJp}</div>
              </div>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────────────────
//  ROAD — overview / chapters / timeline
// ─────────────────────────────────────────────────────────────────────────────
function Road({ today }) {
  const phase = phaseOf(today);
  return (
    <div className="pb-road">
      <div className="pb-grain" />

      <div className="pb-r-hero">
        <div className="pb-section-head">
          <div className="pb-section-num">02</div>
          <div className="pb-section-titles">
            <div className="pb-section-en">THE ROADMAP.</div>
            <div className="pb-section-jp">{window.PB.ROAD_SUB || "この夏の道のり"}</div>
          </div>
        </div>
        <div className="pb-r-hero-band">
          <div className="pb-r-hero-band-l">PRINCIPLE</div>
          <div className="pb-r-hero-band-c">
            <div className="pb-r-hero-en">{(window.PB.PRINCIPLE || {}).en || ""}</div>
            <div className="pb-r-hero-jp">{(window.PB.PRINCIPLE || {}).jp || ""}</div>
          </div>
        </div>
      </div>

      {/* THE VOYAGE — 縦の旅程。フェーズは1行、レースは大きな駅、現在地は金の灯 */}
      <div className="pb-voy">
        {PHASES.map((b, i) => {
          const isCurrent = phase && b.id === phase.id;
          const done = today > b.end;
          const dd = Math.max(0, daysBetween(today, b.start));
          const range = b.start.slice(5).replace("-", "/") + "–" + b.end.slice(5).replace("-", "/");
          if (b.kind === "race-int" || b.kind === "race-jp") {
            return (
              <div key={i} className={`pb-voy-item pb-voy-race ${done ? "pb-voy-done" : ""}`}>
                <span className="pb-voy-dot pb-voy-dot-race" />
                <div className="pb-voy-race-row">
                  <div>
                    <div className="pb-voy-race-name">{b.peak ? "★ " : ""}{b.label}</div>
                    <div className="pb-voy-race-sub pb-mono">{range} ／ {b.desc}</div>
                  </div>
                  <div className="pb-voy-race-d">{done ? "✓" : isCurrent ? "NOW" : `D-${dd}`}</div>
                </div>
              </div>
            );
          }
          return (
            <div key={i} className={`pb-voy-item ${done ? "pb-voy-done" : ""} ${isCurrent ? "pb-voy-nowitem" : ""}`}>
              <span className="pb-voy-dot" style={{ borderColor: `var(--c-${b.kind})` }} />
              <div className="pb-voy-head">
                <span className="pb-voy-name">{b.emoji} {b.label}</span>
                <span className="pb-voy-dates pb-mono">{range} ・ {b.days}日</span>
                {isCurrent && <span className="pb-voy-here pb-mono">いまここ ── DAY {dayInPhase(b, today)}/{b.days}</span>}
              </div>
              {!done && <div className="pb-voy-desc">{b.desc}</div>}
            </div>
          );
        })}
      </div>

      {/* N BATTLES（出場大会の数で自動） */}
      <div className="pb-section-head">
        <div className="pb-section-num">03</div>
        <div className="pb-section-titles">
          <div className="pb-section-en">{(["ONE", "TWO", "THREE", "FOUR"][CAMPAIGNS.length - 1] || "THE")} BATTLES.</div>
          <div className="pb-section-jp">{(["ひとつの", "二つの", "三つの", "四つの"][CAMPAIGNS.length - 1] || "")}戦場 ／ 優先順位は変えない</div>
        </div>
      </div>
      <div className="pb-r-chapters">
        {CAMPAIGNS.map((c, i) => {
          const d = Math.max(0, daysBetween(today, c.targetDate));
          const cs = campaignState(c, today);
          const done = cs === "done";
          return (
            <div key={c.id} className={`pb-ch pb-ch-${i}`}>
              <div className="pb-ch-l">
                <div className="pb-ch-rank">{c.rank}</div>
                <div className="pb-ch-rank-label">PRIORITY</div>
                <div className="pb-ch-d pb-mono">{done ? "DONE" : cs === "now" ? "NOW" : `D-${d}`}</div>
              </div>
              <div className="pb-ch-body">
                <div className="pb-ch-where pb-mono">{c.whereEn}</div>
                <div className="pb-ch-en">{c.nameEn}</div>
                <div className="pb-ch-jp">{c.nameJp}</div>
                <div className="pb-ch-meta">
                  <div className="pb-ch-meta-cell">
                    <div className="pb-ch-meta-k pb-mono">DATE</div>
                    <div className="pb-ch-meta-v pb-mono">{c.datesEn}</div>
                  </div>
                  <div className="pb-ch-meta-cell">
                    <div className="pb-ch-meta-k pb-mono">ROLE</div>
                    <div className="pb-ch-meta-v pb-mono">{c.role}</div>
                  </div>
                </div>
                <div className="pb-ch-note">{c.note}</div>
              </div>
            </div>
          );
        })}
      </div>

      <div className="pb-r-story" style={{ marginTop: 14 }}>
        <div className="pb-r-story-tag">NOTE</div>
        <div className="pb-r-story-text">
          各期の具体的なメニュー・意図は <b>コーチが設定</b> します。実施した練習・ゾーン配分・強度は
          「<b>{TAB_LABEL.wake}</b>」タブに intervals.icu から自動で表示されます（見せかけの数字は置きません）。
        </div>
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────────────────
//  CODE — 三箇条（航路の最後に置く）とゾーン基準（調律に置く）に分割
// ─────────────────────────────────────────────────────────────────────────────
function CodeLaws() {
  return (
    <div className="pb-code">
      <div className="pb-grain" />
      <div className="pb-section-head">
        <div className="pb-section-num">05</div>
        <div className="pb-section-titles">
          <div className="pb-section-en">THE CODE.</div>
          <div className="pb-section-jp">三箇条 ／ 迷ったらこれを見る</div>
        </div>
      </div>
      <div className="pb-c-laws">
        {[
          { no: "i.", en: "READINESS RULES THE WORK.",
            jp: "計画より、体の声が上。",
            body: "主観疲労 <b>7+</b> ／ 睡眠の質 <b>4-</b> が2日連続 ／ 筋肉痛 <b>7+</b> ／ やる気 <b>3-</b> が3日連続 → 高強度は <b>Z1漕ぎ</b> に置換する。" },
          { no: "ii.", en: "ROW Z1 IN Z1.",
            jp: "軽い日は本気で軽く。",
            body: "Z1は燃料を入れる時間。レース本能で勝手に上げない。<b>Z1の上限スプリットはコーチ設定待ち</b>。" },
          (ATH.lw
            ? { no: "iii.", en: "WEIGHT IS BUILT, NOT STARVED.",
                jp: "体重は、削るな、整えろ。",
                body: "急減量は出力を削る。日々の体重は「<b>調律</b>」に記録してコーチと共有。レース計量は計画的に。" }
            : { no: "iii.", en: "RECOVERY IS THE WORK.",
                jp: "回復こそが、訓練。",
                body: "睡眠・栄養・休息も練習の一部。判取りと sRPE を<b>正直に</b>──次の一本の質がそこで決まる。" }),
        ].map((r, i) => (
          <div key={i} className="pb-c-law">
            <div className="pb-c-law-no">{r.no}</div>
            <div className="pb-c-law-body">
              <div className="pb-c-law-en">{r.en}</div>
              <div className="pb-c-law-jp">{r.jp}</div>
              <div className="pb-c-law-text" dangerouslySetInnerHTML={{__html: r.body}} />
            </div>
          </div>
        ))}
      </div>

      {/* Footer（選手データから自動） */}
      <div className="pb-c-foot">
        <div className="pb-c-foot-l">
          <div className="pb-c-foot-brand">{ATH.en} / {ATH.klass || ""}</div>
          <div className="pb-mono pb-c-foot-mono">{ATH.sub}</div>
        </div>
        <div className="pb-mono pb-c-foot-r">{CAMPAIGNS.map((c) => c.short).join(" → ")}</div>
      </div>
    </div>
  );
}

// 心拍ゾーンの入力セル（心拍計の基準がある選手だけ。空欄でOK・D1台帳へ保存）
function HrCell({ zoneTag, cur, onSaved }) {
  const [editing, setEditing] = React.useState(false);
  const [v, setV] = React.useState(cur ? cur.value : "");
  const [busy, setBusy] = React.useState(false);
  const ok = /^\d{2,3}(-\d{2,3})?$/.test(v.trim());
  function save() {
    if (!ok || busy) return;
    setBusy(true);
    postTuning({ category: "hrzone", item: zoneTag, value: v.trim() })
      .then((j) => { onSaved(j.row); setEditing(false); })
      .catch(() => {})
      .finally(() => setBusy(false));
  }
  if (!editing) {
    return (
      <button type="button" className="pb-hr-cell" onClick={() => setEditing(true)}>
        {cur
          ? <span className="pb-hr-v pb-mono">{cur.value}<i>bpm</i></span>
          : <span className="pb-hr-add">＋心拍</span>}
      </button>
    );
  }
  return (
    <span className="pb-hr-edit">
      <input className="pb-mono" type="text" placeholder="130-145" autoFocus
        value={v} onChange={(e) => setV(e.target.value)}
        onKeyDown={(e) => { if (e.key === "Enter") save(); if (e.key === "Escape") setEditing(false); }} />
      <button type="button" disabled={!ok || busy} onClick={save}>{busy ? "…" : "✓"}</button>
    </span>
  );
}

// ゾーン基準表（PROFILE 用）。スプリットは 2000m エルゴから Concept2 の式で自動算出。
// 帯＝2kパワー(W)の% [Z1 58-68 / Z2 72-84 / Z3 85-95 / RP 95-100]（標準の漕艇ゾーン行列を4段に集約）。
// watts = 2.80/(秒/m)^3。各%出力の500mスプリット = 500*(2.80/(W2k*p))^(1/3)。RP100%=2kスプリット。
const ZONE_PWR = { Z1: [58, 68], Z2: [72, 84], Z3: [85, 95], RP: [95, 100] };
function zoneSplit(erg2kSec, lo, hi) {
  if (!erg2kSec) return null;
  const sp = erg2kSec / 4;                 // 2k の 500m スプリット(秒)
  const w = 2.80 / Math.pow(sp / 500, 3);  // 2k パワー(W)
  const fmt = (s) => { s = Math.round(s * 10) / 10; const m = Math.floor(s / 60), ss = s - m * 60; return m + ":" + (ss < 10 ? "0" : "") + ss.toFixed(1); };
  const at = (p) => 500 * Math.pow(2.80 / (w * p / 100), 1 / 3);
  return fmt(at(hi)) + "–" + fmt(at(lo));  // 速い端(hi%)→遅い端(lo%)
}
// 心拍帯の表示整形（[lo,hi]→"lo–hi"。下限なし→"〜hi"、上限なし→"lo〜"）
function fmtHrBand(b) { if (!b) return ""; const lo = b[0], hi = b[1]; if (lo == null && hi == null) return ""; if (lo == null) return "〜" + hi; if (hi == null) return lo + "〜"; return lo + "–" + hi; }
// 実測LT1/LT2（＋最大HR・安静HR）から3ゾーン境界。Z1<LT1 / Z2 LT1–LT2 / Z3 >LT2 / RP=高Z3（レース）
function ltBands(lt1, lt2, maxHr, resting) {
  lt1 = parseInt(lt1, 10); lt2 = parseInt(lt2, 10);
  if (!lt1 || !lt2 || lt2 <= lt1) return null;
  const mx = parseInt(maxHr, 10); const max = (mx > lt2) ? mx : null;
  const rh = parseInt(resting, 10); const floor = (rh >= 30 && rh <= 100) ? rh : null;
  const rpLo = max ? Math.round((lt2 + max) / 2) : null;
  return { Z1: [floor, lt1], Z2: [lt1 + 1, lt2], Z3: [lt2 + 1, max], RP: [rpLo, max] };
}
// 漸増負荷試験の実測値（LT1・LT2・最大HR）入力。category=hrzone / item=LT1|LT2|HRmax
function LtInput({ cur, onSaved }) {
  const [open, setOpen] = React.useState(false);
  const [lt1, setLt1] = React.useState(cur.LT1 ? cur.LT1.value : "");
  const [lt2, setLt2] = React.useState(cur.LT2 ? cur.LT2.value : "");
  const [hmax, setHmax] = React.useState(cur.HRmax ? cur.HRmax.value : "");
  const [busy, setBusy] = React.useState(false);
  const has = cur.LT1 && cur.LT2;
  function save() {
    const items = [["LT1", lt1], ["LT2", lt2], ["HRmax", hmax]].filter(([k, v]) => /^\d{2,3}$/.test(String(v).trim()));
    if (busy || items.length < 2) return; // 最低 LT1・LT2
    setBusy(true);
    Promise.all(items.map(([item, value]) => postTuning({ category: "hrzone", item, value: String(value).trim() }).then((j) => onSaved(j.row)).catch(() => {})))
      .then(() => setOpen(false)).finally(() => setBusy(false));
  }
  if (!open) {
    return (
      <button type="button" className="pb-lt-toggle" onClick={() => setOpen(true)}>
        {has ? `🧪 実測LTを修正（LT1 ${cur.LT1.value} ／ LT2 ${cur.LT2.value} bpm）` : "🧪 漸増負荷試験の実測値を入力（LT1・LT2 → ゾーンに反映）"}
      </button>
    );
  }
  return (
    <div className="pb-lt-edit">
      <div className="pb-lt-row">
        <label>LT1<input className="pb-mono" type="text" inputMode="numeric" placeholder="bpm" value={lt1} onChange={(e) => setLt1(e.target.value)} /></label>
        <label>LT2<input className="pb-mono" type="text" inputMode="numeric" placeholder="bpm" value={lt2} onChange={(e) => setLt2(e.target.value)} /></label>
        <label>最大<input className="pb-mono" type="text" inputMode="numeric" placeholder="任意" value={hmax} onChange={(e) => setHmax(e.target.value)} /></label>
      </div>
      <div className="pb-lt-btns">
        <button type="button" className="pb-lt-save" disabled={busy} onClick={save}>{busy ? "保存中…" : "保存（ゾーンに反映）"}</button>
        <button type="button" className="pb-lt-cancel" onClick={() => setOpen(false)}>キャンセル</button>
      </div>
      <div className="pb-lt-note">漸増負荷試験で測った LT1（有酸素閾値）と LT2（無酸素性作業閾値）を入れると、各ゾーンの基準心拍がこの実測値で上書きされます。最大HRは任意（未入力なら intervals の最大を使用）。</div>
    </div>
  );
}
function ZoneTable({ num, hrCur = {}, onSavedHr, erg2kSec = null, hrAuto = null, hrMax = null, hrRest = null }) {
  const ltB = (hrCur.LT1 && hrCur.LT2) ? ltBands(hrCur.LT1.value, hrCur.LT2.value, (hrCur.HRmax ? hrCur.HRmax.value : hrMax), hrRest) : null;
  const effBands = ltB || hrAuto;   // 実測LT優先 → intervals自動
  const hrSrc = ltB ? "lt" : (hrAuto ? "intervals" : null);
  return (
    <div>
      <div className="pb-section-head pb-section-head-sub">
        <div className="pb-section-num">{num}</div>
        <div className="pb-section-titles">
          <div className="pb-section-en">INTENSITY ZONES.</div>
          <div className="pb-section-jp">強度ゾーン ／ RPEの目安 ・ {hrSrc === "lt" ? "心拍は実測LT基準（漸増負荷試験）" : hrSrc === "intervals" ? "心拍は intervals.icu から自動（3ゾーン）" : "心拍は自分で記入"} ・ {erg2kSec ? "スプリットは2000mエルゴから自動（Concept2式）" : "スプリットは2000mエルゴ登録で自動表示"}</div>
        </div>
      </div>
      <div className="pb-c-zones">
        {[
          { tag: "Z1", name: "RECOVERY",   rpe: "3–4", jp: "楽" },
          { tag: "Z2", name: "THRESHOLD",  rpe: "6–7", jp: "ややきつい〜きつい" },
          { tag: "Z3", name: "HIGH INT.",  rpe: "8–9", jp: "非常にきつい" },
          { tag: "RP", name: "RACE PACE",  rpe: "10", jp: "限界", rp: true },
        ].map((z, i) => {
          const band = ZONE_PWR[z.tag];
          const sp = band ? zoneSplit(erg2kSec, band[0], band[1]) : null;
          return (
          <div key={i} className={`pb-c-zone ${z.rp ? "pb-c-zone-rp" : ""}`}>
            <div className="pb-c-zone-tag">{z.tag}</div>
            <div className="pb-c-zone-body">
              <div className="pb-c-zone-name">{z.name}</div>
              <div className="pb-c-zone-stats pb-mono">
                <span>RPE {z.rpe} <em className="pb-c-zone-rpejp">（{z.jp}）</em></span>
                <span className="pb-c-zone-hr">心拍 {effBands && effBands[z.tag] && fmtHrBand(effBands[z.tag]) ? <span className={"pb-hr-auto pb-mono" + (ltB ? " pb-hr-lt" : "")}>{fmtHrBand(effBands[z.tag])}<i>bpm</i></span> : <HrCell zoneTag={z.tag} cur={hrCur[z.tag]} onSaved={onSavedHr} />}</span>
                <span className="pb-c-zone-stats-k">スプリット {sp ? <b className="pb-c-zone-split">{sp}<em> /500m</em></b> : "── 2000mエルゴ待ち"}</span>
              </div>
            </div>
          </div>
          );
        })}
        <LtInput cur={hrCur} onSaved={onSavedHr} />
        <div className="pb-cond-foot pb-mono">{erg2kSec ? "スプリットは2000mエルゴ（公式記録）から Concept2 の式で算出（2kパワーの% → ペース）。コーチが上書き可。" : "2000mエルゴを登録するとスプリットが自動表示されます。"}{hrSrc === "lt" ? "心拍は漸増負荷試験の実測 LT1・LT2 から算出（Z1<LT1 ／ Z2 LT1–LT2 ／ Z3>LT2）。実測値が最優先。" : hrSrc === "intervals" ? "心拍は intervals.icu の設定ゾーンを3ゾーンに集約（RP＝最上ゾーン）。実測LTを入れると上書き。" : "心拍は intervals.icu 連携、または実測LTの入力で自動表示。"} ・ ゾーン配分の実績は「{TAB_LABEL.wake}」へ</div>
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────────────────
//  COURSE — 航路（未来）: 今週 → ロードマップ → 二つの戦場 → 期分けの大枠 → 三箇条
// ─────────────────────────────────────────────────────────────────────────────
function Course({ today }) {
  return (
    <div>
      <WeekBlock today={today} />
      {ATH.menu_pdf && (
        <a className="pb-weights" href={ATH.menu_pdf.href} target="_blank" rel="noopener">
          <span className="pb-eyebrow pb-eyebrow-gold">MENU ／ 今ブロックの原本</span>
          <b>{ATH.menu_pdf.title}</b>
          <i className="pb-mono">原本PDFを開く ↗ ── セッションの狙い・ゾーン定義・HRR計算式まで全文（改変なし）</i>
        </a>
      )}
      {ATH.weights && (
        <a className="pb-weights" href={ATH.weights.href} target="_blank" rel="noopener">
          <span className="pb-eyebrow pb-eyebrow-red">WEIGHTS ／ ウェイトメニュー</span>
          <b>{ATH.weights.title}</b>
          <i className="pb-mono">原本PDFを開く ↗ ── %1RM・Set×Rep・RIR・設計意図まで全文（改変なし）</i>
        </a>
      )}
      <Road today={today} />
      {/* THE CODE（三箇条）は全員非表示（2026-06-16 紘志指示） */}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────────────────
//  APP
// ─────────────────────────────────────────────────────────────────────────────
//  CHECK-IN — Hooper(朝・1-7×4) と sRPE(練習後・1-10＋分) の入力（2026-06-10 追加）
//  紘志さん指定：「入力する場所も -rowing 側に」。
//  保存先は rowing-rpg と同一の D1（/api/checkin/* Pages Functions 経由）。
//  streak・XP も rowing-rpg と同一契約（どちらから入れても二重付与なし）。
// ─────────────────────────────────────────────────────────────────────────────
const HOOPER_ITEMS = [
  ["sleep", "睡眠の質", "1=ぐっすり ↔ 7=ほぼ眠れず"],
  ["fatigue", "疲労感", "1=とても元気 ↔ 7=極度の疲労"],
  ["stress", "ストレス", "1=リラックス ↔ 7=極度"],
  ["soreness", "筋肉痛・張り", "1=なし ↔ 7=ひどい"],
];
// コーチカルテ(athlete.html)と同じ Hooper 円グラフ。値に応じて色（≥5赤・≥4金・それ以外緑）。
function HooperRing({ label, val }) {
  const v = val == null ? null : Number(val);
  const col = v == null ? "rgba(255,255,255,.25)" : (v >= 5 ? "var(--red)" : (v >= 4 ? "var(--warn)" : "#4ec77f")); // ≥4 は注意色（金は使わない）
  const C = 201, frac = v == null ? 0 : Math.max(0, Math.min(7, v)) / 7, off = Math.round(C * (1 - frac));
  return (
    <div className="pb-ring-cell">
      <svg className="pb-ring-svg" viewBox="0 0 80 80">
        <circle cx="40" cy="40" r="32" fill="none" stroke="rgba(255,255,255,.09)" strokeWidth="7" />
        {v != null && (
          <circle cx="40" cy="40" r="32" fill="none" stroke={col} strokeWidth="7" strokeLinecap="round"
            strokeDasharray={C} strokeDashoffset={off} transform="rotate(-90 40 40)" />
        )}
        <text x="40" y="40" textAnchor="middle" dominantBaseline="central" fill={col}
          fontFamily="JetBrains Mono,monospace" fontWeight="700" fontSize="22">
          {v == null ? "–" : v}
        </text>
      </svg>
      <div className="pb-ring-lab">{label}</div>
    </div>
  );
}
function HooperRingRow({ rec }) {
  return (
    <div className="pb-ring-row">
      {HOOPER_ITEMS.map(([k, label]) => (
        <HooperRing key={k} label={label.replace("の質","").replace("感","").replace("・張り","")} val={rec[k]} />
      ))}
    </div>
  );
}
const SRPE_CATS = [
  ["row", "乗艇"],
  ["ergo", "Ergo"],
  ["cross", "有酸素"],
  ["strength", "筋トレ"],
];
const SPORT_JP = { Row: "乗艇", Erg: "Ergo", Cross: "有酸素", Other: "筋トレ/他" };

// 「M/D の記録」表示。サーバーの記録日（status API の date）と端末の今日が1日以上ずれていれば警告
function RecDate({ date, today }) {
  if (!date) return null;
  const md = (+String(date).slice(5, 7)) + "/" + (+String(date).slice(8, 10));
  let off = 0;
  try { off = today ? Math.abs(daysBetween(String(date), today)) : 0; } catch (e) { off = 0; }
  return (
    <React.Fragment>
      <div className="pb-rec-date pb-mono">{md} の記録</div>
      {off >= 1 && <div className="pb-rec-warnday">⚠ ページの日付設定がずれています。コーチに連絡してください</div>}
    </React.Fragment>
  );
}

function CheckIn() {
  const today = useToday();
  const [boot, setBoot] = React.useState({ phase: "loading" });
  const [hist, setHist] = React.useState([]); // 14日の Hooper 履歴（保存成功時は再取得せず今日の点を足す）
  React.useEffect(() => {
    let dead = false;
    fetch("/api/checkin/status?id=" + encodeURIComponent(ATH.id), { credentials: "same-origin", cache: "no-store" })
      .then((r) => {
        if (!r.ok) throw new Error("http " + r.status);
        return r.json();
      })
      .then((j) => {
        if (dead) return;
        setBoot({ phase: "ok", data: j });
        setHist(Array.isArray(j.hooper_hist) ? j.hooper_hist : []);
        // TODAY 最上部の「今日の記録へ ↓」チップ用（本人で朝チェック未入力のときだけ true）
        const notOwner = j.coach_preview || (j.viewer_role && j.viewer_role !== "athlete" && !j.is_owner);
        window.dispatchEvent(new CustomEvent("pb-checkin-pending", { detail: !notOwner && !j.today_wellness }));
      })
      .catch(() => { if (!dead) setBoot({ phase: "error" }); });
    return () => { dead = true; };
  }, []);
  // 朝チェック保存成功（カード／ポップアップ）→ 履歴に今日の値を追記・上書き
  React.useEffect(() => {
    const onRec = (ev) => {
      const d = ev.detail;
      if (!d || !d.date) return;
      setHist((h) => {
        const row = { date: d.date, sleep: d.sleep, fatigue: d.fatigue, stress: d.stress, soreness: d.soreness };
        const i = h.findIndex((r) => String(r.date) === String(d.date));
        if (i >= 0) { const c = h.slice(); c[i] = row; return c; }
        return h.concat([row]).sort((a, b) => (String(a.date) < String(b.date) ? -1 : 1));
      });
    };
    window.addEventListener("pb-hooper-recorded", onRec);
    return () => window.removeEventListener("pb-hooper-recorded", onRec);
  }, []);

  return (
    <div className="pb-t-ci">
      <div className="pb-section-head">
        <div className="pb-section-num">01</div>
        <div className="pb-section-titles">
          <div className="pb-section-en">CHECK-IN.</div>
          <div className="pb-section-jp">今日の記録 ／ 1分で完了</div>
        </div>
      </div>
      {boot.phase === "ok" && <RecDate date={boot.data.date} today={today} />}

      {boot.phase === "loading" && (
        <div className="pb-cond-standby"><div className="pb-cond-standby-en pb-mono">LOADING…</div></div>
      )}
      {boot.phase === "error" && (
        <div className="pb-cond-standby">
          <div className="pb-cond-standby-en">CHECK-IN — RETRY</div>
          <div className="pb-cond-standby-jp">読み込めませんでした。再読み込みしてください。</div>
        </div>
      )}
      {boot.phase === "ok" && (
        boot.data.coach_preview ? (
          <div className="pb-ci-stack">
            <div className="pb-cond-card pb-ci-coachnote pb-mono">👁 コーチ閲覧（選手視点）— 入力欄は本人のみ。下は本人の記録です。</div>
            {boot.data.today_wellness ? (
              <div className="pb-cond-card">
                <div className="pb-eyebrow pb-eyebrow-red">MORNING ／ 今朝のコンディション（本人記録）</div>
                <HooperRingRow rec={boot.data.today_wellness} />
              </div>
            ) : (
              <div className="pb-cond-card pb-ci-coachnote pb-mono">今朝はまだ未入力です。</div>
            )}
            <HooperHistory hist={hist} today={boot.data.date} />
          </div>
        ) : (boot.data.viewer_role && boot.data.viewer_role !== "athlete" && !boot.data.is_owner) ? (
          <div className="pb-ci-stack">
            <div className="pb-cond-card pb-ci-coachnote pb-mono">これは選手本人の記録欄です。コンディションの確認は監督室カルテからどうぞ。</div>
          </div>
        ) : (
          <div className="pb-ci-stack">
            <HooperCard init={boot.data} hist={hist} />
            <HooperHistory hist={hist} today={boot.data.date} />
            <WeightCardToday />
            <SrpeCard init={boot.data} />
          </div>
        )
      )}
    </div>
  );
}

// ── コンディション履歴（選手TODAY・14日推移）──────────────────────────────
//  監督室カルテと同じ図を選手本人にも。ただし「要注意」判定（赤破線・しきい値・赤点）は出さない＝値の推移のみ。
function HooperHistory({ hist, today }) {
  if (!hist || !hist.length) return null;
  const dnum = (ds) => { const p = String(ds).split("-").map(Number); return Date.UTC(p[0], p[1] - 1, p[2]); };
  const t0 = dnum(today);
  const METR = [["sleep", "睡眠", "#7fb0ff"], ["fatigue", "疲労", "#6ad1c8"], ["stress", "ストレス", "#c98bff"], ["soreness", "筋肉痛", "#ef6b6b"]]; // 疲労は金（--gold）を使わない
  const cell = (key, label, color) => {
    const W = 150, H = 56, pad = 7;
    const yOf = (v) => (H - pad) - ((v - 1) / 6) * (H - 2 * pad);
    const series = hist.filter((r) => r[key] != null).map((r) => {
      const off = Math.max(0, Math.min(13, 13 + Math.round((dnum(r.date) - t0) / 86400000)));
      return { x: pad + (off / 13) * (W - 2 * pad), y: yOf(r[key]), v: r[key], today: String(r.date) === today };
    });
    const latest = series.length ? series[series.length - 1].v : "—";
    return (
      <div className="pb-hh-cell" key={key}>
        <div className="pb-hh-lab">{label} <b>{latest}</b><span>/7</span></div>
        <svg className="pb-hh-svg" viewBox={"0 0 " + W + " " + H} preserveAspectRatio="none">
          {series.length >= 2 && <polyline points={series.map((p) => p.x.toFixed(1) + "," + p.y.toFixed(1)).join(" ")} fill="none" stroke={color} strokeWidth="1.6" />}
          {series.map((p, i) => <circle key={i} cx={p.x.toFixed(1)} cy={p.y.toFixed(1)} r={p.today ? 3 : 1.8} fill={p.today ? "var(--gold)" : color} stroke={p.today ? "#fff" : "none"} strokeWidth={p.today ? 1.2 : 0} vectorEffect="non-scaling-stroke" />)}
        </svg>
      </div>
    );
  };
  const sums = hist.map((r) => ({ date: r.date, v: (+r.sleep || 0) + (+r.fatigue || 0) + (+r.stress || 0) + (+r.soreness || 0), today: String(r.date) === today }));
  const vv = sums.map((s) => s.v);
  const mean = vv.reduce((a, b) => a + b, 0) / vv.length;
  // 縦軸は 4〜28 固定（Hooper合計の取りうる範囲）。日によって縮尺が変わらないようにする
  const lo = 4, hi = 28;
  const W = 320, H = 74, px = 8, pt = 9, pb = 9;
  const xOf = (d) => { const off = Math.max(0, Math.min(13, 13 + Math.round((dnum(d) - t0) / 86400000))); return px + (off / 13) * (W - 2 * px); };
  const yOf = (v) => (H - pb) - ((v - lo) / (hi - lo)) * (H - pt - pb);
  const latest = sums.length ? sums[sums.length - 1].v : "—";
  return (
    <div className="pb-cond-card pb-hh">
      <div className="pb-hh-head"><div className="pb-eyebrow">コンディションの推移（14日）</div><span className="pb-hh-sub">高い日ほど疲れ</span></div>
      <div className="pb-hh-lab pb-hh-main-lab">Hooper合計 <b>{latest}</b><span>/28</span><span className="pb-hh-meta">平均 {Math.round(mean * 10) / 10}</span></div>
      <div style={{ display: "flex", alignItems: "stretch", gap: "4px" }}>
        <div className="pb-mono" aria-hidden="true" style={{ display: "flex", flexDirection: "column", justifyContent: "space-between", fontSize: "10px", lineHeight: 1, color: "rgba(255,255,255,.7)", padding: "6px 0" }}>
          <span>{hi}</span><span>{lo}</span>
        </div>
        <svg className="pb-hh-msvg" viewBox={"0 0 " + W + " " + H} preserveAspectRatio="none" style={{ flex: 1, minWidth: 0 }}>
          <line x1={px} x2={W - px} y1={yOf(mean).toFixed(1)} y2={yOf(mean).toFixed(1)} stroke="rgba(255,255,255,.34)" strokeWidth="1" />
          {sums.length >= 2 && <polyline points={sums.map((s) => xOf(s.date).toFixed(1) + "," + yOf(s.v).toFixed(1)).join(" ")} fill="none" stroke="var(--gold)" strokeWidth="1.8" />}
          {sums.map((s, i) => <circle key={i} cx={xOf(s.date).toFixed(1)} cy={yOf(s.v).toFixed(1)} r={s.today ? 3.4 : 2} fill="var(--gold)" stroke={s.today ? "#fff" : "none"} strokeWidth={s.today ? 1.2 : 0} vectorEffect="non-scaling-stroke" />)}
        </svg>
      </div>
      <div className="pb-hh-grid">{METR.map((m) => cell(m[0], m[1], m[2]))}</div>
      <div className="pb-hh-foot pb-mono" style={{ fontSize: "11px" }}>金線＝Hooper合計（縦軸 4〜28） ／ 白線＝個人平均 ／ 白縁の点＝今日</div>
      {/* 2026-09-05 年表（TRENDタブ）への導線。TODAYからは辿り着けない問題への対処（選手で試行）。pb-set-tab イベントで切替（再読み込みなし） */}
      <div className="pb-hh-more"><button type="button" className="pb-tl-guide-btn pb-mono" onClick={() => goTab("wake")}>項目ごと・長期の推移を見る →</button></div>
    </div>
  );
}

// ── 朝のコンディション（Hooper 4項目） ──────────────────────────────────────
//  保存成功は emitHooperRecorded で通知（ポップアップと共通）。記録済みでも「修正する」で同じAPIへ再送（UPSERT）。
function HooperCard({ init, onRecorded, hist }) {
  const [recorded, setRecorded] = React.useState(init.today_wellness || null);
  const [temp, setTemp] = React.useState("");   // 体温（体調不良「あり」のときだけ入力・任意）
  const contactUrl = init.contact_url || null;
  const [streak, setStreak] = React.useState(init.streak);
  const [editing, setEditing] = React.useState(false); // 記録済みの値を初期選択にしてフォームを再表示中
  const [vals, setVals] = React.useState({ sleep: null, fatigue: null, stress: null, soreness: null });
  const [issue, setIssue] = React.useState(false);
  const [notes, setNotes] = React.useState("");
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);
  const [flash, setFlash] = React.useState(null); // {streak, xp}

  // 保存成功の反映（このカードで保存しても、ポップアップで保存しても同じ処理）
  const apply = React.useCallback((d) => {
    if (!d || d.sleep == null) return;
    setRecorded({
      sleep: d.sleep, fatigue: d.fatigue, stress: d.stress, soreness: d.soreness,
      is_pre_exercise: d.is_pre_exercise ? 1 : 0,
      has_health_issue: d.has_health_issue ? 1 : 0,
      health_notes: d.notes || null,
      body_temp: d.body_temp != null ? d.body_temp : null,
    });
    if (d.streak) setStreak(d.streak);
    setEditing(false);
    setErr(null);
  }, []);
  React.useEffect(() => {
    const h = (ev) => apply(ev.detail);
    window.addEventListener("pb-hooper-recorded", h);
    return () => window.removeEventListener("pb-hooper-recorded", h);
  }, [apply]);

  const allSet = HOOPER_ITEMS.every(([k]) => vals[k] != null);

  function submit() {
    if (!allSet || busy) return;
    setBusy(true);
    setErr(null);
    const bodyTemp = issue ? parseTemp(temp) : null;
    fetch("/api/checkin/hooper", {
      method: "POST",
      credentials: "same-origin",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        sleep: vals.sleep, fatigue: vals.fatigue, stress: vals.stress, soreness: vals.soreness,
        has_health_issue: issue, health_notes: issue ? notes : null, body_temp: bodyTemp,
      }),
    })
      .then(jsonOrThrow)
      .then((j) => {
        const detail = {
          date: j.date || init.date,
          sleep: vals.sleep, fatigue: vals.fatigue, stress: vals.stress, soreness: vals.soreness,
          has_health_issue: !!issue, notes: issue ? notes : null, body_temp: bodyTemp,
          is_pre_exercise: !!j.is_pre_exercise,
          streak: { current: j.streak_after, ending_yesterday: j.streak_ending_yesterday, total: j.total_recorded_days, recorded_today: !!j.is_pre_exercise },
        };
        apply(detail);
        emitHooperRecorded(detail);
        if (j.xp_awarded > 0) {
          setFlash({ streak: j.streak_after, xp: j.xp_awarded });
          setTimeout(() => setFlash(null), 3200);
        }
        if (onRecorded) onRecorded(detail);
      })
      .catch((e) => setErr(failMsg(e)))
      .finally(() => setBusy(false));
  }
  // 「修正する」: 記録値を初期選択にしてフォームを再表示（同じAPIに再送＝上書き）
  function startEdit() {
    if (!recorded) return;
    setVals({ sleep: recorded.sleep, fatigue: recorded.fatigue, stress: recorded.stress, soreness: recorded.soreness });
    setIssue(recorded.has_health_issue === 1);
    setNotes(recorded.health_notes || "");
    setTemp(recorded.body_temp != null ? Number(recorded.body_temp).toFixed(1) : "");
    setErr(null);
    setEditing(true);
  }

  // streak 文言（朝・入力前でも 0 に見せない）
  const streakLine = recorded
    ? (recorded.is_pre_exercise === 1
        ? `🔥 連続 ${streak.current}日目 ／ 今日の分も加算済み`
        : `記録済み ／ 練習が始まった後の記録のため連続日数には入りません。明日は練習前に入れると続きます`)
    : (streak.ending_yesterday > 0
        ? `昨日まで ${streak.ending_yesterday}日連続 — 今日の記録で ${streak.ending_yesterday + 1}日目`
        : streak.total > 0
          ? `今日から、また1日目（練習前の記録 累計 ${streak.total}日は消えません）`
          : `今日が記録の1日目`);

  const showForm = !recorded || editing;

  return (
    <div className={`pb-cond-card pb-ci-card ${recorded ? "" : "pb-pulse"}`}>
      <div className="pb-eyebrow pb-eyebrow-red">MORNING ／ 朝のコンディション</div>
      {init.acked && (
        <div className="pb-ci-acked pb-mono" style={{ color: "#ffb84d", fontSize: "11px", letterSpacing: ".06em", margin: "2px 0 6px" }}>
          👁 コーチ確認済み — 今日のあなたの記録は届いています
        </div>
      )}
      <div className="pb-ci-streak pb-mono">{streakLine}</div>

      {!showForm ? (
        <div className="pb-ci-done">
          <RecDate date={init.date} today={localTodayISO()} />
          <AdviceLine rec={recorded} hist={hist} date={init.date} />
          <HooperRingRow rec={recorded} />
          {recorded.has_health_issue === 1 && (
            <div className="pb-ci-issue">体調に問題ありの申告{recorded.body_temp != null ? `（体温 ${Number(recorded.body_temp).toFixed(1)}℃）` : ""} — 無理せずコーチに連絡を</div>
          )}
          {recorded.has_health_issue === 1 && <ContactBtn url={contactUrl} />}
          <div className="pb-cond-foot pb-mono">1=とても良い ／ 7=とても悪い</div>
          <button type="button" className="pb-fix-btn" onClick={startEdit}>修正する</button>
        </div>
      ) : (
        <div>
          {editing && <div className="pb-cond-foot pb-mono">記録済みの値が入っています。直したい数字をタップして「修正して記録する」</div>}
          {HOOPER_ITEMS.map(([k, label, hint]) => (
            <div key={k} className="pb-ci-row">
              <div className="pb-ci-row-head">
                <span className="pb-ci-row-label">{label}{k === "stress" && <InfoDot k="stress" />}</span>
                <span className="pb-ci-row-hint">{hint}</span>
              </div>
              {/* 両端の「良い」「悪い」は常時表示（.pb-hooper-ends）。.pb-ci-btns は7列グリッドなので外側に置く */}
              <div className="pb-ci-scale-wrap" style={{ display: "flex", alignItems: "center", gap: "6px", marginTop: "6px" }}>
                <span className="pb-hooper-ends">良い</span>
                <div className="pb-ci-btns" style={{ flex: 1, minWidth: 0, marginTop: 0 }}>
                  {[1, 2, 3, 4, 5, 6, 7].map((n) => (
                    <button
                      key={n}
                      type="button"
                      className={`pb-ci-btn pb-mono ${vals[k] === n ? "pb-ci-btn-on" : ""}`}
                      onClick={() => setVals((v) => Object.assign({}, v, { [k]: n }))}
                    >{n}</button>
                  ))}
                </div>
                <span className="pb-hooper-ends">悪い</span>
              </div>
            </div>
          ))}

          <div className="pb-ci-issue-row">
            <span className="pb-ci-row-label">熱・咳など体調に問題</span>
            <button type="button" className={`pb-ci-tgl ${!issue ? "pb-ci-tgl-on" : ""}`} onClick={() => setIssue(false)}>なし</button>
            <button type="button" className={`pb-ci-tgl ${issue ? "pb-ci-tgl-warn" : ""}`} onClick={() => setIssue(true)}>あり</button>
          </div>
          {issue && (
            <textarea
              className="pb-ci-notes"
              rows={2}
              maxLength={500}
              placeholder="症状メモ（例: のど痛い、頭痛、咳）"
              value={notes}
              onChange={(e) => setNotes(e.target.value)}
            />
          )}
          {issue && <TempRow value={temp} onChange={setTemp} />}
          {issue && <ContactBtn url={contactUrl} />}

          <button type="button" className="pb-ci-submit" disabled={!allSet || busy} onClick={submit}>
            {busy ? "保存中…" : editing ? "修正して記録する" : "記録する"}
          </button>
          {editing && !busy && (
            <button type="button" className="pb-fix-btn" style={{ marginLeft: "8px" }} onClick={() => { setEditing(false); setErr(null); }}>やめる</button>
          )}
          {err && <div className="pb-ci-err">{err}</div>}
        </div>
      )}

      {flash && (
        <div className="pb-ci-flash" onClick={() => setFlash(null)}>
          <div className="pb-ci-flash-num pb-mono">{flash.streak}<span>日連続</span></div>
          <div className="pb-ci-flash-xp pb-mono">+{flash.xp} XP</div>
        </div>
      )}
    </div>
  );
}

// ── sRPE（練習後の主観的強度 1-10 ＋ 時間） ────────────────────────────────
function SrpeCard({ init }) {
  const [sessions, setSessions] = React.useState(init.sessions || []);
  const [cat, setCat] = React.useState(null);
  const [rpe, setRpe] = React.useState(null);
  const [minutes, setMinutes] = React.useState("");
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);
  const [xpChip, setXpChip] = React.useState(null);
  // intervals.icu の今日の活動（sRPE促し用。タップで種目・時間が入る。未連携なら空のまま）
  const [acts, setActs] = React.useState([]);
  const [picked, setPicked] = React.useState(null);   // いまフォームに入れている活動
  const [doneSet, setDoneSet] = React.useState({});   // この画面で評価済みにした活動
  React.useEffect(() => {
    let dead = false;
    fetch(`/api/athlete/${ATH.id}/sessions?days=1`, { credentials: "same-origin" })
      .then((r) => (r.ok ? r.json() : null))
      .then((j) => { if (!dead && j && Array.isArray(j.sessions)) setActs(j.sessions.filter((s) => s.minutes >= 5)); })
      .catch(() => {});
    return () => { dead = true; };
  }, []);
  // 手入力済みセッションと「分数±2 ＋ 種目一致 ＋（両方に開始時刻があれば）±10分」で一致する活動は「評価済みらしい」とみなす
  const SPORT_CAT = { Row: "row", Erg: "ergo", Cross: "cross", Other: "strength" };
  const catOf = (sess) => sess.category || SPORT_CAT[sess.sport] || null;
  const hm = (t) => { const mm = /^(\d{1,2}):(\d{2})/.exec(String(t || "")); return mm ? (+mm[1]) * 60 + (+mm[2]) : null; };
  const likelyDone = (a) => sessions.some((sess) => {
    if (Math.abs((sess.minutes || 0) - a.minutes) > 2) return false;
    const cs = catOf(sess), ca = a.cat || null;
    if (cs && ca && cs !== ca) return false;
    const ts = hm(sess.time), ta = hm(a.time);
    if (ts != null && ta != null && Math.abs(ts - ta) > 10) return false;
    return true;
  });
  const isDone = (a, i) => !!doneSet[i] || likelyDone(a);
  const pendingCount = acts.filter((a, i) => !isDone(a, i)).length;
  // 実績分数（手入力セッション ＋ まだ評価していない心拍計の活動）→ TODAY のメニューカード「予定／実績」へ
  React.useEffect(() => {
    const total = sessions.reduce((sum, x) => sum + (x.minutes || 0), 0)
      + acts.reduce((sum, a, i) => sum + (isDone(a, i) ? 0 : (a.minutes || 0)), 0);
    window.dispatchEvent(new CustomEvent("pb-srpe-minutes", { detail: { minutes: total } }));
  }, [sessions, acts, doneSet]);

  const RPE_HINT = { 1: "とても楽", 2: "楽", 3: "やや楽", 4: "普通", 5: "ややきつい", 6: "きつい", 7: "とてもきつい", 8: "とてもとてもきつい", 9: "ほぼ限界", 10: "限界" };
  const m = parseInt(minutes, 10);
  const okMin = Number.isInteger(m) && m >= 5 && m <= 600;
  const ready = cat && rpe != null && okMin;

  const [dupNote, setDupNote] = React.useState(null);
  function submit() {
    if (!ready || busy) return;
    setBusy(true);
    setErr(null);
    const pickedAct = picked != null ? acts[picked] : null;
    const startT = pickedAct && hm(pickedAct.time) != null ? pickedAct.time : null;
    fetch("/api/checkin/srpe", {
      method: "POST",
      credentials: "same-origin",
      headers: { "Content-Type": "application/json" },
      // 心拍計の活動から選んだときは開始時刻（HH:MM）も送る（同じ練習の二重登録を防ぐ照合に使う）
      body: JSON.stringify({ rpe: rpe, minutes: m, category: cat, start_time_local: startT }),
    })
      .then(jsonOrThrow)
      .then((j) => {
        const row = { id: j.activityId, time: j.time || startT || "いま", minutes: m, sport: null, rpe: rpe, category: j.category || cat };
        setSessions((s) => (j.duplicate && s.some((x) => x.id === j.activityId)) ? s : s.concat([row]));
        if (picked != null) { setDoneSet((d) => Object.assign({}, d, { [picked]: 1 })); setPicked(null); }
        setCat(null); setRpe(null); setMinutes("");
        if (j.duplicate) { setDupNote("この練習はすでに記録済みでした（二重には登録していません）"); setTimeout(() => setDupNote(null), 4000); }
        if (j.xp_awarded > 0) { setXpChip(j.xp_awarded); setTimeout(() => setXpChip(null), 3000); }
      })
      .catch((e) => setErr(failMsg(e)))
      .finally(() => setBusy(false));
  }

  const catJp = (s) => {
    if (s.category) { const f = SRPE_CATS.find((c) => c[0] === s.category); return f ? f[1] : s.category; }
    return SPORT_JP[s.sport] || s.sport || "";
  };

  return (
    <div className={`pb-cond-card pb-ci-card ${pendingCount > 0 ? "pb-pulse" : ""}`}>
      <div className="pb-eyebrow pb-eyebrow-red">AFTER SESSION ／ sRPE</div>
      {init.acked && (
        <div className="pb-ci-acked pb-mono" style={{ color: "#ffb84d", fontSize: "11px", letterSpacing: ".06em", margin: "2px 0 6px" }}>
          👁 コーチ確認済み
        </div>
      )}
      <div className="pb-ci-streak pb-mono">
        {pendingCount > 0 ? `心拍計に未評価の練習が ${pendingCount}本` : sessions.length > 0 ? `今日 ${sessions.length}本 記録済み` : "練習が終わったら、その場で記録"}
      </div>

      {sessions.length > 0 && (
        <div className="pb-ci-sess">
          {sessions.map((s) => (
            <div key={s.id} className="pb-ci-sess-row pb-mono">
              <span>{s.time}</span>
              <span>{catJp(s)}</span>
              <span>{s.minutes}分</span>
              <span className="pb-ci-sess-rpe">{s.rpe != null ? `RPE ${s.rpe}` : "RPE 未記録"}</span>
            </div>
          ))}
        </div>
      )}

      {acts.length > 0 && (
        <div className="pb-ci-row">
          <div className="pb-ci-row-head">
            <span className="pb-ci-row-label">心拍計の記録から選ぶ</span>
            <span className="pb-ci-row-hint">タップで種目と時間が入ります</span>
          </div>
          <div className="pb-ci-acts">
            {acts.map((a, i) => (
              <button key={i} type="button"
                className={`pb-ci-act ${isDone(a, i) ? "pb-ci-act-done" : ""} ${picked === i ? "pb-ci-act-on" : ""}`}
                onClick={() => { if (isDone(a, i)) return; setMinutes(String(a.minutes)); if (a.cat) setCat(a.cat); setPicked(i); }}>
                <b className="pb-mono">{a.time}</b><span>{a.sportJa}</span><i className="pb-mono">{a.minutes}分</i>{isDone(a, i) && <em>✓ 記録済み</em>}
              </button>
            ))}
          </div>
        </div>
      )}

      <div className="pb-ci-row">
        <div className="pb-ci-row-head"><span className="pb-ci-row-label">種目</span></div>
        <div className="pb-ci-btns pb-ci-btns-4">
          {SRPE_CATS.map(([key, jp]) => (
            <button key={key} type="button" className={`pb-ci-btn ${cat === key ? "pb-ci-btn-on" : ""}`} onClick={() => setCat(cat === key ? null : key)}>{jp}</button>
          ))}
        </div>
      </div>
      <div className="pb-ci-row">
        <div className="pb-ci-row-head">
          <span className="pb-ci-row-label">きつさ（1〜10）</span>
          <span className="pb-ci-row-hint">{rpe != null ? `${rpe}: ${RPE_HINT[rpe]}` : "1=とても楽 ↔ 10=限界"}</span>
        </div>
        <div className="pb-ci-btns pb-ci-btns-10">
          {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((n) => (
            <button key={n} type="button" className={`pb-ci-btn pb-mono ${rpe === n ? "pb-ci-btn-on" : ""}`} onClick={() => setRpe(n)}>{n}</button>
          ))}
        </div>
      </div>
      <div className="pb-ci-row">
        <div className="pb-ci-row-head"><span className="pb-ci-row-label">時間（分）</span></div>
        <div className="pb-ci-min">
          {[30, 60, 90, 120].map((p) => (
            <button key={p} type="button" className={`pb-ci-btn pb-mono ${minutes === String(p) ? "pb-ci-btn-on" : ""}`} onClick={() => setMinutes(String(p))}>{p}</button>
          ))}
          <input
            className="pb-ci-min-input pb-mono"
            type="number"
            min={5}
            max={600}
            placeholder="分"
            value={minutes}
            onChange={(e) => setMinutes(e.target.value)}
          />
        </div>
      </div>

      <button type="button" className="pb-ci-submit" disabled={!ready || busy} onClick={submit}>
        {busy ? "保存中…" : "記録する"}
      </button>
      {xpChip && <span className="pb-ci-xp pb-mono">+{xpChip} XP</span>}
      {dupNote && <div className="pb-cond-foot pb-mono">{dupNote}</div>}
      {err && <div className="pb-ci-err">{err}</div>}
      <div className="pb-cond-foot pb-mono">記録は負荷管理（ACWR）にそのまま反映されます</div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────────────────
//  COND — 直近28日の実データ（2026-06-10 追加）
//  「一瞬でわかる」: Zone配分 vs 目標 ／ 直近セッション ／ 負荷(ACWR・sRPE) ／ 回復(HRV・RHR・TSB)
//  データ源: 同一オリジン /api/athlete/tomita/{activities,wellness,acwr}（Pages Functions 同梱）
//  intervals.icu 未連携の間（404）は数値を一切出さず STANDBY 表示 — 見せかけの数字を置かない。
// ─────────────────────────────────────────────────────────────────────────────
const ZONE_TARGET = { z1: 80, z2: 15, z3: 5 }; // チーム指針（3ゾーン極性モデル）

function useCond() {
  const [st, setSt] = React.useState({ phase: "loading" });
  React.useEffect(() => {
    let dead = false;
    const get = (p) =>
      fetch(p, { credentials: "same-origin" }).then((r) => {
        if (r.status === 404) throw new Error("404");
        if (!r.ok) throw new Error("http " + r.status);
        return r.json();
      });
    const monday = (() => { const d = new Date(); const dow = (d.getDay() + 6) % 7; d.setDate(d.getDate() - dow); return iso(d); })();
    const prevMonday = (() => { const d = new Date(); const dow = (d.getDay() + 6) % 7; d.setDate(d.getDate() - dow - 7); return iso(d); })();
    Promise.allSettled([
      get(`/api/athlete/${ATH.id}/activities?days=28`),
      get(`/api/athlete/${ATH.id}/wellness`),
      get(`/api/athlete/${ATH.id}/acwr`),
      get(`/api/athlete/${ATH.id}/sessions?days=14`),
    ]).then((rs) => {
      if (dead) return;
      const [act, wel, acwr, ses] = rs.map((r) => (r.status === "fulfilled" ? r.value : null));
      // 週TSS（月曜起点・表示専用）: intervals/Polar の Load 合計
      // wk/prev=週合計（後方互換）、days/prevDays=日別[月..日] でTP風の棒グラフを描く。
      // 日別の波（monotony）・テーパーの形・先週比を「数字でなく形」で見せるのが狙い。
      // 心拍なし(load=null)はTSS不明なので加算せず hasNullLoad で注記する（NO FAKE DATA）。
      let tss = null;
      if (ses && Array.isArray(ses.sessions)) {
        let wk = 0, prev = 0, any = false, hasNullLoad = false;
        const days = [0, 0, 0, 0, 0, 0, 0];     // 今週 月..日
        const prevDays = [0, 0, 0, 0, 0, 0, 0];  // 先週 月..日
        ses.sessions.forEach((s) => {
          if (s.load == null) { hasNullLoad = true; return; }
          any = true;
          // 正午固定で TZ ずれによる曜日ズレを回避（EU滞在中の選手ページでも安全）
          const idx = (new Date(s.date + "T12:00:00").getDay() + 6) % 7; // 月=0..日=6
          if (s.date >= monday) { wk += s.load; days[idx] += s.load; }
          else if (s.date >= prevMonday) { prev += s.load; prevDays[idx] += s.load; }
        });
        if (any) tss = {
          wk: Math.round(wk), prev: Math.round(prev),
          days: days.map(Math.round), prevDays: prevDays.map(Math.round),
          hasNullLoad,
        };
      }
      // 異常の自動検知（表示のみ・自動修正は絶対にしない）DESIGN_HR_FIX.md §4-5
      //   止め忘れ疑い: 長時間(>=150分)なのに負荷が異常に低い(<0.4/分)クロストレ（乗艇/Ergoは長時間が正規）
      //   HR欠測:       30分以上のセッションでゾーン滞在が全ゼロ＝心拍が記録されていない
      // 例: 選手のWalk 191分/load56 → 0.29/分 で検知。誤検知してもヒントが出るだけ＝安全。
      const anoms = [];
      const recentDates = [];
      if (ses && Array.isArray(ses.sessions)) {
        const cut = (() => { const d = new Date(); d.setDate(d.getDate() - 6); return iso(d); })();
        const seen = {};
        ses.sessions.forEach((s) => {
          if (s.date < cut) return;
          if (!seen[s.date]) { seen[s.date] = 1; recentDates.push(s.date); }
          const zsum = (s.z1 || 0) + (s.z2 || 0) + (s.z3 || 0);
          if (s.load != null && s.minutes >= 150 && s.load / s.minutes < 0.4 && s.cat === "cross") {
            anoms.push({ date: s.date, time: s.time, kind: "stop_forgot", label: "止め忘れ?", min: s.minutes });
          } else if (s.minutes >= 30 && zsum === 0) {
            anoms.push({ date: s.date, time: s.time, kind: "hr_error", label: "HR欠測", min: s.minutes });
          }
        });
      }
      const is404 = (r) => r.status === "rejected" && String(r.reason && r.reason.message) === "404";
      // CHECK-IN の手入力 sRPE があれば acwr が実データを返す → グリッド表示に切替
      const acwrHasData = acwr && (acwr.acwr != null || (acwr.trainingDays || 0) > 0);
      // activities と wellness が両方 404 = intervals.icu 未連携の署名
      //（acwr は未連携でも 200 を返す仕様のため、実データの有無だけ見る）
      if (is404(rs[0]) && is404(rs[1]) && !acwrHasData) {
        setSt({ phase: "standby" });
        return;
      }
      if (rs.every((r) => r.status === "rejected")) {
        setSt({ phase: "error" });
        return;
      }
      setSt({ phase: "ok", act, wel, acwr, tss, anoms, recentDates });
    });
    return () => { dead = true; };
  }, []);
  return st;
}

function fmtHM(sec) {
  const m = Math.floor(sec / 60);
  return Math.floor(m / 60) + ":" + ("0" + (m % 60)).slice(-2);
}

// 心拍記録の「原状回復」申請フォーム（DESIGN_HR_FIX.md §4-3）
// 修正はコーチが intervals.icu（源流）で行う。ここは依頼を出すだけ＝数字は作らない。
const FIX_KINDS = [
  ["stop_forgot", "止め忘れ（時間が長すぎる）"],
  ["hr_error", "心拍センサーの異常"],
  ["missing", "記録を取り忘れた"],
  ["duplicate", "二重に記録された"],
  ["other", "その他"],
];
function md(s) { return (+s.slice(5, 7)) + "/" + (+s.slice(8, 10)); }
function FixRequest({ anoms, dates }) {
  const ds = (dates && dates.length ? dates : [iso(new Date())]);
  const [open, setOpen] = React.useState(false);
  const [date, setDate] = React.useState(ds[0]);
  const [kind, setKind] = React.useState("stop_forgot");
  const [note, setNote] = React.useState("");
  const [busy, setBusy] = React.useState(false);
  const [sent, setSent] = React.useState(false);

  const start = (d, k) => { if (d) setDate(d); if (k) setKind(k); setSent(false); setOpen(true); };
  const submit = () => {
    setBusy(true);
    fetch("/api/checkin/fix-request", {
      method: "POST", credentials: "same-origin",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ date, kind, note: note.trim(), athlete: (window.PB && window.PB.ATHLETE && window.PB.ATHLETE.id) || "" }),
    }).then((r) => r.json()).then((j) => {
      setBusy(false);
      if (j && j.ok) { setSent(true); setOpen(false); }
    }).catch(() => setBusy(false));
  };

  return (
    <div className="pb-fix">
      {anoms && anoms.length > 0 && !sent && (
        <div className="pb-fix-flags">
          {anoms.slice(0, 3).map((a, i) => (
            <button key={i} className="pb-fix-flag" onClick={() => start(a.date, a.kind)}>
              ⚠ {md(a.date)} {a.label}
            </button>
          ))}
        </div>
      )}
      {sent ? (
        <div className="pb-fix-done pb-mono">✓ コーチに届きました。修正されるとこのページの数字も自動で直ります。</div>
      ) : !open ? (
        <button className="pb-fix-open pb-mono" onClick={() => start()}>記録がおかしい？ → 修正を申請</button>
      ) : (
        <div className="pb-fix-form">
          <label className="pb-fix-row">
            <span>対象日</span>
            <select value={date} onChange={(e) => setDate(e.target.value)}>
              {ds.map((d) => <option key={d} value={d}>{md(d)}</option>)}
            </select>
          </label>
          <div className="pb-fix-kinds">
            {FIX_KINDS.map(([k, lab]) => (
              <label key={k} className={"pb-fix-kind" + (kind === k ? " on" : "")}>
                <input type="radio" name="fixkind" checked={kind === k} onChange={() => setKind(k)} />{lab}
              </label>
            ))}
          </div>
          <input className="pb-fix-note" type="text" maxLength={500} value={note}
            placeholder="例：実際は60分で終わってます" onChange={(e) => setNote(e.target.value)} />
          <div className="pb-fix-actions">
            <button className="pb-fix-cancel" onClick={() => setOpen(false)}>やめる</button>
            <button className="pb-fix-send" disabled={busy} onClick={submit}>{busy ? "送信中…" : "コーチに送る"}</button>
          </div>
          <div className="pb-fix-edu">
            取り忘れた練習は、intervalsアプリで「手動アクティビティ」（種目と時間だけ）を足して、このページで sRPE を入れてください。
            心拍の数字は作りません — 無いものは無いままが正しい記録です。
          </div>
        </div>
      )}
    </div>
  );
}

// ── 指標の「やさしい解説」（ⓘタップで表示）2026-06-17 追加 ──────────────
const METRIC_INFO = {
  tss: { t: "TSS（トレーニング負荷）", b: "その日の練習の「きつさ × 長さ」を点数にしたもの。長く・きついほど高い（目安：約100＝1時間しっかり漕いだ相当）。週の合計で『今週どれだけ積んだか』、棒グラフで日々の波と先週との差（淡い棒＝先週）が分かります。" },
  hrv: { t: "HRV（心拍変動）", b: "朝の自律神経の回復度。心拍の“ゆらぎ”の大きさで、高い＝よく回復／低い＝疲れ・ストレスのサイン。1日ごとに上下するので、単日の数字より『いつもより高い・低い』という流れで見ます（下のグラフと7日平均比）。" },
  rhr: { t: "安静時HR（安静時心拍）", b: "朝の落ち着いた状態の心拍数（bpm）。低いほど回復・調子が良い目安。いつもより数bpm高い日が続くときは、疲労や体調の崩れ・寝不足のサインです。" },
  tsb: { t: "TSB（フォーム＝調子）", b: "体力（長期の積み上げ）から疲労（直近の負荷）を引いた値。＋＝フレッシュでキレが出やすい／−＝負荷が乗っている最中。レース週は＋方向に近づけるのが狙い。−20以下は疲労が深めのサインです。" },
  stress: { t: "ストレス", b: "精神的な疲労感を表します。トレーニングだけでなく、学業・仕事・長期の遠征・大会のプレッシャーなど、心の負担も含めて『今どれくらい張りつめているか』を答えてください。1＝リラックス 〜 7＝極度。" },
};
function InfoDot({ k }) {
  const [open, setOpen] = React.useState(false);
  const m = METRIC_INFO[k];
  if (!m) return null;
  return (
    <>
      <button type="button" className="pb-infodot" aria-label={m.t + "の説明"} onClick={(e) => { e.stopPropagation(); setOpen(true); }}>i</button>
      {open && (
        <div className="pb-info-ov" onClick={() => setOpen(false)} role="dialog" aria-modal="true">
          <div className="pb-info-card" onClick={(e) => e.stopPropagation()}>
            <div className="pb-info-t">{m.t}</div>
            <div className="pb-info-b">{m.b}</div>
            <button type="button" className="pb-info-x" onClick={() => setOpen(false)}>閉じる</button>
          </div>
        </div>
      )}
    </>
  );
}
// 小さな折れ線（HRV・安静時HRの推移）。最新点を強調。
function MiniSpark({ data }) {
  if (!data || data.length < 2) return null;
  const W = 100, H = 26, n = data.length;
  const min = Math.min.apply(null, data), max = Math.max.apply(null, data), rng = (max - min) || 1;
  const xy = (v, i) => [(i / (n - 1)) * W, H - 3 - ((v - min) / rng) * (H - 6)];
  const pts = data.map((v, i) => xy(v, i).map((q) => q.toFixed(1)).join(",")).join(" ");
  const last = xy(data[n - 1], n - 1);
  return (
    <svg className="pb-rec-spark" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" aria-hidden="true">
      <polyline points={pts} fill="none" stroke="rgba(127,176,255,.75)" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
      <circle cx={last[0].toFixed(1)} cy={last[1].toFixed(1)} r="2.2" fill="#7fb0ff" />
    </svg>
  );
}
// 系列 → {latest, base(直近7件平均・最新除く), delta, series(直近14件)}
function recoStat(rows, key) {
  const s = rows.filter((r) => r[key] != null).map((r) => r[key]);
  if (!s.length) return null;
  const latest = s[s.length - 1];
  const prev = s.slice(Math.max(0, s.length - 8), s.length - 1);
  const base = prev.length ? prev.reduce((a, b) => a + b, 0) / prev.length : null;
  return { latest, base, delta: base != null ? latest - base : null, series: s.slice(-14) };
}

function Cond({ nextD = null }) {
  const st = useCond();

  return (
    <div className="pb-t-cond">
      <div className="pb-section-head">
        <div className="pb-section-num">02</div>
        <div className="pb-section-titles">
          <div className="pb-section-en">CONDITION.</div>
          <div className="pb-section-jp">実データ ／ 直近28日</div>
        </div>
      </div>

      {st.phase === "loading" && (
        <div className="pb-cond-standby"><div className="pb-cond-standby-en pb-mono">LOADING…</div></div>
      )}

      {st.phase === "standby" && (
        <div className="pb-cond-standby">
          <div className="pb-cond-standby-en">DATA LINK — STANDBY</div>
          <div className="pb-cond-standby-jp">
            心拍計を intervals.icu に連携すると、ここに
            <b> Zone配分・直近セッション・強度（sRPE×時間）・疲労（HRV / 安静時HR / TSB）</b>
            が自動で点灯します。
          </div>
          <div className="pb-cond-standby-note pb-mono">NO FAKE NUMBERS — 本物のデータだけを映す。</div>
        </div>
      )}

      {st.phase === "error" && (
        <div className="pb-cond-standby">
          <div className="pb-cond-standby-en">DATA LINK — RETRY</div>
          <div className="pb-cond-standby-jp">取得できませんでした。通信状況を確認して再読み込みしてください。</div>
        </div>
      )}

      {st.phase === "ok" && (
        <div className="pb-cond-grid">
          {/* ① ZONE 28D vs 目標 */}
          <div className="pb-cond-card">
            <div className="pb-eyebrow">HR ZONE · 28D ／ 配分</div>
            {st.act && st.act.summary28 && st.act.summary28.total > 0 ? (
              <>
                {[
                  ["Z1", "z1", "RCV"],
                  ["Z2", "z2", "THR"],
                  ["Z3", "z3", "HI"],
                ].map(([zl, zk, sub]) => {
                  const pct = st.act.summary28[zk + "pct"];
                  return (
                    <div key={zk} className="pb-cz-row">
                      <div className="pb-cz-name pb-mono">{zl}<span>{sub}</span></div>
                      <div className="pb-cz-bar">
                        <div className={`pb-cz-fill pb-cz-${zk}`} style={{ width: pct + "%" }} />
                      </div>
                      <div className="pb-cz-val pb-mono">{pct}%</div>
                    </div>
                  );
                })}
                <div className="pb-cond-foot pb-mono">
                  TOTAL {fmtHM(st.act.summary28.total)}
                </div>
              </>
            ) : (
              <div className="pb-cond-empty">心拍データなし — intervals.icu 連携で表示</div>
            )}
          </div>

          {/* ② 直近セッション（7日） */}
          <div className="pb-cond-card">
            <div className="pb-eyebrow">RECENT ／ 直近セッション</div>
            {st.act && st.act.daily && st.act.daily.length > 0 ? (
              <div className="pb-cd-days">
                {st.act.daily.slice(-7).reverse().map((d) => {
                  const tot = d.total || 1;
                  return (
                    <div key={d.date} className="pb-cd-day">
                      <span className="pb-cd-date pb-mono">{(+d.date.slice(5, 7)) + "/" + (+d.date.slice(8, 10))}</span>
                      <span className="pb-cd-bar">
                        <span className="pb-cz-z1" style={{ width: (d.z1 / tot) * 100 + "%" }} />
                        <span className="pb-cz-z2" style={{ width: (d.z2 / tot) * 100 + "%" }} />
                        <span className="pb-cz-z3" style={{ width: (d.z3 / tot) * 100 + "%" }} />
                      </span>
                      <span className="pb-cd-min pb-mono">{Math.round(tot / 60)}分</span>
                    </div>
                  );
                })}
              </div>
            ) : (
              <div className="pb-cond-empty">直近の活動データなし</div>
            )}
            <FixRequest anoms={st.anoms} dates={st.recentDates} />
          </div>

          {/* ③ 負荷（ACWR / sRPE） */}
          <div className="pb-cond-card">
            <div className="pb-eyebrow">LOAD ／ 強度・主観的強度</div>
            {st.acwr && !st.acwr.error ? (
              <>
                <div className="pb-cond-big pb-mono">
                  {st.acwr.acwr != null ? st.acwr.acwr.toFixed(2) : "—"}
                  <span className={st.acwr.status === "high" || st.acwr.status === "very_high" ? "pb-cond-warn" : ""}>
                    {st.acwr.statusLabel || ""}
                  </span>
                </div>
                {st.acwr.acwr != null && (
                  <div className="pb-acwr-meter">
                    <div className="pb-acwr-band" />
                    <div className="pb-acwr-marker" style={{ left: Math.min(Math.max((st.acwr.acwr / 2) * 100, 0), 100) + "%" }} />
                  </div>
                )}
                <div className="pb-cond-foot pb-mono">
                  {st.acwr.acwr != null
                    ? "acute " + st.acwr.acute + " : chronic " + st.acwr.chronic + " ／ sRPE記録 " + st.acwr.trainingDays + "日"
                    : (st.acwr.note || "sRPE×時間のデータ蓄積中")}
                </div>
              </>
            ) : (
              <div className="pb-cond-empty">sRPE（主観的強度）の記録が始まると表示</div>
            )}
            {st.tss && (() => {
              // 週TSSの日別棒（TrainingPeaks風）。日別の波・テーパーの形・先週比を「形」で見せる。
              const W = ["月", "火", "水", "木", "金", "土", "日"];
              const t = st.tss;
              const peak = Math.max(100, ...t.days, ...t.prevDays); // 100床で小負荷を誇張しない
              const todayIdx = (new Date().getDay() + 6) % 7;       // 月=0..日=6
              const h = (v) => (v > 0 ? Math.max(Math.round((v / peak) * 48), 3) : 0);
              return (
                <div className="pb-tss">
                  <div className="pb-tss-lab pb-mono"><span className="pb-tss-labtxt">WEEK TSS ／ 今週の積み上げ <InfoDot k="tss" /></span><span>淡＝先週</span></div>
                  <div className="pb-tss-week">
                    {W.map((wd, i) => {
                      const v = t.days[i], pv = t.prevDays[i];
                      const isToday = i === todayIdx;
                      return (
                        <div className="pb-tss-day" key={i}>
                          <div className={"pb-tss-num pb-mono" + (isToday ? " pb-tss-now" : "")}>{v > 0 ? v : ""}</div>
                          <div className="pb-tss-barwrap" style={{ height: Math.max(h(v), 2) + "px" }}>
                            {pv > 0 && <div className="pb-tss-ghost" style={{ height: h(pv) + "px" }} />}
                            <div className={"pb-tss-bar" + (isToday ? " pb-tss-now" : "")} style={{ height: (v > 0 ? h(v) : 2) + "px" }} />
                          </div>
                        </div>
                      );
                    })}
                  </div>
                  <div className="pb-tss-week pb-tss-wlab">
                    {W.map((wd, i) => (
                      <div className={"pb-tss-day pb-mono" + (i === todayIdx ? " pb-tss-now" : "")} key={i}>{wd}</div>
                    ))}
                  </div>
                  {t.hasNullLoad && <div className="pb-cond-foot pb-mono">※ 心拍なしのセッション（筋トレ等）はTSSに含まれません</div>}
                  <div className="pb-cond-foot pb-mono">今週TSS <b>{t.wk}</b>{t.prev > 0 ? ` ／ 先週 ${t.prev}` : ""} ── 週=月曜起点・表示専用（intervals/Polar Load）</div>
                </div>
              );
            })()}
          </div>

          {/* ④ 回復（HRV / RHR / TSB） */}
          <div className="pb-cond-card">
            <div className="pb-eyebrow">RECOVERY ／ 疲労・回復</div>
            {(() => {
              const rows = (st.wel && st.wel.data) || [];
              if (!rows.length) return <div className="pb-cond-empty">intervals.icu 連携で表示（HRV・安静時HR・TSB）</div>;
              const H = recoStat(rows, "hrv"), R = recoStat(rows, "rhr");
              let frm = null;
              for (let i = rows.length - 1; i >= 0; i--) { if (rows[i].form != null) { frm = rows[i]; break; } }
              // HRV: 高い=good（▲緑/▼金）。安静時HR: 低い=good（▼緑/▲金）。
              const arrow = (delta, goodIsUp) => {
                if (delta == null || Math.round(delta) === 0) return { s: "±0", c: "pb-rec-flat" };
                const up = delta > 0, good = up === goodIsUp;
                return { s: (up ? "▲" : "▼") + Math.abs(Math.round(delta)), c: good ? "pb-rec-good" : "pb-rec-warn" };
              };
              const hA = H && H.delta != null ? arrow(H.delta, true) : null;
              const rA = R && R.delta != null ? arrow(R.delta, false) : null;
              return (
                <React.Fragment>
                <div className="pb-rec-grid">
                  <div className="pb-rec-cell">
                    <div className="pb-rec-v pb-mono">{H ? Math.round(H.latest) : "—"}</div>
                    <div className="pb-rec-l">HRV <InfoDot k="hrv" />{hA && <span className={"pb-rec-d " + hA.c}>{hA.s}</span>}</div>
                    {H && H.series.length >= 2 && <MiniSpark data={H.series} />}
                    <div className="pb-rec-sub pb-mono">{H ? "ms" + (H.delta != null ? " · 7日平均比" : "") : ""}</div>
                  </div>
                  <div className="pb-rec-cell">
                    <div className="pb-rec-v pb-mono">{R ? Math.round(R.latest) : "—"}</div>
                    <div className="pb-rec-l">安静時HR <InfoDot k="rhr" />{rA && <span className={"pb-rec-d " + rA.c}>{rA.s}</span>}</div>
                    {R && R.series.length >= 2 && <MiniSpark data={R.series} />}
                    <div className="pb-rec-sub pb-mono">{R ? "bpm" + (R.delta != null ? " · 7日平均比" : "") : ""}</div>
                  </div>
                  <div className="pb-rec-cell">
                    <div className={`pb-rec-v pb-mono ${frm && frm.form < 0 ? "pb-cond-warn" : ""}`}>{frm ? frm.form : "—"}</div>
                    <div className="pb-rec-l">TSB <InfoDot k="tsb" /><span>フォーム</span></div>
                  </div>
                </div>
                {nextD != null && nextD <= 14 && frm && (
                  <div className="pb-cond-foot pb-mono">レースまで {nextD}日 ／ TSBは＋方向へ（いま {frm.form}）</div>
                )}
                </React.Fragment>
              );
            })()}
          </div>
        </div>
      )}

      <div className="pb-cond-more">
        <button className="pb-cover-cta" onClick={() => goTab("wake")}>
          <span className="pb-mono">→</span>&nbsp;{TAB_LABEL.wake}で詳しく（記録の積み上げ）
        </button>
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────────────────
//  WAKE — TREND（体調の推移）: 朝チェックの記録・セッションの記録（D1実データのみ）＋詳細
// ─────────────────────────────────────────────────────────────────────────────
function dateShift(dateStr, delta) {
  const [y, m, d] = dateStr.split("-").map(Number);
  return new Date(Date.UTC(y, m - 1, d + delta)).toISOString().slice(0, 10);
}


// 04 詳しく見る ── 1モーションごとのゾーン配分（紘志さん指定 2026-06-11）
// Z1/Z2/Z3 は心拍の実測から自動計算。Z3 はメニューが距離指定のため、
// 500mあたり 男子1:30 / 女子1:40 で「メニュー想定 Z3 時間」を併記する。
function MotionDetail() {
  const [days, setDays] = React.useState(14);
  const [st, setSt] = React.useState({ phase: "loading" });
  React.useEffect(() => {
    let dead = false;
    setSt({ phase: "loading" });
    fetch(`/api/athlete/${ATH.id}/sessions?days=${days}`, { credentials: "same-origin" })
      .then((r) => { if (!r.ok) throw new Error("http " + r.status); return r.json(); })
      .then((j) => { if (!dead) setSt({ phase: "ok", sessions: j.sessions || [] }); })
      .catch(() => { if (!dead) setSt({ phase: "error" }); });
    return () => { dead = true; };
  }, [days]);
  const fmtMS = (sec) => Math.floor(sec / 60) + ":" + ("0" + Math.round(sec % 60)).slice(-2);
  const isMale = (ATH.sex === "M");
  // その日のメニューから Z3 の距離（例 500m×10）を拾い、想定時間に換算
  const z3Est = (dateISO) => {
    const menu = ((DAY_MENU[dateISO] || {}).menu) || "";
    const seg = String(menu).split("／").find((s) => /Z3/.test(s)) || "";
    const m = seg.match(/(\d{2,4})\s*m×(\d+)/);
    if (!m) return null;
    const total = parseInt(m[1], 10) * parseInt(m[2], 10);
    const per500 = isMale ? 90 : 100;
    return { label: m[1] + "m×" + m[2], sec: Math.round((total / 500) * per500) };
  };
  const list = st.sessions || [];
  const byDate = {};
  list.forEach((s) => { (byDate[s.date] = byDate[s.date] || []).push(s); });
  const dates = Object.keys(byDate).sort().reverse();
  return (
    <div className="pb-cond-card pb-modetail">
      <div className="pb-eyebrow pb-eyebrow-gold">DETAIL ／ 1モーションごとのゾーン配分・時間</div>
      <div className="pb-mo-days">
        {[7, 14, 31].map((n) => (
          <button key={n} type="button" className={`pb-ci-btn pb-mono ${days === n ? "pb-ci-btn-on" : ""}`} onClick={() => setDays(n)}>直近{n}日</button>
        ))}
      </div>
      {st.phase === "loading" && <div className="pb-ci-streak pb-mono">LOADING…</div>}
      {st.phase === "error" && <div className="pb-cond-empty">取得できませんでした。再読み込みしてください。</div>}
      {st.phase === "ok" && dates.length === 0 && <div className="pb-cond-empty">この期間の練習データはありません</div>}
      {st.phase === "ok" && dates.map((d) => {
        const est = z3Est(d);
        return (
          <div key={d} className="pb-mo-day">
            <div className="pb-mo-date">{(+d.slice(5, 7)) + "/" + (+d.slice(8, 10))}<span className="pb-mo-wk">{weekdayJp[parseISO(d).getDay()]}</span></div>
            {est && (
              <div className="pb-mo-est pb-mono">メニュー想定 Z3 ≈ <b>{fmtMS(est.sec)}</b>（{est.label} ・ {isMale ? "1:30" : "1:40"}/500m 換算）</div>
            )}
            {byDate[d].map((s, i) => {
              const tot = (s.z1 || 0) + (s.z2 || 0) + (s.z3 || 0);
              return (
                <div key={i} className="pb-mo">
                  <div className="pb-mo-head pb-mono">
                    <span className="pb-mo-time">{s.time}</span>
                    <span className="pb-mo-sport">{s.sportJa}</span>
                    {s.load != null && <span className="pb-mo-tss">TSS {s.load}</span>}
                    <b>{s.minutes}分</b>
                  </div>
                  {tot > 0 ? (
                    <React.Fragment>
                      <div className="pb-mo-bar">
                        <span className="pb-cz-z1" style={{ width: (s.z1 / tot) * 100 + "%" }} />
                        <span className="pb-cz-z2" style={{ width: (s.z2 / tot) * 100 + "%" }} />
                        <span className="pb-cz-z3" style={{ width: (s.z3 / tot) * 100 + "%" }} />
                      </div>
                      <div className="pb-mo-z pb-mono">
                        Z1 {Math.round((s.z1 / tot) * 100)}%・{fmtMS(s.z1)} ／ Z2 {Math.round((s.z2 / tot) * 100)}%・{fmtMS(s.z2)} ／ Z3 {Math.round((s.z3 / tot) * 100)}%・{fmtMS(s.z3)}
                      </div>
                    </React.Fragment>
                  ) : (
                    <div className="pb-mo-nohr pb-mono">心拍データなし</div>
                  )}
                </div>
              );
            })}
          </div>
        );
      })}
      <div className="pb-cond-foot pb-mono">配分は心拍の実測から自動計算。短いZ3は心拍の遅れで小さく出るため「メニュー想定」を目安に ／ TSSは intervals・Polar の Load（表示専用・管理の正は sRPE×分）</div>
    </div>
  );
}

// 期間チェック（チェックイン→チェックアウト）: 任意期間のゾーン配分・時間・推移
// 紘志さん指定 2026-06-11：「直近28日だけでなく、ホテルのように任意の日付で見たい」
function RangeView() {
  const today = iso(new Date());
  const [from, setFrom] = React.useState(dateShift(today, -27));
  const [to, setTo] = React.useState(today);
  const [st, setSt] = React.useState({ phase: "loading" });
  const load = (f, t) => {
    const o = f || from, n = t || to;
    if (!o || !n || o > n) return;
    setSt({ phase: "loading" });
    fetch(`/api/athlete/${ATH.id}/range?oldest=${o}&newest=${n}`, { credentials: "same-origin" })
      .then((r) => { if (!r.ok) throw new Error("http " + r.status); return r.json(); })
      .then((j) => setSt({ phase: "ok", data: j }))
      .catch(() => setSt({ phase: "error" }));
  };
  React.useEffect(() => { load(); }, []);
  const d = st.data;
  const fmtH = (sec) => { const m = Math.round(sec / 60); return Math.floor(m / 60) + ":" + ("0" + (m % 60)).slice(-2); };
  // 期間が長いときは週ごとにまとめて推移を見せる
  const rows = (() => {
    if (!d || !d.daily) return [];
    if (d.daily.length <= 35) return d.daily.map((x) => ({ label: (+x.date.slice(5, 7)) + "/" + (+x.date.slice(8, 10)), z1: x.z1, z2: x.z2, z3: x.z3, total: x.total }));
    const byWeek = new Map();
    d.daily.forEach((x) => {
      const dt = parseISO(x.date);
      const mon = dateShift(x.date, -((dt.getDay() + 6) % 7));
      const cur = byWeek.get(mon) || { label: (+mon.slice(5, 7)) + "/" + (+mon.slice(8, 10)) + "週", z1: 0, z2: 0, z3: 0, total: 0 };
      cur.z1 += x.z1; cur.z2 += x.z2; cur.z3 += x.z3; cur.total += x.total;
      byWeek.set(mon, cur);
    });
    return [...byWeek.values()];
  })();
  const maxTot = Math.max(1, ...rows.map((r) => r.total));
  return (
    <div className="pb-cond-card pb-range">
      <div className="pb-range-bar">
        <span className="pb-range-k pb-mono">IN</span>
        <input type="date" className="pb-range-date pb-mono" value={from} max={to} onChange={(e) => setFrom(e.target.value)} />
        <span className="pb-range-k pb-mono">→ OUT</span>
        <input type="date" className="pb-range-date pb-mono" value={to} min={from} onChange={(e) => setTo(e.target.value)} />
        <button type="button" className="pb-ci-submit pb-tn-btn" onClick={() => load()}>表示</button>
      </div>
      <div className="pb-range-quick">
        {[["直近7日", 7], ["14日", 14], ["28日", 28], ["90日", 90]].map(([l, n]) => (
          <button key={n} type="button" className="pb-ci-btn pb-mono" onClick={() => { const f = dateShift(today, -(n - 1)); setFrom(f); setTo(today); load(f, today); }}>{l}</button>
        ))}
      </div>
      {st.phase === "loading" && <div className="pb-ci-streak pb-mono">LOADING…</div>}
      {st.phase === "error" && <div className="pb-cond-empty">取得できませんでした。期間を確認して再実行してください。</div>}
      {st.phase === "ok" && d && (d.summary.total === 0 ? (
        <div className="pb-cond-empty">この期間の心拍データはありません</div>
      ) : (
        <div>
          <div className="pb-range-sum">
            <div className="pb-wake-stat"><div className="pb-wake-v pb-mono">{fmtH(d.summary.total)}</div><div className="pb-wake-l">合計時間</div></div>
            <div className="pb-wake-stat"><div className="pb-wake-v pb-mono">{d.summary.daysActive}<span className="pb-range-unit">/{d.range.days}日</span></div><div className="pb-wake-l">活動日</div></div>
            <div className="pb-wake-stat"><div className="pb-wake-v pb-mono">{d.range.oldest.slice(5).replace("-", "/")}–{d.range.newest.slice(5).replace("-", "/")}</div><div className="pb-wake-l">期間</div></div>
          </div>
          {[["Z1", "z1"], ["Z2", "z2"], ["Z3", "z3"]].map(([zl, zk]) => {
            const pctv = d.summary[zk + "pct"];
            const tgt = ZONE_TARGET[zk];
            return (
              <div key={zk} className="pb-cz-row">
                <div className="pb-cz-name pb-mono">{zl}</div>
                <div className="pb-cz-bar">
                  <div className={`pb-cz-fill pb-cz-${zk}`} style={{ width: pctv + "%" }} />
                  <div className="pb-cz-tick" style={{ left: tgt + "%" }} />
                </div>
                <div className="pb-cz-val pb-mono">{pctv}%<span>{fmtH(d.summary[zk])}</span></div>
              </div>
            );
          })}
          <div className="pb-range-days">
            {rows.map((r, i) => (
              <div key={i} className="pb-wake-day-head pb-range-dayrow">
                <span className="pb-mono pb-wake-day-date">{r.label}</span>
                <span className="pb-range-zbar">
                  <span className="pb-cz-z1" style={{ width: (r.z1 / maxTot) * 100 + "%" }} />
                  <span className="pb-cz-z2" style={{ width: (r.z2 / maxTot) * 100 + "%" }} />
                  <span className="pb-cz-z3" style={{ width: (r.z3 / maxTot) * 100 + "%" }} />
                </span>
                <span className="pb-mono pb-wake-day-load">{fmtH(r.total)}</span>
              </div>
            ))}
          </div>
          <div className="pb-cond-foot pb-mono">{d.daily.length > 35 ? "週ごとの推移" : "日ごとの推移"}</div>
        </div>
      ))}
    </div>
  );
}

function Wake() {
  const [st, setSt] = React.useState({ phase: "loading" });
  React.useEffect(() => {
    let dead = false;
    fetch("/api/checkin/history", { credentials: "same-origin", cache: "no-store" })
      .then((r) => { if (!r.ok) throw new Error("http " + r.status); return r.json(); })
      .then((j) => { if (!dead) setSt({ phase: "ok", data: j }); })
      .catch(() => { if (!dead) setSt({ phase: "error" }); });
    return () => { dead = true; };
  }, []);

  // 2026-07-03 スマホ最優先の再設計（紘志さん指定）:
  //   PAST./01 TIMELINE. などの大見出しは撤去し、各カード先頭の金色見出しに一本化（画面を消費しない）。
  //   並びは 年表 → 積み上げ → 記録(御朱印帳+セッション) → 詳細。
  //   御朱印帳を下げた理由: 習慣の実感はTODAYタブ(streak/ポップアップ)が担い、このタブは「振り返って気づく」場。
  return (
    <div className="pb-code">
      <div className="pb-grain" />
      <CondTimeline />
      <ZoneGrowth />
      {st.phase === "loading" && (
        <div className="pb-cond-standby"><div className="pb-cond-standby-en pb-mono">LOADING…</div></div>
      )}
      {st.phase === "error" && (
        <div className="pb-cond-standby">
          <div className="pb-cond-standby-en">RECORD — RETRY</div>
          <div className="pb-cond-standby-jp">読み込めませんでした。再読み込みしてください。</div>
        </div>
      )}
      {st.phase === "ok" && <WakeBody data={st.data} />}
      {/* 旧DETAIL(MotionDetail)はGRIDと情報重複のため撤去し、GRID(実トレ×心拍・波形タップ)をここへ統合（2026-07-03） */}
      <Grid />
    </div>
  );
}

function WakeBody({ data }) {
  const coachView = !!(data.viewer_role && data.viewer_role !== "athlete");
  const wmap = {};
  (data.wellness || []).forEach((w) => { wmap[w.date] = w.pre; });
  const today = data.today;

  // 今日を含む週の月曜から26週さかのぼる御朱印帳（teiko の TREND と同じ並び）
  const dow = (parseISO(today).getDay() + 6) % 7; // 月=0
  const monday = dateShift(today, -dow);
  const start = dateShift(monday, -25 * 7);
  const weeks = [];
  for (let w = 0; w < 26; w++) {
    const col = [];
    for (let d = 0; d < 7; d++) col.push(dateShift(start, w * 7 + d));
    weeks.push(col);
  }

  // streak（練習前=判取りのみ。今日未記録なら昨日までの連続を見せる — 0に見せない）
  const preSet = new Set(Object.keys(wmap).filter((d) => wmap[d] === 1));
  let cur = 0;
  for (let i = 0; i < 400; i++) { if (!preSet.has(dateShift(today, -i))) break; cur++; }
  let endYest = 0;
  for (let i = 1; i <= 400; i++) { if (!preSet.has(dateShift(today, -i))) break; endYest++; }
  const streakLine = cur > 0 ? `連続 ${cur}日` : endYest > 0 ? `昨日まで ${endYest}日連続` : "—";

  // セッション: 日付ごとにまとめ、sRPE×分 = その日の負荷
  const byDay = {};
  (data.sessions || []).forEach((s) => {
    (byDay[s.date] = byDay[s.date] || []).push(s);
  });
  const days = Object.keys(byDay).sort().reverse().slice(0, 14);
  const loadOf = (d) => byDay[d].reduce((a, s) => a + (s.rpe ? s.rpe * s.minutes : 0), 0);
  const maxLoad = Math.max(1, ...days.map(loadOf));
  const catJp = (s) => {
    if (s.category) { const f = SRPE_CATS.find((c) => c[0] === s.category); return f ? f[1] : s.category; }
    return SPORT_JP[s.sport] || s.sport || "";
  };

  return (
    <div>
      {/* 朝チェックの記録（御朱印帳） */}
      <div className="pb-cond-card pb-wake-card">
        <div className="pb-eyebrow pb-eyebrow-red">MORNING CHECK ／ 朝チェックの記録 — 直近26週</div>
        <div className="pb-wake-stats">
          <div className="pb-wake-stat"><div className="pb-wake-v pb-mono">{coachView ? "—" : (data.wellness || []).length}</div><div className="pb-wake-l">累計記録日</div></div>
          <div className="pb-wake-stat"><div className="pb-wake-v pb-mono">{coachView ? "—" : preSet.size}</div><div className="pb-wake-l">練習前に記録</div></div>
          <div className="pb-wake-stat"><div className="pb-wake-v pb-mono">{coachView ? "—" : streakLine}</div><div className="pb-wake-l">いまの流れ</div></div>
        </div>
        <div className="pb-cond-foot pb-mono">{coachView
          ? "🔒 コーチ閲覧モード ── この欄は選手本人のログインでのみ点灯します（いまは消灯）"
          : "この欄は「ログインした本人」の記録だけが表示されます"}</div>
        <div className="pb-wake-hm" role="img" aria-label="朝チェックカレンダー">
          {weeks.map((col, wi) => (
            <div key={wi} className="pb-wake-col">
              {col.map((d) => {
                const cls = d > today ? "f" : wmap[d] === 1 ? "on" : wmap[d] === 0 ? "late" : "e";
                return <div key={d} className={`pb-wake-cell pb-wake-${cls}`} title={d} />;
              })}
            </div>
          ))}
        </div>
        <div className="pb-wake-legend pb-mono">
          <span><i className="pb-wake-cell pb-wake-on" /> 練習前に記録（streak対象）</span>
          <span><i className="pb-wake-cell pb-wake-late" /> 記録（練習後）</span>
          <span><i className="pb-wake-cell pb-wake-e" /> 空白 — 埋めるのは明日のあなた</span>
        </div>
      </div>

      {/* セッションの記録（番号なしサブ見出しに変更 2026-07-02 — 「02」が GROWTH と重複していたため） */}
      <div className="pb-eyebrow pb-eyebrow-gold" style={{ margin: "28px 0 10px" }}>SESSIONS ／ セッションの記録 — sRPE × 時間 = その日の負荷</div>
      {days.length === 0 ? (
        <div className="pb-cond-standby">
          <div className="pb-cond-standby-en">NO SESSIONS YET</div>
          <div className="pb-cond-standby-jp">練習後に「{TAB_LABEL.today}」タブの sRPE を記録すると、ここに記録が増えていきます。</div>
        </div>
      ) : (
        <div className="pb-cond-card pb-wake-card">
          {days.map((d) => (
            <div key={d} className="pb-wake-day">
              <div className="pb-wake-day-head">
                <span className="pb-mono pb-wake-day-date">{(+d.slice(5, 7)) + "/" + (+d.slice(8, 10))}</span>
                <span className="pb-wake-day-bar"><span style={{ width: (loadOf(d) / maxLoad) * 100 + "%" }} /></span>
                <span className="pb-mono pb-wake-day-load">{loadOf(d) > 0 ? Math.round(loadOf(d)) : "—"}</span>
              </div>
              {byDay[d].map((s, i) => (
                <div key={i} className="pb-ci-sess-row pb-mono">
                  <span>{s.time || "—"}</span>
                  <span>{catJp(s)}</span>
                  <span>{s.minutes}分</span>
                  <span className="pb-ci-sess-rpe">{s.rpe != null ? `RPE ${s.rpe}` : "RPE 未記録"}</span>
                </div>
              ))}
            </div>
          ))}
          <div className="pb-cond-foot pb-mono">負荷 = sRPE × 分（Foster法）。ACWR（CONDITION）はこの記録から計算されます</div>
        </div>
      )}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────────────────
//  TUNING — 調律（自分という艇の整備記録）: 体重・エルゴ・リギングを D1 台帳へ
// ─────────────────────────────────────────────────────────────────────────────
const RIG_ITEMS = ["スパン", "ブッシュ", "ハイト", "ハイト差", "ストレッチャー角", "デプス", "インボード", "オール全長", "その他"];
const ERGO_ITEM_DEFS = [
  ["2000m", "time", "7:21.5 の形式"],
  ["6000m", "time", "23:58.4 の形式"],
  ["30分", "dist", "距離をメートルで（例 7650）"],
];
const TN_CAT_JP = { weight: "体重", ergo: "エルゴ", rigging: "リギング", hrzone: "心拍", career: "漕歴" };

// エルゴ値の比較用数値（タイム=秒で小さいほど速い、30分=メートルで大きいほど良い）
function ergoNum(item, v) {
  if (item === "30分") { const n = parseInt(v, 10); return Number.isFinite(n) ? n : null; }
  const m = String(v).match(/^(\d{1,2}):([0-5]?\d(?:\.\d)?)$/);
  return m ? (+m[1]) * 60 + parseFloat(m[2]) : null;
}
function ergoBetter(item, a, b) { return item === "30分" ? a > b : a < b; }
function ergoFmtDiff(item, a, b) {
  if (item === "30分") return Math.abs(a - b) + "m";
  return Math.abs(a - b).toFixed(1) + "秒";
}

function Tuning() {
  const [st, setSt] = React.useState({ phase: "loading" });
  const [rows, setRows] = React.useState([]);
  const [hrMeta, setHrMeta] = React.useState(null); // intervals.icu 由来（bands / maxHr / restingHr）
  React.useEffect(() => {
    let dead = false;
    fetch("/api/tuning/list?id=" + encodeURIComponent(ATH.id), { credentials: "same-origin", cache: "no-store" })
      .then((r) => { if (!r.ok) throw new Error("http " + r.status); return r.json(); })
      .then((j) => { if (!dead) { setRows(j.rows || []); setSt({ phase: "ok" }); } })
      .catch(() => { if (!dead) setSt({ phase: "error" }); });
    return () => { dead = true; };
  }, []);

  // INTENSITY ZONES の基準心拍を intervals.icu から自動表示（3ゾーン）
  React.useEffect(() => {
    let dead = false;
    fetch("/api/athlete/" + encodeURIComponent(ATH.id) + "/hrzones", { credentials: "same-origin", cache: "no-store" })
      .then((r) => (r.ok ? r.json() : null))
      .then((j) => { if (!dead && j) setHrMeta(j); })
      .catch(() => {});
    return () => { dead = true; };
  }, []);

  function addRow(row) {
    setRows((rs) => [Object.assign({ created_at: Math.floor(Date.now() / 1000) }, row), ...rs]);
  }

  // 派生: 最新体重・エルゴのベスト/列・リギング現在値
  const weights = rows.filter((r) => r.category === "weight");
  const latestW = weights[0] || null;
  const ergoByItem = {};
  rows.filter((r) => r.category === "ergo").forEach((r) => {
    (ergoByItem[r.item] = ergoByItem[r.item] || []).push(r);
  });
  Object.keys(ergoByItem).forEach((k) => ergoByItem[k].reverse()); // 古→新（数字の行進）
  const bestOf = (item) => {
    const list = ergoByItem[item] || [];
    let best = null;
    list.forEach((r) => {
      const n = ergoNum(item, r.value);
      if (n != null && (best == null || ergoBetter(item, n, ergoNum(item, best.value)))) best = r;
    });
    return best;
  };
  const erg2kSec = (function () { const b = bestOf("2000m"); return b ? ergoNum("2000m", b.value) : null; })();
  const rigCur = {};
  rows.filter((r) => r.category === "rigging").forEach((r) => {
    if (!rigCur[r.item]) rigCur[r.item] = r; // rows は新→旧なので最初が現在値
  });
  const hrCur = {};
  rows.filter((r) => r.category === "hrzone").forEach((r) => {
    if (!hrCur[r.item]) hrCur[r.item] = r;
  });

  return (
    <div className="pb-code">
      <div className="pb-grain" />
      <div className="pb-section-head">
        <div className="pb-section-num">01</div>
        <div className="pb-section-titles">
          <div className="pb-section-en">PROFILE.</div>
          <div className="pb-section-jp">調律 ／ 自分という艇の、整備記録</div>
        </div>
      </div>

      {st.phase === "loading" && (
        <div className="pb-cond-standby"><div className="pb-cond-standby-en pb-mono">LOADING…</div></div>
      )}
      {st.phase === "error" && (
        <div className="pb-cond-standby">
          <div className="pb-cond-standby-en">TUNING — RETRY</div>
          <div className="pb-cond-standby-jp">読み込めませんでした。再読み込みしてください。</div>
        </div>
      )}

      {st.phase === "ok" && (
        <div>
          {/* 強度ゾーン（PROFILE の先頭。TODAY のメニュー直下にも Z1／Z3 の1行を同じ計算で出す） */}
          <ZoneTable num="02" hrCur={hrCur} onSavedHr={addRow} erg2kSec={erg2kSec} hrAuto={hrMeta && hrMeta.bands} hrMax={hrMeta && hrMeta.maxHr} hrRest={hrMeta && hrMeta.restingHr} />

          {/* 現在値プレート */}
          <div className="pb-tn-plates">
            <div className="pb-cond-card pb-tn-plate">
              <div className="pb-eyebrow">朝体重</div>
              <div className="pb-tn-v pb-mono">{latestW ? latestW.value : "—"}<span>{latestW ? "kg" : ""}</span></div>
              <div className="pb-tn-l pb-mono">{latestW ? (latestW.prev_value ? `前回 ${latestW.prev_value} → ${latestW.value}` : latestW.date) : "未記録"}</div>
            </div>
            {ERGO_ITEM_DEFS.map(([item]) => {
              const b = bestOf(item);
              return (
                <div key={item} className="pb-cond-card pb-tn-plate">
                  <div className="pb-eyebrow">{item} BEST</div>
                  <div className="pb-tn-v pb-mono">{b ? b.value : "—"}</div>
                  <div className="pb-tn-l pb-mono">{b ? b.date : "未記録"}</div>
                </div>
              );
            })}
          </div>

          {Object.keys(rigCur).length > 0 && (
            <div className="pb-cond-card" style={{ marginTop: "10px" }}>
              <div className="pb-eyebrow">RIGGING ／ 現在のセッティング</div>
              {Object.keys(rigCur).map((k) => (
                <div key={k} style={{ display: "flex", justifyContent: "space-between", padding: "5px 0", borderBottom: "1px solid rgba(255,255,255,.08)", fontSize: "13px" }}>
                  <span style={{ color: "rgba(255,255,255,.6)" }}>{k}</span>
                  <span className="pb-mono" style={{ fontWeight: 600 }}>{rigCur[k].value}</span>
                </div>
              ))}
            </div>
          )}

          {/* RECORD — エルゴ・リギングの入力（体重は TODAY タブのCHECK-INへ移動） */}
          <div className="pb-section-head pb-section-head-sub">
            <div className="pb-section-num">03</div>
            <div className="pb-section-titles">
              <div className="pb-section-en">RECORD.</div>
              <div className="pb-section-jp">記録する ／ 変化はすべて台帳に残る ・ 体重は「{TAB_LABEL.today}」タブで</div>
            </div>
          </div>
          <div className="pb-tn-forms pb-tn-forms-2">
            <ErgoForm bestOf={bestOf} onSaved={addRow} />
            <RigForm rigCur={rigCur} onSaved={addRow} />
          </div>

          {/* ERGO HALL — 数字の行進 */}
          <div className="pb-section-head pb-section-head-sub">
            <div className="pb-section-num">04</div>
            <div className="pb-section-titles">
              <div className="pb-section-en">ERGO HALL.</div>
              <div className="pb-section-jp">数字の行進 ／ 過去の自分が、一列に並ぶ</div>
            </div>
          </div>
          {Object.keys(ergoByItem).length === 0 ? (
            <div className="pb-cond-standby">
              <div className="pb-cond-standby-en">THE HALL IS EMPTY</div>
              <div className="pb-cond-standby-jp">最初のエルゴ記録を入れると、ここから行進が始まります。</div>
            </div>
          ) : (
            <div className="pb-tn-ergo">
              {ERGO_ITEM_DEFS.filter(([item]) => ergoByItem[item] && ergoByItem[item].length).map(([item]) => {
                const list = ergoByItem[item];
                const best = bestOf(item);
                const first = list[0];
                const fn = ergoNum(item, first.value);
                const bn = best ? ergoNum(item, best.value) : null;
                const imp = list.length > 1 && fn != null && bn != null && fn !== bn
                  ? (item === "30分" ? `初記録から +${ergoFmtDiff(item, bn, fn)}` : `初記録から −${ergoFmtDiff(item, fn, bn)}`)
                  : null;
                return (
                  <div key={item} className="pb-cond-card pb-tn-march">
                    <div className="pb-tn-march-head">
                      <span className="pb-eyebrow">{item}</span>
                      {imp && <span className="pb-tn-imp pb-mono">{imp}</span>}
                    </div>
                    <div className="pb-tn-march-row">
                      {list.map((r, i) => {
                        const isBest = best && r.id === best.id;
                        const op = 0.45 + 0.5 * (i / Math.max(1, list.length - 1));
                        return (
                          <React.Fragment key={r.id || i}>
                            {i > 0 && <span className="pb-tn-arrow">›</span>}
                            <span className={`pb-tn-step pb-mono ${isBest ? "pb-tn-pb" : ""}`} style={isBest ? null : { opacity: op }}>
                              <b>{r.value}</b>
                              <i>{(r.date || "").slice(0, 7).replace("-", ".")}</i>
                              {isBest && <em>── PB</em>}
                            </span>
                          </React.Fragment>
                        );
                      })}
                    </div>
                  </div>
                );
              })}
            </div>
          )}

          {/* 台帳 */}
          <div className="pb-section-head pb-section-head-sub">
            <div className="pb-section-num">05</div>
            <div className="pb-section-titles">
              <div className="pb-section-en">THE LEDGER.</div>
              <div className="pb-section-jp">調律帳 ／ 上書きしない。訂正も歴史</div>
            </div>
          </div>
          {rows.length === 0 ? (
            <div className="pb-cond-standby">
              <div className="pb-cond-standby-jp">まだ記録がありません。最初の1行を入れてください。</div>
            </div>
          ) : (
            <div className="pb-cond-card pb-tn-ledger">
              {rows.slice(0, 30).map((r, i) => (
                <div key={r.id || i} className="pb-tn-led-row pb-mono">
                  <span className="pb-tn-led-date">{r.date}</span>
                  <span className="pb-tn-led-cat">{TN_CAT_JP[r.category] || r.category}</span>
                  <span className="pb-tn-led-item">{r.item}</span>
                  <span className="pb-tn-led-val">
                    {r.prev_value ? <i>{r.prev_value} → </i> : null}<b>{r.value}</b>
                  </span>
                  {r.note && <span className="pb-tn-led-note">{r.note}</span>}
                </div>
              ))}
              {rows.length > 30 && <div className="pb-cond-foot pb-mono">直近30件を表示（全{rows.length}件）</div>}
            </div>
          )}

        </div>
      )}
    </div>
  );
}

// 共通: 調律イベントPOST
function postTuning(payload) {
  return fetch("/api/tuning/event", {
    method: "POST",
    credentials: "same-origin",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  }).then(jsonOrThrow);
}

// 体重ドラム（横スクロールで 0.1kg 刻みに選ぶ。毎回の手打ちを無くす）
const WP_MIN = 40, WP_MAX = 120, WP_TICK = 14;
function WeightPicker({ value, onChange }) {
  const ref = React.useRef(null);
  // 最新の onChange / value を ref で保持（合成イベントに頼らず native listener で拾う）
  const onChangeRef = React.useRef(onChange); onChangeRef.current = onChange;
  const valueRef = React.useRef(value); valueRef.current = value;
  const vals = React.useMemo(() => {
    const a = [];
    const n = Math.round((WP_MAX - WP_MIN) / 0.1);
    for (let i = 0; i <= n; i++) a.push(+(WP_MIN + i * 0.1).toFixed(1));
    return a;
  }, []);
  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    // 初期位置を現在値へ（中央寄せは spacer に任せる）
    el.scrollLeft = Math.round((valueRef.current - WP_MIN) / 0.1) * WP_TICK;
    // 同期計算（rAF はタブ非表示で止まるため使わない。処理は O(1) で十分軽い）
    let lastIdx = -1;
    const onScroll = () => {
      const idx = Math.max(0, Math.min(vals.length - 1, Math.round(el.scrollLeft / WP_TICK)));
      if (idx === lastIdx) return;
      lastIdx = idx;
      const v = vals[idx];
      if (v !== valueRef.current) onChangeRef.current(v);
    };
    el.addEventListener("scroll", onScroll, { passive: true });
    return () => el.removeEventListener("scroll", onScroll);
  }, []);
  // 数字（整数kg）はタップでその値へスナップ（細かいスクロールが苦手でも選べる保険）
  function tapTo(i) {
    const el = ref.current;
    if (el) el.scrollTo({ left: i * WP_TICK, behavior: "smooth" });
  }
  return (
    <div className="pb-wp">
      <div className="pb-wp-caret" />
      <div className="pb-wp-scroll" ref={ref}>
        <div className="pb-wp-pad" />
        {vals.map((v, i) => {
          const major = Number.isInteger(v);
          const half = !major && Math.abs((v * 10) % 10 - 5) < 0.01;
          return (
            <div key={v} className={`pb-wp-tick ${major ? "major" : half ? "half" : ""}`} onClick={major ? () => tapTo(i) : undefined}>
              {major && <span className="pb-wp-num pb-mono">{v}</span>}
            </div>
          );
        })}
        <div className="pb-wp-pad" />
      </div>
    </div>
  );
}

// ATHLETE.weight_target（kg）。数値のときだけ返す（null／未設定／文字列は null）
function weightTargetOf() {
  const t = ATH.weight_target;
  return (typeof t === "number" && Number.isFinite(t) && t > 0) ? t : null;
}
function WeightForm({ latest, onSaved }) {
  // 初回の初期値: 男子80.0 / 女子70.0（紘志さん指定 2026-06-11）。2日目以降は前回の記録値から
  const sexDefault = (ATH.sex === "M") ? 80.0 : 70.0;
  const hasPrev = !!(latest && Number.isFinite(Number(latest.value)));
  const start = hasPrev ? Number(latest.value) : sexDefault;
  const [v, setV] = React.useState(start);
  const [touched, setTouched] = React.useState(false);   // 初回（記録なし）はダイヤルを動かすまで保存できない
  const [editing, setEditing] = React.useState(false);   // 今朝の記録を修正中
  const [busy, setBusy] = React.useState(false);
  const [fb, setFb] = React.useState(null);
  const recordedToday = hasPrev && latest.date && String(latest.date) === localTodayISO();
  const ok = Number.isFinite(v) && v >= WP_MIN && v <= WP_MAX && (hasPrev || touched);
  // 目標体重（ATHLETE.weight_target。null なら非表示）— 見出し横に「目標 xx.x ／ 差 ±y.y」
  const tgt = weightTargetOf();
  const tgtLine = (cur) => {
    if (tgt == null || !Number.isFinite(cur)) return null;
    const d = cur - tgt;
    return <span className="pb-weight-target pb-mono">目標 {tgt.toFixed(1)} ／ 差 {d >= 0 ? "+" : ""}{d.toFixed(1)}</span>;
  };
  function onDial(nv) { setV(nv); setTouched(true); }
  function submit() {
    if (!ok || busy) return;
    setBusy(true); setFb(null);
    postTuning({ category: "weight", value: v.toFixed(1) })
      .then((j) => {
        const row = Object.assign({}, j.row || {}, { date: (j.row && j.row.date) || localTodayISO(), value: (j.row && j.row.value) || v.toFixed(1) });
        onSaved(row);
        const d = latest ? (v - Number(latest.value)) : null;
        setFb(d == null ? "記録しました" : Math.abs(d) < 0.05 ? "前回と同じ" : (d > 0 ? `+${d.toFixed(1)}kg` : `${d.toFixed(1)}kg`) + " ／ 記録しました");
        setEditing(false);
      })
      .catch((e) => setFb(failMsg(e)))
      .finally(() => setBusy(false));
  }
  if (recordedToday && !editing) {
    return (
      <div className="pb-cond-card pb-ci-card">
        <div className="pb-eyebrow pb-eyebrow-red">WEIGHT ／ 朝体重{tgtLine(Number(latest.value))}</div>
        <div className="pb-ci-done">
          <div className="pb-ci-streak pb-mono">今朝 {Number(latest.value).toFixed(1)} kg 記録済み</div>
          {fb && <div className="pb-tn-fb pb-mono">{fb}</div>}
          <button type="button" className="pb-fix-btn" onClick={() => { setV(Number(latest.value)); setFb(null); setEditing(true); }}>修正する</button>
        </div>
      </div>
    );
  }
  return (
    <div className="pb-cond-card pb-ci-card">
      <div className="pb-eyebrow pb-eyebrow-red">WEIGHT ／ 朝体重{tgtLine(v)}</div>
      <div className="pb-ci-row-head"><span className="pb-ci-row-label">今朝の体重</span><span className="pb-ci-row-hint">起床後・朝食前</span></div>
      <div className="pb-wp-window">
        <span className="pb-mono pb-wp-big">{v.toFixed(1)}</span><span className="pb-wp-unit">kg</span>
        <div className="pb-wp-window-cap pb-mono">◂ &nbsp;目盛りを横にスライド&nbsp; ▸</div>
      </div>
      <WeightPicker value={v} onChange={onDial} />
      {!hasPrev && !touched && <div className="pb-cond-foot pb-mono">目盛りを動かして今朝の体重に合わせると記録できます</div>}
      <button type="button" className="pb-ci-submit pb-tn-btn pb-wp-save" disabled={!ok || busy} onClick={submit}>{busy ? "保存中…" : editing ? "この体重に修正する" : "この体重で記録する"}</button>
      {editing && !busy && <button type="button" className="pb-fix-btn" style={{ marginLeft: "8px" }} onClick={() => { setEditing(false); setFb(null); }}>やめる</button>}
      {fb && <div className="pb-tn-fb pb-mono">{fb}</div>}
    </div>
  );
}

// TODAY タブのCHECK-IN用：最新体重を読んでから体重ダイヤルを出す（朝体重の入力場所）
function WeightCardToday() {
  const [latest, setLatest] = React.useState(undefined); // undefined=loading, null=なし
  React.useEffect(() => {
    let dead = false;
    fetch("/api/tuning/list?id=" + encodeURIComponent(ATH.id), { credentials: "same-origin", cache: "no-store" })
      .then((r) => (r.ok ? r.json() : { rows: [] }))
      .then((j) => { if (!dead) setLatest((j.rows || []).find((r) => r.category === "weight") || null); })
      .catch(() => { if (!dead) setLatest(null); });
    return () => { dead = true; };
  }, []);
  if (latest === undefined) {
    return (
      <div className="pb-cond-card pb-ci-card">
        <div className="pb-eyebrow pb-eyebrow-red">WEIGHT ／ 朝体重</div>
        <div className="pb-ci-streak pb-mono">LOADING…</div>
      </div>
    );
  }
  return <WeightForm latest={latest} onSaved={(row) => setLatest(row)} />;
}

function ErgoForm({ bestOf, onSaved }) {
  const [item, setItem] = React.useState(null);
  const [v, setV] = React.useState("");
  const [busy, setBusy] = React.useState(false);
  const [fb, setFb] = React.useState(null);
  const def = ERGO_ITEM_DEFS.find((d) => d[0] === item);
  const valOk = item && (item === "30分"
    ? (() => { const n = parseInt(v, 10); return Number.isFinite(n) && n >= 1000 && n <= 20000; })()
    : /^\d{1,2}:[0-5]?\d(\.\d)?$/.test(v.trim()));
  function submit() {
    if (!valOk || busy) return;
    setBusy(true); setFb(null);
    const prevBest = bestOf(item);
    postTuning({ category: "ergo", item: item, value: item === "30分" ? parseInt(v, 10) : v.trim() })
      .then((j) => {
        onSaved(j.row);
        const nNew = ergoNum(item, j.row.value);
        const nBest = prevBest ? ergoNum(item, prevBest.value) : null;
        if (nBest == null || (nNew != null && ergoBetter(item, nNew, nBest))) {
          setFb("⚡ 自己ベスト — 行進の先頭が入れ替わりました");
        } else {
          setFb(`列に積みました ── PBまであと ${ergoFmtDiff(item, nNew, nBest)}`);
        }
        setV("");
      })
      .catch((e) => setFb("エラー: " + e.message))
      .finally(() => setBusy(false));
  }
  return (
    <div className="pb-cond-card pb-ci-card">
      <div className="pb-eyebrow pb-eyebrow-red">ERGO ／ エルゴベスト</div>
      <div className="pb-ci-row">
        <div className="pb-ci-row-head"><span className="pb-ci-row-label">種目</span></div>
        <div className="pb-ci-btns pb-ci-btns-4">
          {ERGO_ITEM_DEFS.map(([k]) => (
            <button key={k} type="button" className={`pb-ci-btn ${item === k ? "pb-ci-btn-on" : ""}`} onClick={() => { setItem(item === k ? null : k); setV(""); setFb(null); }}>{k}</button>
          ))}
        </div>
      </div>
      <div className="pb-ci-row">
        <div className="pb-ci-row-head">
          <span className="pb-ci-row-label">記録</span>
          <span className="pb-ci-row-hint">{def ? def[2] : "種目を選択"}</span>
        </div>
        <div className="pb-ci-min">
          <input className="pb-ci-min-input pb-mono pb-tn-wide" type="text" inputMode={item === "30分" ? "numeric" : "text"}
            placeholder={item === "30分" ? "7650" : item ? (item === "2000m" ? "7:21.5" : "23:58.4") : ""}
            disabled={!item} value={v} onChange={(e) => setV(e.target.value)} />
          <button type="button" className="pb-ci-submit pb-tn-btn" disabled={!valOk || busy} onClick={submit}>{busy ? "保存中…" : "記録する"}</button>
        </div>
      </div>
      {fb && <div className="pb-tn-fb pb-mono">{fb}</div>}
    </div>
  );
}

function RigForm({ rigCur, onSaved }) {
  const [item, setItem] = React.useState(null);
  const [v, setV] = React.useState("");
  const [why, setWhy] = React.useState("");
  const [busy, setBusy] = React.useState(false);
  const [fb, setFb] = React.useState(null);
  const ok = item && v.trim().length >= 1 && v.trim().length <= 20;
  function submit() {
    if (!ok || busy) return;
    setBusy(true); setFb(null);
    postTuning({ category: "rigging", item: item, value: v.trim(), note: why })
      .then((j) => {
        onSaved(j.row);
        setFb(j.row.prev_value ? `${j.row.prev_value} → ${j.row.value} ／ 台帳に記録` : "台帳に記録しました");
        setV(""); setWhy("");
      })
      .catch((e) => setFb("エラー: " + e.message))
      .finally(() => setBusy(false));
  }
  return (
    <div className="pb-cond-card pb-ci-card">
      <div className="pb-eyebrow pb-eyebrow-red">RIGGING ／ 艇の調律</div>
      <div className="pb-ci-row">
        <div className="pb-ci-row-head"><span className="pb-ci-row-label">項目</span>{item && rigCur[item] && <span className="pb-ci-row-hint">現在 {rigCur[item].value}</span>}</div>
        <div className="pb-ci-btns pb-tn-rigbtns">
          {RIG_ITEMS.map((k) => (
            <button key={k} type="button" className={`pb-ci-btn ${item === k ? "pb-ci-btn-on" : ""}`} onClick={() => { setItem(item === k ? null : k); setFb(null); }}>{k}</button>
          ))}
        </div>
      </div>
      <div className="pb-ci-row">
        <div className="pb-ci-row-head"><span className="pb-ci-row-label">新しい値</span><span className="pb-ci-row-hint">例 159.5cm ／ 4.0°</span></div>
        <div className="pb-ci-min">
          <input className="pb-ci-min-input pb-mono pb-tn-wide" type="text" maxLength={20} disabled={!item}
            value={v} onChange={(e) => setV(e.target.value)} />
          <button type="button" className="pb-ci-submit pb-tn-btn" disabled={!ok || busy} onClick={submit}>{busy ? "保存中…" : "記録する"}</button>
        </div>
      </div>
      <div className="pb-ci-row">
        <div className="pb-ci-row-head"><span className="pb-ci-row-label">なぜ変えた？（任意）</span></div>
        <input className="pb-ci-min-input pb-mono pb-tn-full" type="text" maxLength={200} disabled={!item}
          placeholder="例: キャッチが深いのでハイト+5mm" value={why} onChange={(e) => setWhy(e.target.value)} />
      </div>
      {fb && <div className="pb-tn-fb pb-mono">{fb}</div>}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────────────────
//  HALL — 漕史: 自分の章（MY CHAPTER・D1保存）× 日本代表の漕史（共有棟 /soushi）
//  紘志さん指定 2026-06-11：「個々人の歴史を書くことが、自信・不安の除去に繋がる」
// ─────────────────────────────────────────────────────────────────────────────
// MY_WALL（既に公式記録にある行）は playbook-data.jsx（window.PB.MY_WALL）から差し込む。

function MyChapter() {
  const [rows, setRows] = React.useState(null); // null=loading
  const [y, setY] = React.useState("");
  const [t, setT] = React.useState("");
  const [n, setN] = React.useState("");
  const [busy, setBusy] = React.useState(false);
  const [fb, setFb] = React.useState(null);
  React.useEffect(() => {
    let dead = false;
    fetch("/api/tuning/list?id=" + encodeURIComponent(ATH.id), { credentials: "same-origin", cache: "no-store" })
      .then((r) => (r.ok ? r.json() : { rows: [] }))
      .then((j) => {
        if (dead) return;
        const cr = (j.rows || []).filter((r) => r.category === "career");
        cr.sort((a, b) => (a.item === b.item ? (a.created_at || 0) - (b.created_at || 0) : a.item < b.item ? -1 : 1));
        setRows(cr);
      })
      .catch(() => { if (!dead) setRows([]); });
    return () => { dead = true; };
  }, []);
  const ok = /^\d{4}(\.\d{1,2})?$/.test(y.trim()) && t.trim().length >= 1 && t.trim().length <= 60;
  function submit() {
    if (!ok || busy) return;
    setBusy(true); setFb(null);
    postTuning({ category: "career", item: y.trim(), value: t.trim(), note: n })
      .then((j) => {
        setRows((rs) => {
          const next = (rs || []).concat([j.row]);
          next.sort((a, b) => (a.item === b.item ? 0 : a.item < b.item ? -1 : 1));
          return next;
        });
        setY(""); setT(""); setN("");
        setFb("刻みました ── この1行は消えません");
      })
      .catch((e) => setFb("エラー: " + e.message))
      .finally(() => setBusy(false));
  }
  return (
    <div className="pb-cond-card pb-myc">
      {/* 公式記録に既にある行 */}
      <div className="pb-myc-wall">
        <div className="pb-eyebrow">ALREADY ON THE WALL ── チームの漕史に、既にあなたの行がある</div>
        {MY_WALL.map((w, i) => (
          <div key={i} className="pb-myc-row pb-myc-row-wall">
            <span className="pb-myc-y">{w.y}</span>
            <span className="pb-myc-t">{w.t}</span>
          </div>
        ))}
      </div>

      {/* 自分で刻んだ行 */}
      {rows === null ? (
        <div className="pb-ci-streak pb-mono">LOADING…</div>
      ) : rows.length === 0 ? (
        <div className="pb-myc-empty">
          ここから先は、自分で刻む。初めて漕いだ日、初めての公式戦、忘れたくない一本──
          小さな行の積み重ねが「ここまでやってきた」という証拠になります。
        </div>
      ) : (
        <div>
          {rows.map((r, i) => (
            <div key={r.id || i} className="pb-myc-row">
              <span className="pb-myc-y">{r.item}</span>
              <span className="pb-myc-t">{r.value}{r.note && <i className="pb-myc-note">{r.note}</i>}</span>
            </div>
          ))}
        </div>
      )}

      {/* 入力 */}
      <div className="pb-myc-form">
        <input className="pb-ci-min-input pb-mono pb-myc-in-y" type="text" placeholder="2019" maxLength={7}
          value={y} onChange={(e) => setY(e.target.value)} />
        <input className="pb-ci-min-input pb-myc-in-t" type="text" placeholder="出来事（例: 全日本選手権 W1x 優勝）" maxLength={60}
          value={t} onChange={(e) => setT(e.target.value)} />
        <button type="button" className="pb-ci-submit pb-tn-btn" disabled={!ok || busy} onClick={submit}>{busy ? "保存中…" : "刻む"}</button>
      </div>
      <input className="pb-ci-min-input pb-myc-in-n" type="text" placeholder="メモ（任意）" maxLength={200}
        value={n} onChange={(e) => setN(e.target.value)} />
      {fb && <div className="pb-tn-fb pb-mono">{fb}</div>}
    </div>
  );
}

function Hall() {
  const ref = React.useRef(null);
  const [h, setH] = React.useState(3200);
  const fit = () => {
    try {
      const d = ref.current && ref.current.contentDocument;
      if (d && d.body && d.body.scrollHeight > 400) setH(d.body.scrollHeight + 80);
    } catch (e) {}
  };
  React.useEffect(() => {
    const t = setInterval(fit, 1500);
    return () => clearInterval(t);
  }, []);
  return (
    <div className="pb-code">
      <div className="pb-grain" />
      <div className="pb-section-head">
        <div className="pb-section-num">01</div>
        <div className="pb-section-titles">
          <div className="pb-section-en">MY CHAPTER.</div>
          <div className="pb-section-jp">自分の漕史 ／ 自分の章は、自分で刻む</div>
        </div>
      </div>
      <MyChapter />

      <div className="pb-section-head pb-section-head-sub">
        <div className="pb-section-num">02</div>
        <div className="pb-section-titles">
          <div className="pb-section-en">CREW JAPAN.</div>
          <div className="pb-section-jp">日本代表の漕史 ／ 1920年からの実記録 — あなたはこの列の最後尾にいる</div>
        </div>
      </div>
      <iframe
        ref={ref}
        onLoad={fit}
        src={"/soushi?embed=1&boat=" + encodeURIComponent(((ATH.klass || "").split("／").pop() || "").trim().replace(/^B/, "") || "M1x")}
        title="漕史"
        style={{ width: "100%", height: h + "px", border: "none", display: "block", background: "transparent" }}
      />
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────────────────
// ── GRID — 選手自身の「日付 × 練習」マトリクス。各マスにZ1/Z2/Z3%、タップで波形 ──
// ─────────────────────────────────────────────────────────────────────────────
const PB_SPORT_ICON = { Rowing:"ti-rowing", VirtualRow:"ti-rowing", RowErg:"ti-rowing", Run:"ti-run", VirtualRide:"ti-bike", Ride:"ti-bike", Workout:"ti-stretching", WeightTraining:"ti-barbell", Walk:"ti-walk", Swim:"ti-swimming", Pilates:"ti-yoga", OpenWaterSwim:"ti-swimming", Transition:"ti-arrows-right-left" };
const PB_SPORT_LB   = { Rowing:"漕艇", VirtualRow:"Erg", RowErg:"Erg", Run:"ラン", VirtualRide:"バイク", Ride:"バイク", Workout:"運動", WeightTraining:"ウエイト", Walk:"歩行", Swim:"水泳", Pilates:"ピラティス", OpenWaterSwim:"OWS", Transition:"移動" };

function Grid() {
  const [data, setData] = React.useState(null);
  const [oldest, setOldest] = React.useState(null);
  const [loading, setLoading] = React.useState(true);
  const [open, setOpen] = React.useState(null); // 選択中のセル: {act, name}

  function todayJST(){ var d=new Date(Date.now()+9*3600*1000); return d.toISOString().slice(0,10); }
  function addD(s,n){ var d=new Date(s+"T00:00:00Z"); d.setUTCDate(d.getUTCDate()+n); return d.toISOString().slice(0,10); }

  React.useEffect(() => {
    var today = todayJST();
    var old = addD(today, -13);
    setOldest(old);
    fetch("/api/me/grid?id="+encodeURIComponent(ATH.id)+"&oldest="+old+"&newest="+today, { credentials:"same-origin", cache:"no-store" })
      .then(function(r){ return r.json(); })
      .then(function(j){ setData(j); setLoading(false); })
      .catch(function(){ setLoading(false); });
  }, []);

  function loadMore(){
    if (!oldest) return;
    var newOld = addD(oldest, -7);
    var newEnd = addD(oldest, -1);
    fetch("/api/me/grid?id="+encodeURIComponent(ATH.id)+"&oldest="+newOld+"&newest="+newEnd, { credentials:"same-origin", cache:"no-store" })
      .then(function(r){ return r.json(); })
      .then(function(j){
        if (!j.ok) return;
        setData(function(prev){
          var merged = Object.assign({}, prev.athlete.by_day, j.athlete.by_day);
          var days = j.days.concat(prev.days);
          return { ok:true, days:days, athlete: Object.assign({}, prev.athlete, { by_day: merged }) };
        });
        setOldest(newOld);
      });
  }

  if (loading) return <div className="pb-grid-wrap"><div className="pb-grid-muted">読み込み中…</div></div>;
  if (!data || !data.ok) return <div className="pb-grid-wrap"><div className="pb-grid-muted">{(data && data.error) || "取得失敗"}</div></div>;

  var today = todayJST();
  // 縦＝日付（新しい順）・横＝その日のセッション（開始時刻の早い順=朝が左）— 2026-07-03 紘志さん指定・スマホ最優先
  var days = (data.days || []).slice().reverse();
  var byDay = (data.athlete && data.athlete.by_day) || {};

  return (
    <div className="pb-grid-wrap">
      <div className="pb-grid-head">
        <div className="pb-eyebrow pb-eyebrow-gold">SESSIONS ／ 実トレ × 心拍</div>
        <div className="pb-grid-sub">縦＝日付（新しい順）・横＝その日のセッション（朝が左）。マスをタップで心拍波形。緑=Z1 / 金=Z2 / 赤=Z3</div>
      </div>
      {days.map(function(d){
        var acts = (byDay[d] || []).slice().sort(function(a,b){ return String(a.start||"").localeCompare(String(b.start||"")); });
        if (!acts.length) return null; // OFF日は行を作らない（リズムはGROWTHの「リズム」で見る）
        var dd=new Date(d+"T00:00:00Z");
        var w=["日","月","火","水","木","金","土"][dd.getUTCDay()];
        var isToday = (d===today);
        return (
          <div key={d} className="pb-gr-row">
            <div className={"pb-gr-date pb-mono"+(isToday?" pb-gr-today":"")}>{d.slice(5).replace("-","/")} <span className="pb-gr-w">{w}</span>{isToday?<span className="pb-gr-td">今日</span>:null}</div>
            <div className="pb-gr-acts">
              {acts.map(function(x, idx){
                var ic = PB_SPORT_ICON[x.sport] || "ti-activity";
                var lb = PB_SPORT_LB[x.sport]   || x.sport;
                var min = x.minutes ? (x.minutes>=60 ? Math.floor(x.minutes/60)+"h"+(x.minutes%60)+"m" : x.minutes+"m") : "—";
                var tm = x.start ? String(x.start).slice(11,16) : "";
                var p1=x.z1pct||0, p2=x.z2pct||0, p3=x.z3pct||0;
                var hasZ = (x.z1pct!=null||x.z2pct!=null||x.z3pct!=null);
                return <div key={idx} className="pb-act pb-gr-act" onClick={function(){ setOpen({ act:x, name:(data.athlete.jp||ATH.jp), sportLabel:lb }); }}>
                  <div className="pb-act-r1">{tm ? <span className="pb-gr-tm">{tm}</span> : null}<i className={"ti "+ic+" pb-act-ic"} aria-hidden="true"></i><span className="pb-act-min">{min}</span></div>
                  <div className="pb-act-r2">{x.avg_hr ? <span className="pb-act-hr">{x.avg_hr}bpm</span> : null}{x.load!=null ? <span className="pb-act-ld">負荷 {x.load}</span> : null}<span className="pb-act-sp">{lb}</span></div>
                  {hasZ ? <div className="pb-zbar"><div className="pb-z1" style={{width:p1+"%"}}></div><div className="pb-z2" style={{width:p2+"%"}}></div><div className="pb-z3" style={{width:p3+"%"}}></div></div> : null}
                  {hasZ ? <div className="pb-zpct"><span className="pb-zp1">Z1 {p1}%</span><span className="pb-zp2">Z2 {p2}%</span><span className="pb-zp3">Z3 {p3}%</span></div> : null}
                </div>;
              })}
            </div>
          </div>
        );
      })}
      <button className="pb-grid-more" onClick={loadMore}>← さらに過去を読み込む</button>
      {open && <GridPanel info={open} onClose={function(){ setOpen(null); }} />}
    </div>
  );
}

function GridPanel({ info, onClose }) {
  const [pts, setPts] = React.useState(null);
  const [note, setNote] = React.useState("");
  const canvasRef = React.useRef(null);
  var x = info.act;

  React.useEffect(() => {
    fetch("/api/me/stream?id="+encodeURIComponent(ATH.id)+"&activity="+encodeURIComponent(x.id)+"&every=5", { credentials:"same-origin", cache:"no-store" })
      .then(function(r){ return r.json(); })
      .then(function(j){
        if (!j.ok) { setNote(j.error||"取得失敗"); return; }
        if (!j.points || !j.points.length) { setNote("心拍ストリームなし"); return; }
        setPts(j.points);
      })
      .catch(function(){ setNote("通信エラー"); });
  }, []);

  React.useEffect(() => {
    if (!pts || !canvasRef.current) return;
    drawHRChart(canvasRef.current, pts, x.hr_z2_low, x.hr_z3_low);
  }, [pts]);

  function fmtSec(s){ var m=Math.round(s/60); return m>=60 ? Math.floor(m/60)+"h"+(m%60)+"m" : m+"m"; }
  var min = x.minutes ? (x.minutes>=60 ? Math.floor(x.minutes/60)+"h"+(x.minutes%60)+"m" : x.minutes+"m") : "—";
  var hasZ = (x.z1pct!=null||x.z2pct!=null||x.z3pct!=null);

  return (
    <div className="pb-grid-modal" onClick={function(e){ if (e.target===e.currentTarget) onClose(); }}>
      <div className="pb-grid-box">
        <button className="pb-grid-close" onClick={onClose}>×</button>
        <div className="pb-grid-title">{info.name} ／ {info.sportLabel}</div>
        <div className="pb-grid-time">{x.start || "—"}</div>
        <div className="pb-grid-stats">
          <div className="pb-gs"><div className="pb-gs-k">時間</div><div className="pb-gs-v">{min}</div></div>
          <div className="pb-gs"><div className="pb-gs-k">平均心拍</div><div className="pb-gs-v">{x.avg_hr ? x.avg_hr+"bpm" : "—"}</div></div>
          <div className="pb-gs"><div className="pb-gs-k">最高心拍</div><div className="pb-gs-v">{x.max_hr ? x.max_hr+"bpm" : "—"}</div></div>
          <div className="pb-gs"><div className="pb-gs-k">負荷</div><div className="pb-gs-v">{x.load!=null ? x.load : "—"}</div></div>
        </div>
        {hasZ && <div className="pb-grid-zones">
          <div className="pb-grid-zlabel">ゾーン内訳</div>
          <div className="pb-grid-zbar"><div style={{ background:"#4ec77f", width:(x.z1pct||0)+"%" }}></div><div style={{ background:"var(--gold)", width:(x.z2pct||0)+"%" }}></div><div style={{ background:"#ef2b2b", width:(x.z3pct||0)+"%" }}></div></div>
          <div className="pb-grid-zrow"><span style={{color:"#4ec77f"}}>Z1 {x.z1pct!=null?x.z1pct:"—"}% <span className="pb-grid-zsub">{x.z1?"("+fmtSec(x.z1)+")":""}</span></span><span style={{color:"var(--gold)"}}>Z2 {x.z2pct!=null?x.z2pct:"—"}% <span className="pb-grid-zsub">{x.z2?"("+fmtSec(x.z2)+")":""}</span></span><span style={{color:"#ef2b2b"}}>Z3 {x.z3pct!=null?x.z3pct:"—"}% <span className="pb-grid-zsub">{x.z3?"("+fmtSec(x.z3)+")":""}</span></span></div>
        </div>}
        <canvas ref={canvasRef} className="pb-grid-chart"></canvas>
        {note && <div className="pb-grid-note">{note}</div>}
      </div>
    </div>
  );
}

function drawHRChart(c, pts, z2low, z3low) {
  var dpr = window.devicePixelRatio || 1;
  var W = c.clientWidth, H = c.clientHeight;
  c.width = W*dpr; c.height = H*dpr;
  var x = c.getContext("2d"); x.scale(dpr,dpr);
  x.fillStyle="#000"; x.fillRect(0,0,W,H);
  var tMax = pts[pts.length-1].t || 1;
  var hrs = pts.map(function(p){return p.hr;});
  var hMin = Math.min.apply(null,hrs)-5, hMax = Math.max.apply(null,hrs)+5;
  if (z2low!=null && z2low<hMin) hMin = z2low-5;
  if (z3low!=null && z3low>hMax) hMax = z3low+5;
  var hr2y = function(h){ return H - (h-hMin)/(hMax-hMin)*H; };
  if (z2low!=null && z3low!=null){
    var y_z3low = hr2y(z3low), y_z2low = hr2y(z2low);
    x.fillStyle="rgba(239,43,43,.10)"; x.fillRect(0,0,W,y_z3low);
    x.fillStyle="rgba(255,184,77,.10)"; x.fillRect(0,y_z3low,W,y_z2low-y_z3low);
    x.fillStyle="rgba(78,199,127,.10)"; x.fillRect(0,y_z2low,W,H-y_z2low);
    x.strokeStyle="rgba(255,184,77,.4)"; x.lineWidth=1; x.setLineDash([3,3]);
    x.beginPath(); x.moveTo(0,y_z2low); x.lineTo(W,y_z2low); x.stroke();
    x.strokeStyle="rgba(239,43,43,.4)";
    x.beginPath(); x.moveTo(0,y_z3low); x.lineTo(W,y_z3low); x.stroke();
    x.setLineDash([]);
    x.fillStyle="rgba(255,184,77,.7)"; x.font='10px "JetBrains Mono",monospace';
    x.fillText(z2low+"", W-30, y_z2low-2);
    x.fillStyle="rgba(239,43,43,.7)";
    x.fillText(z3low+"", W-30, y_z3low-2);
  } else {
    x.strokeStyle="rgba(255,255,255,.08)"; x.lineWidth=1;
    for (var i=1;i<5;i++){ var y=H*i/5; x.beginPath(); x.moveTo(0,y); x.lineTo(W,y); x.stroke(); }
  }
  var colorFor = function(hr){
    if (z3low!=null && hr>=z3low) return "#ef2b2b";
    if (z2low!=null && hr>=z2low) return "#ffb84d";
    if (z2low!=null) return "#4ec77f";
    return "#ff6b6b";
  };
  x.lineWidth=1.5;
  for (var i=1;i<pts.length;i++){
    var p0=pts[i-1], p1=pts[i];
    x.strokeStyle = colorFor((p0.hr+p1.hr)/2);
    x.beginPath();
    x.moveTo(p0.t/tMax*W, hr2y(p0.hr));
    x.lineTo(p1.t/tMax*W, hr2y(p1.hr));
    x.stroke();
  }
  x.fillStyle="rgba(255,255,255,.7)"; x.font='10px "JetBrains Mono",monospace';
  x.fillText(Math.round(hMax)+"bpm", 4, 12);
  x.fillText(Math.round(hMin)+"bpm", 4, H-4);
}

// ─────────────────────────────────────────────────────────────────────────────
// ── GROWTH — ゾーンの積み上げ（TREND タブ・2026-06-29）
//    成長=累積マウンテン / 配分=7日ローリング比率 / リズム=日次バー。
//    データ源は /api/athlete/<id>/activities?days=60（日別Z1/Z2/Z3秒・嘘の数字は無い）。
// ─────────────────────────────────────────────────────────────────────────────
function zgNiceCeil(v){ if(v<=0) return 1; var p=Math.pow(10,Math.floor(Math.log10(v))); var f=v/p; var nf=f<=1?1:f<=2?2:f<=5?5:10; return nf*p; }

function zgSetup(c){
  var dpr=window.devicePixelRatio||1, W=c.clientWidth, H=c.clientHeight;
  if(!W||!H) return null;
  c.width=W*dpr; c.height=H*dpr; var x=c.getContext("2d"); x.setTransform(dpr,0,0,dpr,0,0); x.clearRect(0,0,W,H);
  return { x:x, W:W, H:H, padL:36, padR:8, padT:8, padB:16 };
}
function zgXLabels(g, s){
  var x=g.x, n=s.dates.length, pw=g.W-g.padL-g.padR;
  var X=function(i){ return g.padL + (n<=1?0:i/(n-1))*pw; };
  x.fillStyle="rgba(255,255,255,.7)"; x.font='10px "JetBrains Mono",monospace';
  [0,(n-1)>>1,n-1].forEach(function(i){ if(i<0) return; var lbl=s.dates[i].slice(5).replace("-","/"); var tx=X(i); if(i===n-1) tx-=20; if(i===0) tx=g.padL; x.fillText(lbl, tx, g.H-4); });
}

// 成長：累積スタックドエリア（下から緑Z1→金Z2→赤Z3）
function drawZGrowth(c, s){
  var g=zgSetup(c); if(!g) return; var x=g.x, n=s.dates.length;
  var pw=g.W-g.padL-g.padR, ph=g.H-g.padT-g.padB;
  var c1=[],c2=[],c3=[],a=0,b=0,d=0;
  for(var i=0;i<n;i++){ a+=s.z1[i]/3600; b+=s.z2[i]/3600; d+=s.z3[i]/3600; c1.push(a); c2.push(a+b); c3.push(a+b+d); }
  var yMax=zgNiceCeil(c3[n-1]||1);
  var X=function(i){ return g.padL + (n<=1?0:i/(n-1))*pw; };
  var Y=function(v){ return g.padT+ph-(v/yMax)*ph; };
  x.font='10px "JetBrains Mono",monospace';
  for(var gg=0;gg<=4;gg++){ var v=yMax*gg/4, yy=Y(v); x.strokeStyle="rgba(255,255,255,.06)"; x.lineWidth=1; x.beginPath(); x.moveTo(g.padL,yy); x.lineTo(g.W-g.padR,yy); x.stroke(); x.fillStyle="rgba(255,255,255,.7)"; x.fillText((Math.round(v*10)/10)+"h",2,yy+3); }
  function fillArea(top,base,col){ x.beginPath(); x.moveTo(X(0),Y(base?base[0]:0)); for(var i=0;i<n;i++) x.lineTo(X(i),Y(top[i])); for(var i=n-1;i>=0;i--) x.lineTo(X(i),Y(base?base[i]:0)); x.closePath(); x.fillStyle=col; x.fill(); }
  fillArea(c3,c2,"rgba(239,43,43,.22)");
  fillArea(c2,c1,"rgba(255,184,77,.26)");
  fillArea(c1,null,"rgba(78,199,127,.30)");
  function stroke(arr,col){ x.beginPath(); for(var i=0;i<n;i++){ var px=X(i),py=Y(arr[i]); i?x.lineTo(px,py):x.moveTo(px,py); } x.strokeStyle=col; x.lineWidth=1.6; x.stroke(); }
  stroke(c3,"#ef2b2b"); stroke(c2,"#ffb84d"); stroke(c1,"#4ec77f");
  zgXLabels(g,s);
}

// 配分：7日ローリングの比率（100%スタック・無データ日は0で潰す＝嘘を足さない）
function drawZDist(c, s){
  var g=zgSetup(c); if(!g) return; var x=g.x, n=s.dates.length;
  var pw=g.W-g.padL-g.padR, ph=g.H-g.padT-g.padB;
  var p1=[],p2=[],p3=[];
  for(var i=0;i<n;i++){
    var a=0,b=0,d=0;
    for(var j=Math.max(0,i-6);j<=i;j++){ a+=s.z1[j]; b+=s.z2[j]; d+=s.z3[j]; }
    var t=a+b+d;
    if(t>0){ p1.push(a/t*100); p2.push((a+b)/t*100); p3.push(100); }
    else { p1.push(0); p2.push(0); p3.push(0); }
  }
  var X=function(i){ return g.padL + (n<=1?0:i/(n-1))*pw; };
  var Y=function(v){ return g.padT+ph-(v/100)*ph; };
  x.font='10px "JetBrains Mono",monospace';
  [0,25,50,75,100].forEach(function(v){ var yy=Y(v); x.strokeStyle="rgba(255,255,255,.06)"; x.lineWidth=1; x.beginPath(); x.moveTo(g.padL,yy); x.lineTo(g.W-g.padR,yy); x.stroke(); x.fillStyle="rgba(255,255,255,.7)"; x.fillText(v+"%",2,yy+3); });
  function fillArea(top,base,col){ x.beginPath(); x.moveTo(X(0),Y(base?base[0]:0)); for(var i=0;i<n;i++) x.lineTo(X(i),Y(top[i])); for(var i=n-1;i>=0;i--) x.lineTo(X(i),Y(base?base[i]:0)); x.closePath(); x.fillStyle=col; x.fill(); }
  fillArea(p3,p2,"rgba(239,43,43,.50)");
  fillArea(p2,p1,"rgba(255,184,77,.55)");
  fillArea(p1,null,"rgba(78,199,127,.55)");
  zgXLabels(g,s);
}

// リズム：日次スタックドバー（分・hard/easyのメリハリ）
function drawZRhythm(c, s){
  var g=zgSetup(c); if(!g) return; var x=g.x, n=s.dates.length;
  var pw=g.W-g.padL-g.padR, ph=g.H-g.padT-g.padB;
  var mins=[]; for(var i=0;i<n;i++) mins.push((s.z1[i]+s.z2[i]+s.z3[i])/60);
  var yMax=zgNiceCeil(Math.max.apply(null, mins.concat([1])));
  var Y=function(v){ return g.padT+ph-(v/yMax)*ph; };
  x.font='10px "JetBrains Mono",monospace';
  for(var gg=0;gg<=4;gg++){ var v=yMax*gg/4, yy=Y(v); x.strokeStyle="rgba(255,255,255,.06)"; x.lineWidth=1; x.beginPath(); x.moveTo(g.padL,yy); x.lineTo(g.W-g.padR,yy); x.stroke(); x.fillStyle="rgba(255,255,255,.7)"; x.fillText(Math.round(v)+"分",2,yy+3); }
  var slot=pw/n, bw=Math.max(2, slot*0.66), y0=g.padT+ph;
  for(var i=0;i<n;i++){
    var cx=g.padL + slot*(i+0.5), bx=cx-bw/2, yb=y0;
    var seg=[[s.z1[i]/60,"#4ec77f"],[s.z2[i]/60,"#ffb84d"],[s.z3[i]/60,"#ef2b2b"]];
    for(var k=0;k<3;k++){ var m=seg[k][0]; if(m<=0) continue; var hh=m/yMax*ph; x.fillStyle=seg[k][1]; x.fillRect(bx, yb-hh, bw, hh); yb-=hh; }
  }
  zgXLabels(g,s);
}

// トリマーのフィルムストリップ（全期間の日次トレ量をミニ積み上げで表示＝iPhone動画のサムネ帯）
function drawTrimFilm(c, s){
  var dpr=window.devicePixelRatio||1, W=c.clientWidth, H=c.clientHeight; if(!W||!H) return;
  c.width=W*dpr; c.height=H*dpr; var x=c.getContext("2d"); x.setTransform(dpr,0,0,dpr,0,0); x.clearRect(0,0,W,H);
  var n=s.dates.length; if(!n) return;
  var mins=[]; for(var i=0;i<n;i++) mins.push((s.z1[i]+s.z2[i]+s.z3[i])/60);
  var mx=Math.max.apply(null, mins.concat([1]));
  var slot=W/n, bw=Math.max(1, slot*0.78);
  for(var i=0;i<n;i++){
    if(mins[i]<=0) continue;
    var cx=slot*(i+0.5), bx=cx-bw/2, yb=H-2;
    var seg=[[s.z1[i]/60,"rgba(78,199,127,.9)"],[s.z2[i]/60,"rgba(255,184,77,.9)"],[s.z3[i]/60,"rgba(239,43,43,.9)"]];
    for(var k=0;k<3;k++){ var m=seg[k][0]; if(m<=0) continue; var hh=(m/mx)*(H-4); x.fillStyle=seg[k][1]; x.fillRect(bx, yb-hh, bw, hh); yb-=hh; }
  }
}

function ZoneTrim({ n, lo, hi, dates, full, onChange }){
  // 期間切替の瞬間、親のrngが新データの長さを超える1フレームがある → 範囲内に丸めて事故防止（2026-07-03 真っ暗バグ修正）
  lo = Math.max(0, Math.min(lo, n - 1));
  hi = Math.max(lo, Math.min(hi, n - 1));
  var barRef = React.useRef(null);
  var filmRef = React.useRef(null);
  var drag = React.useRef(null);
  function fmtD(d){ return d ? (+d.slice(5,7))+"/"+(+d.slice(8,10)) : ""; }
  function pct(i){ return n<=1?0:i/(n-1)*100; }
  function clamp(v,mn,mx){ return Math.max(mn,Math.min(mx,v)); }
  function idxFromX(clientX){
    var bar=barRef.current; if(!bar) return 0;
    var r=bar.getBoundingClientRect(), frac=(clientX-r.left)/r.width;
    return clamp(Math.round(frac*(n-1)),0,n-1);
  }
  function down(which,e){
    e.preventDefault(); e.stopPropagation(); drag.current=which;
    var move=function(ev){ var cx=ev.touches?ev.touches[0].clientX:ev.clientX; var i=idxFromX(cx); if(drag.current==="lo") onChange(Math.min(i,hi-1),hi); else onChange(lo,Math.max(i,lo+1)); };
    var up=function(){ drag.current=null; document.removeEventListener("pointermove",move); document.removeEventListener("pointerup",up); document.removeEventListener("touchmove",move); document.removeEventListener("touchend",up); };
    document.addEventListener("pointermove",move); document.addEventListener("pointerup",up); document.addEventListener("touchmove",move,{passive:false}); document.addEventListener("touchend",up);
  }
  React.useEffect(function(){
    function draw(){ if(filmRef.current && full) drawTrimFilm(filmRef.current, full); }
    draw(); window.addEventListener("resize", draw);
    return function(){ window.removeEventListener("resize", draw); };
  }, [full]);

  var isFull = lo===0 && hi===n-1;
  return (
    <div className="pb-trim">
      <div className="pb-trim-tips">
        <span className="pb-trim-tip pb-trim-tip-l pb-mono" style={{left:pct(lo)+"%"}}>{fmtD(dates[lo])}</span>
        <span className="pb-trim-tip pb-trim-tip-r pb-mono" style={{left:pct(hi)+"%"}}>{fmtD(dates[hi])}</span>
      </div>
      <div className="pb-trim-strip" ref={barRef}>
        <canvas className="pb-trim-film" ref={filmRef} aria-hidden="true" />
        <div className="pb-trim-dim" style={{left:0, width:pct(lo)+"%"}}/>
        <div className="pb-trim-dim" style={{right:0, width:(100-pct(hi))+"%"}}/>
        <div className="pb-trim-frame" style={{left:pct(lo)+"%", right:(100-pct(hi))+"%"}}/>
        <div className="pb-trim-h pb-trim-hl" style={{left:pct(lo)+"%"}} onPointerDown={function(e){down("lo",e);}} onTouchStart={function(e){down("lo",e);}}><i className="pb-trim-grip"/></div>
        <div className="pb-trim-h pb-trim-hr" style={{left:pct(hi)+"%"}} onPointerDown={function(e){down("hi",e);}} onTouchStart={function(e){down("hi",e);}}><i className="pb-trim-grip"/></div>
      </div>
      <div className="pb-trim-info pb-mono">
        <span>{fmtD(dates[lo])} — {fmtD(dates[hi])}（{hi-lo+1}日）</span>
        {!isFull && <button type="button" className="pb-trim-reset" onClick={function(){ onChange(0,n-1); }}>全期間</button>}
      </div>
    </div>
  );
}

var SPANS=[[30,"1M"],[60,"2M"],[180,"6M"],[365,"1Y"],[1825,"ALL"]];

function ZoneGrowth(){
  const [span, setSpan] = React.useState(30);
  const [data, setData] = React.useState(null);
  const [phase, setPhase] = React.useState("loading");
  const [view, setView] = React.useState("growth");
  const [rng, setRng] = React.useState(null);
  const cv = React.useRef(null);

  React.useEffect(function(){
    var dead = false;
    setPhase("loading"); setData(null); setRng(null);
    fetch("/api/athlete/"+encodeURIComponent(ATH.id)+"/activities?days="+span, { credentials:"same-origin", cache:"no-store" })
      .then(function(r){ return r.ok ? r.json() : Promise.reject(); })
      .then(function(j){ if(!dead){ setData(j); setPhase("ok"); } })
      .catch(function(){ if(!dead) setPhase("error"); });
    return function(){ dead = true; };
  }, [span]);

  const full = React.useMemo(function(){
    if(!data || !data.daily || !data.range) return null;
    var map={}; data.daily.forEach(function(d){ map[d.date]=d; });
    function addD(s,n){ var dd=new Date(s+"T00:00:00Z"); dd.setUTCDate(dd.getUTCDate()+n); return dd.toISOString().slice(0,10); }
    var dates=[],z1=[],z2=[],z3=[], d=data.range.oldest, end=data.range.newest, guard=0;
    while(d<=end && guard++<1900){ var r=map[d]||{z1:0,z2:0,z3:0}; dates.push(d); z1.push(r.z1||0); z2.push(r.z2||0); z3.push(r.z3||0); d=addD(d,1); }
    // 先頭の空白日の切り詰めは ALL のみ（=取得できたデータ全て、データ開始日から表示）。
    // 1M/2M/6M/1Y はラベル通りの期間を表示（1Y=365日。2026-07-03 紘志さん指定）
    if(span === 1825){
      var first=0; for(var i=0;i<dates.length;i++){ if(z1[i]+z2[i]+z3[i]>0){ first=i; break; } }
      if(first>0){ dates=dates.slice(first); z1=z1.slice(first); z2=z2.slice(first); z3=z3.slice(first); }
    }
    return { dates:dates, z1:z1, z2:z2, z3:z3 };
  }, [data, span]);

  React.useEffect(function(){ if(full) setRng([0, full.dates.length-1]); }, [full]);

  var series = React.useMemo(function(){
    if(!full || !rng) return null;
    var lo=rng[0], hi=rng[1];
    return { dates:full.dates.slice(lo,hi+1), z1:full.z1.slice(lo,hi+1), z2:full.z2.slice(lo,hi+1), z3:full.z3.slice(lo,hi+1) };
  }, [full, rng]);

  const hasData = !!(full && full.z1.concat(full.z2, full.z3).some(function(v){ return v>0; }));

  React.useEffect(function(){
    if(phase!=="ok" || !series || !hasData) return;
    function redraw(){ if(!cv.current) return; if(view==="growth") drawZGrowth(cv.current,series); else if(view==="dist") drawZDist(cv.current,series); else drawZRhythm(cv.current,series); }
    redraw();
    window.addEventListener("resize", redraw);
    return function(){ window.removeEventListener("resize", redraw); };
  }, [phase, series, hasData, view]);

  var s1=0,s2=0,s3=0;
  if(series){ for(var i=0;i<series.dates.length;i++){ s1+=series.z1[i]; s2+=series.z2[i]; s3+=series.z3[i]; } }
  var tot=s1+s2+s3;
  function h1(x){ return (x/3600).toFixed(1); }
  function pc(x){ return tot>0?Math.round(x/tot*100):0; }
  var nDays = series ? series.dates.length : 0;

  var VIEWS=[["growth","成長"],["dist","配分"],["rhythm","リズム"]];
  var CAP={
    growth:"下から緑=Z1ベース・金=Z2・赤=Z3。横ばい=OFF（回復も訓練）。",
    dist:"7日ローリング配分比率。緑(Z1)が分厚い=良。金(Z2)増=中強度の沼。",
    rhythm:"日ごとの練習時間。hard/easyのメリハリ＝リズム。",
  };

  return (
    <div className="pb-cond-card pb-wake-card pb-zg">
      <div className="pb-eyebrow pb-eyebrow-gold">GROWTH ／ ゾーンの積み上げ</div>
      <div className="pb-zg-spans pb-mono">
        {SPANS.map(function(s){ return <button key={s[0]} type="button" className={"pb-zg-span"+(span===s[0]?" pb-zg-span-on":"")} onClick={function(){ setSpan(s[0]); }}>{s[1]}</button>; })}
      </div>

      {phase==="loading" && <div className="pb-zg-loading"><div className="pb-zg-loading-text pb-mono">Loading…</div></div>}
      {phase==="error" && <div className="pb-cond-empty">取得できませんでした。再読み込みしてください。</div>}
      {phase==="ok" && !hasData && <div className="pb-cond-empty">心拍データなし — intervals.icu 連携で表示されます。</div>}

      {phase==="ok" && hasData && full && rng && (
        <React.Fragment>
          <div className="pb-zg-stats pb-mono">
            <div className="pb-zg-stat"><div className="pb-zg-sv">{h1(tot)}<span>h</span></div><div className="pb-zg-sl">累計（{nDays}日）</div></div>
            <div className="pb-zg-stat"><div className="pb-zg-sv" style={{color:"#4ec77f"}}>{pc(s1)}<span>%</span></div><div className="pb-zg-sl">Z1 BASE</div></div>
            <div className="pb-zg-stat"><div className="pb-zg-sv" style={{color:"var(--gold)"}}>{pc(s2)}<span>%</span></div><div className="pb-zg-sl">Z2 TEMPO</div></div>
            <div className="pb-zg-stat"><div className="pb-zg-sv" style={{color:"#ef2b2b"}}>{pc(s3)}<span>%</span></div><div className="pb-zg-sl">Z3 HIGH</div></div>
          </div>
          <ZoneTrim n={full.dates.length} lo={rng[0]} hi={rng[1]} dates={full.dates} full={full} onChange={function(a,b){ setRng([a,b]); }} />
          <div className="pb-zg-top">
            <div className="pb-zg-legend pb-mono">
              <span><i className="pb-zg-sw pb-zg-c1"/>Z1 {h1(s1)}h</span>
              <span><i className="pb-zg-sw pb-zg-c2"/>Z2 {h1(s2)}h</span>
              <span><i className="pb-zg-sw pb-zg-c3"/>Z3 {h1(s3)}h</span>
            </div>
            <div className="pb-zg-tabs pb-mono">
              {VIEWS.map(function(v){ return <button key={v[0]} type="button" className={"pb-zg-tab"+(view===v[0]?" pb-zg-tab-on":"")} onClick={function(){ setView(v[0]); }}>{v[1]}</button>; })}
            </div>
          </div>
          <canvas ref={cv} className="pb-zg-cv" role="img" aria-label="ゾーン積み上げグラフ" />
          <div className="pb-cond-foot pb-mono">{CAP[view]}</div>
        </React.Fragment>
      )}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────────────────
// ── コンディション年表（トレーナービュー・2026-07-02）───────────────────────
//  トレーナー要望「Hooper・負荷・身体・体重を同じ時間軸で振り返り、離脱と重ねたい」。
//  データ: /api/athlete/<id>/timeline（実データのみ。無い指標は「計測なし」表示＝捏造しない）。
//  離脱の記録（腰/肋骨/その他外傷/内科系/その他）は coach_scopes 登録者のみ表示・操作可。
//  レーン: イベント帯 → Hooper → 日次TSS → CTL/ATL/TSB → HRV → 安静時HR → 体重
// ─────────────────────────────────────────────────────────────────────────────
var TL_COLORS = { hooper:"#ffb84d", item:"#6ad1c8", tss:"rgba(255,184,77,.55)", ctl:"#4ec77f", atl:"#ff8a3d" /* = --warn（注意色。金は使わない） */, tsb:"#7fb0ff", hrv:"#7fb0ff", rhr:"#ff8a8a", wt:"#e8d9b8" };
var TL_CATS = ["腰","肋骨","その他外傷","内科系","その他"];
// 国内レース等の追加イベント（2026-07-03 紘志さん提供の出場実績）。
// 海外W杯(WC1/WC3等)は各選手の PHASES（レース経路）から自動表示されるのでここには書かない。
// who: "all"=全員 / "domestic"=欧州先発組(選手・選手・選手)以外の全員
var TL_EU3 = ["sakurama", "takano", "yonekawa"];
var TL_EVENTS = [
  { start:"2026-03-13", end:"2026-03-15", label:"代表選考", who:"all" },
  { start:"2026-05-09", end:"2026-05-10", label:"社会人", who:"domestic" },
  { start:"2026-06-04", end:"2026-06-07", label:"全日本", who:"domestic" },
];
function tlEventsFor(athleteId){
  var extra = TL_EVENTS.filter(function(ev){
    return ev.who === "all" || (ev.who === "domestic" && TL_EU3.indexOf(athleteId) === -1);
  }).map(function(ev){ return { kind:"race-jp", start:ev.start, end:ev.end, label:ev.label }; });
  var phases = ((window.PB && window.PB.PHASES) || []).filter(function(p){ return /^race/.test(p.kind || ""); });
  return phases.concat(extra);
}

function tlSetup(c){
  var dpr = window.devicePixelRatio || 1, W = c.clientWidth, H = c.clientHeight;
  if(!W || !H) return null;
  c.width = W * dpr; c.height = H * dpr;
  var x = c.getContext("2d"); x.scale(dpr, dpr);
  x.fillStyle = "#000"; x.fillRect(0, 0, W, H);
  return { x:x, W:W, H:H, padL:8, padR:46, padT:6, padB:6 };
}
function tlXOf(g, n){ var plot = g.W - g.padL - g.padR; return function(i){ return g.padL + (n <= 1 ? plot / 2 : plot * i / (n - 1)); }; }
function tlEmpty(g, msg){
  g.x.fillStyle = "rgba(255,255,255,.7)"; g.x.font = '10px "JetBrains Mono",monospace';
  g.x.textAlign = "center"; g.x.fillText(msg, g.W / 2, g.H / 2 + 3); g.x.textAlign = "left";
}
// 連動カーソル（タップ/なぞった日を全レーンに縦線で表示）
function tlCursor(g, n, idx){
  if(idx == null || !n) return;
  var xOf = tlXOf(g, n);
  g.x.strokeStyle = "rgba(255,255,255,.45)"; g.x.lineWidth = 1; g.x.setLineDash([3,3]);
  g.x.beginPath(); g.x.moveTo(xOf(idx), 0); g.x.lineTo(xOf(idx), g.H); g.x.stroke();
  g.x.setLineDash([]);
}
// 直近2週間の向き（前半7日平均→後半7日平均の差）。データ6点未満は null
function tlTrend(map, dates){
  var vals = [];
  for(var i = Math.max(0, dates.length - 14); i < dates.length; i++){
    var v = map[dates[i]]; if(v != null && isFinite(v)) vals.push(v);
  }
  if(vals.length < 6) return null;
  var h = Math.floor(vals.length / 2);
  var m = function(a){ var s = 0; a.forEach(function(x){ s += x; }); return s / a.length; };
  return Math.round((m(vals.slice(h)) - m(vals.slice(0, h))) * 10) / 10;
}
// 離脱期間の赤帯（全レーン共通の背景）
function tlShade(g, dates, injuries){
  if(!injuries || !injuries.length || !dates.length) return;
  var n = dates.length, xOf = tlXOf(g, n);
  g.x.fillStyle = "rgba(239,43,43,.12)";
  injuries.forEach(function(inj){
    var s = inj.start, e = inj.end || dates[n-1];
    if(e < dates[0] || s > dates[n-1]) return;
    var i0 = 0; while(i0 < n - 1 && dates[i0] < s) i0++;
    var i1 = n - 1; while(i1 > 0 && dates[i1] > e) i1--;
    if(i1 < i0) return;
    g.x.fillRect(xOf(i0) - 2, 0, Math.max(3, xOf(i1) - xOf(i0) + 4), g.H);
  });
}
// 汎用の折れ線（欠測は途切れ、右端に最新値ラベル）。値が無ければ false
//  opts.includeZero: 縦軸に必ず0を含める ／ opts.zeroLine: 0の位置に破線
//  opts.annotate: 最大・最小の点に値を注記（単独表示タブ用・2026-07-03）
function tlLine(g, dates, map, color, opts){
  opts = opts || {};
  var n = dates.length, ih = g.H - g.padT - g.padB, xOf = tlXOf(g, n);
  var vals = [], maxV = -Infinity, minV = Infinity, maxI = -1, minI = -1;
  for(var i = 0; i < n; i++){
    var v = map[dates[i]]; if(v == null || !isFinite(v)) continue;
    vals.push(v);
    if(v > maxV){ maxV = v; maxI = i; }
    if(v < minV){ minV = v; minI = i; }
  }
  if(!vals.length) return false;
  var lo = opts.yMin != null ? opts.yMin : Math.min.apply(null, vals);
  var hi = opts.yMax != null ? opts.yMax : Math.max.apply(null, vals);
  if(opts.includeZero){ lo = Math.min(lo, 0); hi = Math.max(hi, 0); }
  if(opts.hline != null && isFinite(opts.hline)){ lo = Math.min(lo, opts.hline); hi = Math.max(hi, opts.hline); }
  if(hi === lo){ hi = lo + 1; lo = lo - 1; }
  if(opts.yMin == null) lo -= (hi - lo) * 0.14;
  if(opts.yMax == null) hi += (hi - lo) * 0.14;
  var yOf = function(v){ return g.padT + ih - (v - lo) / (hi - lo) * ih; };
  // 平均ライン（表示中の期間の平均値・破線。安静時心拍=平均より下が好調 / HRV=平均より上が好調 2026-07-03）
  if(opts.avgLine && vals.length){
    var av = 0; vals.forEach(function(x){ av += x; }); av /= vals.length;
    if(av >= lo && av <= hi){
      var ya = yOf(av);
      g.x.strokeStyle = "rgba(255,255,255,.28)"; g.x.lineWidth = 1; g.x.setLineDash([5,4]);
      g.x.beginPath(); g.x.moveTo(g.padL, ya); g.x.lineTo(g.W - g.padR, ya); g.x.stroke(); g.x.setLineDash([]);
      var lya = ya + 3;
      g._labels = g._labels || [];
      while(g._labels.some(function(p){ return Math.abs(p - lya) < 11; })) lya += 11;
      lya = Math.min(g.H - 3, Math.max(9, lya));
      g._labels.push(lya);
      g.x.fillStyle = "rgba(255,255,255,.7)"; g.x.font = '10px "JetBrains Mono",monospace';
      g.x.fillText("平均" + (Math.round(av * 10) / 10), g.W - g.padR + 4, lya);
    }
  }
  // 0ライン（範囲内にある時だけ・データの下に描く）
  if(opts.zeroLine && lo <= 0 && hi >= 0){
    var y0 = yOf(0);
    g.x.strokeStyle = "rgba(255,255,255,.3)"; g.x.lineWidth = 1; g.x.setLineDash([2,3]);
    g.x.beginPath(); g.x.moveTo(g.padL, y0); g.x.lineTo(g.W - g.padR, y0); g.x.stroke(); g.x.setLineDash([]);
    g.x.fillStyle = "rgba(255,255,255,.7)"; g.x.font = '10px "JetBrains Mono",monospace';
    g.x.fillText("0", g.W - g.padR + 4, y0 + 3);
  }
  var gap = opts.gap || 3, lastIdx = null, lastVal = null;
  g.x.strokeStyle = color; g.x.lineWidth = 1.6;
  if(opts.dash) g.x.setLineDash(opts.dash);
  g.x.beginPath();
  for(var i = 0; i < n; i++){
    var v = map[dates[i]]; if(v == null || !isFinite(v)) continue;
    var X = xOf(i), Y = yOf(v);
    if(lastIdx != null && (i - lastIdx) <= gap) g.x.lineTo(X, Y); else g.x.moveTo(X, Y);
    lastIdx = i; lastVal = v;
  }
  g.x.stroke(); g.x.setLineDash([]);
  // 横点線1本（目標体重など。範囲内に含めた上で描く）
  if(opts.hline != null && isFinite(opts.hline)){
    var yh = yOf(opts.hline);
    g.x.strokeStyle = "rgba(255,255,255,.45)"; g.x.lineWidth = 1; g.x.setLineDash([2,3]);
    g.x.beginPath(); g.x.moveTo(g.padL, yh); g.x.lineTo(g.W - g.padR, yh); g.x.stroke(); g.x.setLineDash([]);
    g.x.fillStyle = "rgba(255,255,255,.7)"; g.x.font = '10px "JetBrains Mono",monospace';
    g.x.fillText((opts.hlineLabel || "") + (Math.round(opts.hline * 10) / 10), g.padL + 2, Math.max(9, yh - 3));
  }
  // 最大・最小の注記（単独表示タブ用）
  if(opts.annotate && maxI >= 0){
    var ann = function(idx, v, above, tag){
      var X = xOf(idx), Y = yOf(v);
      g.x.fillStyle = "#fff";
      g.x.beginPath(); g.x.arc(X, Y, 2.6, 0, 7); g.x.fill();
      g.x.fillStyle = "rgba(255,255,255,.7)"; g.x.font = '10px "JetBrains Mono",monospace';
      var tx = tag + " " + (Math.round(v * 10) / 10);
      var tw = g.x.measureText(tx).width;
      var TX = Math.max(g.padL, Math.min(X - tw / 2, g.W - g.padR - tw));
      g.x.fillText(tx, TX, above ? Math.max(9, Y - 6) : Math.min(g.H - 3, Y + 13));
    };
    ann(maxI, maxV, true, "max");
    if(minI !== maxI) ann(minI, minV, false, "min");
  }
  if(opts.dots){
    g.x.fillStyle = color;
    for(var i = 0; i < n; i++){ var v = map[dates[i]]; if(v == null || !isFinite(v)) continue;
      g.x.beginPath(); g.x.arc(xOf(i), yOf(v), 1.8, 0, 7); g.x.fill(); }
  }
  if(opts.label !== false && lastVal != null){
    // 右端ラベルの重なり回避（同一レーン内で11px間隔を確保）
    var ly = yOf(lastVal) + 3;
    g._labels = g._labels || [];
    while(g._labels.some(function(p){ return Math.abs(p - ly) < 11; })) ly += 11;
    ly = Math.min(g.H - 3, Math.max(9, ly));
    g._labels.push(ly);
    g.x.fillStyle = color; g.x.font = '10px "JetBrains Mono",monospace';
    g.x.fillText(opts.fmt ? opts.fmt(lastVal) : String(lastVal), g.W - g.padR + 4, ly);
  }
  return true;
}
// イベント帯: レース(赤帯・--red)・離脱(赤の薄帯+分類)・体調申告(赤点)
function drawTlEvents(c, dates, injuries, healthSet, phases, cur){
  var g = tlSetup(c); if(!g) return;
  tlCursor(g, dates.length, cur);
  tlShade(g, dates, injuries);
  var n = dates.length, xOf = tlXOf(g, n);
  g.x.font = '10px "JetBrains Mono",monospace';
  (phases || []).forEach(function(p){
    if(!/^race/.test(p.kind || "")) return;
    if(p.end < dates[0] || p.start > dates[n-1]) return;
    var i0 = 0; while(i0 < n - 1 && dates[i0] < p.start) i0++;
    var i1 = n - 1; while(i1 > 0 && dates[i1] > p.end) i1--;
    g.x.fillStyle = "rgba(239,43,43,.85)"; // レース＝--red（TODAY/FUTURE と統一）
    g.x.fillRect(xOf(i0), 5, Math.max(3, xOf(i1) - xOf(i0)), 5);
    g.x.fillText(p.label || "", Math.max(g.padL, xOf(i0)), 24);
  });
  injuries.forEach(function(inj){
    if((inj.end || dates[n-1]) < dates[0] || inj.start > dates[n-1]) return;
    var i0 = 0; while(i0 < n - 1 && dates[i0] < inj.start) i0++;
    g.x.fillStyle = "#ff8a8a";
    g.x.fillText("離脱:" + inj.category, Math.max(g.padL, Math.min(xOf(i0), g.W - g.padR - 60)), g.H - 10);
  });
  g.x.fillStyle = "#ef2b2b";
  for(var i = 0; i < n; i++){ if(healthSet.has(dates[i])){ g.x.fillRect(xOf(i) - 1, g.H - 5, 3, 3); } }
  if(!(phases || []).length && !injuries.length && !healthSet.size) tlEmpty(g, "この期間のイベントなし");
}

function CondTimeline(){
  var [span, setSpan] = React.useState(90);
  var [st, setSt] = React.useState({ phase:"loading" });
  var [item, setItem] = React.useState(null);        // Hooperタブ: null=合計 / sleep等=単独表示
  var [fit, setFit] = React.useState(null);          // 負荷タブ: null=すべて / "ctl"|"atl"|"tsb"=単独表示
  var [rng, setRng] = React.useState(null);          // トリム範囲 [lo,hi]
  var [cur, setCur] = React.useState(null);          // 選択中の日（全レーン連動カーソル）
  var [guide, setGuide] = React.useState(false);     // 「読み方」ガイドの開閉
  var [form, setForm] = React.useState({ category:"腰", start:"", end:"", note:"" });
  var [busy, setBusy] = React.useState(false);
  var cvE = React.useRef(null), cvH = React.useRef(null), cvT = React.useRef(null),
      cvF = React.useRef(null), cvV = React.useRef(null), cvR = React.useRef(null), cvW = React.useRef(null),
      cvS = React.useRef(null);  // 睡眠時間（timeline API が sleep_h を返すときだけレーンを出す）

  var load = React.useCallback(function(){
    setSt({ phase:"loading" });
    setRng(null); setCur(null);  // 旧spanの選択範囲を持ち越さない（真っ暗バグ対策・2026-07-03）
    fetch("/api/athlete/" + encodeURIComponent(ATH.id) + "/timeline?days=" + span, { credentials:"same-origin", cache:"no-store" })
      .then(function(r){ return r.ok ? r.json() : Promise.reject(new Error("http " + r.status)); })
      .then(function(j){ setSt({ phase:"ok", data:j }); })
      .catch(function(){ setSt({ phase:"error" }); });
  }, [span]);
  React.useEffect(function(){ load(); }, [load]);

  // 連続日付軸 + 各系列のマップ化
  var full = React.useMemo(function(){
    if(st.phase !== "ok") return null;
    var d = st.data, dates = [], cur = d.range.oldest, guard = 0;
    function addD(s){ var x = new Date(s + "T00:00:00Z"); x.setUTCDate(x.getUTCDate() + 1); return x.toISOString().slice(0,10); }
    while(cur <= d.range.newest && guard++ < 200){ dates.push(cur); cur = addD(cur); }
    var hooperSum = {}, hooperItem = {}, health = new Set(), tss = {}, ctl = {}, atl = {}, tsb = {}, hrv = {}, rhr = {}, wt = {}, sleep = {}, hasSleep = false;
    (d.hooper || []).forEach(function(r){ hooperSum[r.date] = r.sum; hooperItem[r.date] = r; if(r.health) health.add(r.date); });
    (d.load || []).forEach(function(r){ tss[r.date] = r.tss; });
    (d.wellness || []).forEach(function(r){ if(r.ctl!=null)ctl[r.date]=r.ctl; if(r.atl!=null)atl[r.date]=r.atl; if(r.tsb!=null)tsb[r.date]=r.tsb; if(r.hrv!=null)hrv[r.date]=r.hrv; if(r.rhr!=null)rhr[r.date]=r.rhr; if(r.sleep_h!=null){ sleep[r.date]=r.sleep_h; hasSleep = true; } });
    (d.weight || []).forEach(function(r){ wt[r.date] = r.kg; });
    var tssArr = dates.map(function(dd){ return tss[dd] || 0; });
    return { dates:dates, hooperSum:hooperSum, hooperItem:hooperItem, health:health, tss:tss, tssArr:tssArr,
             ctl:ctl, atl:atl, tsb:tsb, hrv:hrv, rhr:rhr, wt:wt, sleep:sleep, hasSleep:hasSleep,
             injuries:d.injuries || [], canEdit: !!(d.viewer && d.viewer.can_edit) };
  }, [st]);
  React.useEffect(function(){ if(full){ setRng([0, full.dates.length - 1]); setCur(null); } }, [full]);

  var view = React.useMemo(function(){
    if(!full || !rng) return null;
    var dates = full.dates.slice(rng[0], rng[1] + 1);
    var inRange = function(inj){ return (inj.end || dates[dates.length-1]) >= dates[0] && inj.start <= dates[dates.length-1]; };
    return { dates:dates, injuries: full.injuries.filter(inRange) };
  }, [full, rng]);
  React.useEffect(function(){ setCur(null); }, [rng]);

  // グラフをなぞる/タップ → その日を選択（全レーン連動）
  function lanePoint(e){
    if(!view || !view.dates.length) return;
    var c = e.currentTarget, r = c.getBoundingClientRect();
    var clientX = e.clientX != null ? e.clientX : (e.touches && e.touches[0] ? e.touches[0].clientX : null);
    if(clientX == null) return;
    var n = view.dates.length, plot = Math.max(1, r.width - 8 - 46);
    var i = Math.round((clientX - r.left - 8) / plot * (n - 1));
    setCur(Math.max(0, Math.min(n - 1, i)));
  }

  React.useEffect(function(){
    if(!view || !full) return;
    function redraw(){
      var dates = view.dates, inj = view.injuries, N = dates.length;
      var phases = tlEventsFor(ATH.id);  // レース経路 + 国内レース（選手ごとの出場実績で絞る）
      if(cvE.current) drawTlEvents(cvE.current, dates, inj, full.health, phases, cur);
      if(cvH.current){ var g = tlSetup(cvH.current); if(g){ tlShade(g, dates, inj);
        var any;
        if(item){ // 単独表示: 選んだ項目のみ（1〜7）＋max/min注記＋0ライン
          var m = {}; dates.forEach(function(dd){ var r = full.hooperItem[dd]; if(r && r[item] != null) m[dd] = r[item]; });
          any = tlLine(g, dates, m, TL_COLORS.item, { yMin:0, yMax:7.6, dots:true, annotate:true, zeroLine:true, fmt:function(v){ return v; } });
        } else { // 合計（デフォルト）
          any = tlLine(g, dates, full.hooperSum, TL_COLORS.hooper, { yMin:4, yMax:28, dots:true, fmt:function(v){ return v; } });
        }
        if(!any) tlEmpty(g, "朝チェックの記録なし");
        tlCursor(g, N, cur); } }
      if(cvT.current){ var g2 = tlSetup(cvT.current); if(g2){ tlShade(g2, dates, inj);
        var n = dates.length, xOf = tlXOf(g2, n), vals = dates.map(function(dd){ return full.tss[dd] || 0; });
        var mx = Math.max.apply(null, vals);
        if(mx > 0){ var ih = g2.H - g2.padT - g2.padB, bw = Math.max(1.5, (g2.W - g2.padL - g2.padR) / n - 1.5);
          g2.x.fillStyle = TL_COLORS.tss;
          for(var i = 0; i < n; i++){ if(!vals[i]) continue; var h = vals[i] / mx * ih;
            g2.x.fillRect(xOf(i) - bw / 2, g2.padT + ih - h, bw, h); }
          g2.x.fillStyle = "rgba(255,255,255,.7)"; g2.x.font = '10px "JetBrains Mono",monospace';
          g2.x.fillText("max " + Math.round(mx), g2.W - g2.padR + 4, 14);
        } else tlEmpty(g2, "計測なし（intervals未連携/記録なし）");
        tlCursor(g2, N, cur); } }
      if(cvF.current){ var g3 = tlSetup(cvF.current); if(g3){ tlShade(g3, dates, inj);
        var ok3;
        if(fit){ // 単独表示: 選んだ指標のみ＋max/min注記＋0ライン（縦軸に0を含める）
          var col = fit === "ctl" ? TL_COLORS.ctl : fit === "atl" ? TL_COLORS.atl : TL_COLORS.tsb;
          ok3 = tlLine(g3, dates, full[fit], col, { includeZero:true, zeroLine:true, annotate:true, dots:true, fmt:function(v){ return fit.toUpperCase() + " " + v; } });
        } else { // すべて（デフォルト）
          var a = tlLine(g3, dates, full.ctl, TL_COLORS.ctl, { fmt:function(v){ return "CTL " + v; } });
          var b = tlLine(g3, dates, full.atl, TL_COLORS.atl, { dash:[4,3], fmt:function(v){ return "ATL " + v; } });
          var c = tlLine(g3, dates, full.tsb, TL_COLORS.tsb, { fmt:function(v){ return "TSB " + v; } });
          ok3 = a || b || c;
        }
        if(!ok3) tlEmpty(g3, "計測なし");
        tlCursor(g3, N, cur); } }
      if(cvV.current){ var g4 = tlSetup(cvV.current); if(g4){ tlShade(g4, dates, inj);
        if(!tlLine(g4, dates, full.hrv, TL_COLORS.hrv, { dots:true, avgLine:true })) tlEmpty(g4, "HRV計測なし（対応デバイスのみ）");
        tlCursor(g4, N, cur); } }
      if(cvR.current){ var g5 = tlSetup(cvR.current); if(g5){ tlShade(g5, dates, inj);
        if(!tlLine(g5, dates, full.rhr, TL_COLORS.rhr, { dots:true, avgLine:true })) tlEmpty(g5, "安静時心拍の計測なし");
        tlCursor(g5, N, cur); } }
      if(cvS.current){ var g7 = tlSetup(cvS.current); if(g7){ tlShade(g7, dates, inj);
        if(!tlLine(g7, dates, full.sleep, TL_COLORS.hrv, { dots:true, avgLine:true, fmt:function(v){ return v.toFixed(1) + "h"; } })) tlEmpty(g7, "計測なし");
        tlCursor(g7, N, cur); } }
      if(cvW.current){ var g6 = tlSetup(cvW.current); if(g6){ tlShade(g6, dates, inj);
        var wtTarget = weightTargetOf();  // 目標体重（ATHLETE.weight_target）。null なら点線なし
        if(!tlLine(g6, dates, full.wt, TL_COLORS.wt, { dots:true, gap:8, hline:wtTarget, hlineLabel:"目標 ", fmt:function(v){ return v.toFixed(1); } })) tlEmpty(g6, "体重の記録なし（" + TAB_LABEL.today + "で入力）");
        tlCursor(g6, N, cur); } }
    }
    redraw();
    window.addEventListener("resize", redraw);
    return function(){ window.removeEventListener("resize", redraw); };
  }, [view, full, item, fit, cur]);

  function postInjury(payload, after){
    setBusy(true);
    fetch("/api/athlete/" + encodeURIComponent(ATH.id) + "/injury", {
      method:"POST", credentials:"same-origin",
      headers:{ "content-type":"application/json" }, body: JSON.stringify(payload),
    }).then(function(r){ return r.json(); })
      .then(function(j){ setBusy(false); if(j && j.ok){ if(after) after(); load(); } else alert("保存できませんでした: " + ((j && j.error) || "")); })
      .catch(function(){ setBusy(false); alert("通信エラー"); });
  }

  var dates = view ? view.dates : [];
  var fmtD = function(s){ return s ? (+s.slice(5,7)) + "/" + (+s.slice(8,10)) : ""; };
  var ITEMS = [["","合計"],["sleep","睡眠"],["fatigue","疲労"],["stress","ストレス"],["soreness","筋肉痛"]];
  var FITS  = [["","すべて"],["ctl","CTL"],["atl","ATL"],["tsb","TSB"]];
  var SPANS_TL = [[30,"1M"],[90,"3M"],[180,"6M"]];

  return (
    <div className="pb-cond-card pb-wake-card pb-tl">
      <div className="pb-eyebrow pb-eyebrow-gold">コンディション年表 ／ TIMELINE</div>
      <div className="pb-tl-top">
        <div className="pb-zg-spans pb-mono" style={{ margin:0 }}>
          {SPANS_TL.map(function(s){ return <button key={s[0]} type="button" className={"pb-zg-span" + (span === s[0] ? " pb-zg-span-on" : "")} onClick={function(){ setSpan(s[0]); }}>{s[1]}</button>; })}
        </div>
        <button type="button" className="pb-tl-guide-btn pb-mono" onClick={function(){ setGuide(!guide); }}>{guide ? "✕ 閉じる" : "？ この画面の読み方"}</button>
      </div>
      {guide && (
        <div className="pb-tl-guide">
          <div><b>つながり</b>：練習する（日次TSS）→ 数日は疲れが出る（ATL↑・Hooper↑・安静時心拍↑・HRV↓）→ 続けると体力になる（CTL↑）。</div>
          <div><b>TSB 調子</b>＝体力(CTL)−疲労(ATL)。ふだんはマイナスでOK（鍛えている証拠）。レース週にプラスへ持っていくのがテーパー。</div>
          <div><b>危ないサイン</b>：TSSの急増＋Hooper↑＋HRV↓＋安静時心拍↑が<b>同時に続く</b>形。赤帯（離脱）の手前でよく見られる並びです。</div>
          <div><b>使い方</b>：グラフを指でなぞって日付を合わせ、レーンを縦に見比べる。「この形の後に何が起きたか」を言葉にして残すのがコツ。</div>
        </div>
      )}

      {st.phase === "loading" && <div className="pb-tl-loading"><span className="pb-mono">Loading…</span></div>}
      {st.phase === "error" && <div className="pb-cond-empty">取得できませんでした。再読み込みしてください。</div>}

      {st.phase === "ok" && full && view && (
        <React.Fragment>
          <div className="pb-tl-trimlab pb-mono">▼ 金の帯＝下の「日次TSS」と同じもの（全期間ぶんの縮小版）。白い枠をドラッグして、見たい期間を切り取る</div>
          {/* 帯の色は日次TSSレーンと同じ金に統一（drawTrimFilmのz2スロット=金を使う） */}
          <ZoneTrim n={full.dates.length} lo={rng[0]} hi={rng[1]} dates={full.dates}
            full={{ dates:full.dates, z1:full.dates.map(function(){ return 0; }), z2:full.tssArr, z3:full.dates.map(function(){ return 0; }) }}
            onChange={function(a,b){ setRng([a,b]); }} />

          {cur != null && dates[cur] ? (function(){
            var d0 = dates[cur];
            var injHit = view.injuries.filter(function(j){ return j.start <= d0 && (j.end || "9999-12-31") >= d0; })[0];
            var vv = function(v, f){ return v == null ? "—" : (f ? f(v) : v); };
            return (
              <div className="pb-tl-cursorbar pb-mono">
                <b>{fmtD(d0)}</b>
                {injHit ? <span className="pb-tl-cinj">離脱:{injHit.category}</span> : null}
                <span>Hooper <b>{vv(full.hooperSum[d0])}</b></span>
                <span>TSS <b>{vv(full.tss[d0])}</b></span>
                <span>TSB <b>{vv(full.tsb[d0])}</b></span>
                <span>HRV <b>{vv(full.hrv[d0])}</b></span>
                <span>安静HR <b>{vv(full.rhr[d0])}</b></span>
                {full.hasSleep ? <span>睡眠 <b>{vv(full.sleep[d0], function(x){ return x.toFixed(1) + "h"; })}</b></span> : null}
                <span>体重 <b>{vv(full.wt[d0], function(x){ return x.toFixed(1); })}</b></span>
                <button type="button" className="pb-tl-cx" onClick={function(){ setCur(null); }} aria-label="閉じる">✕</button>
              </div>
            );
          })() : (
            <div className="pb-tl-hint pb-mono">💡 グラフを指でなぞる／タップすると、その日の全部の値がここに並びます</div>
          )}

          <div className="pb-tl-lane"><div className="pb-tl-lab pb-mono">イベント（<span style={{ color:"var(--red)" }}>▬ レース</span>・<span style={{ color:"#ff8a8a" }}>▬ 離脱</span>・<span style={{ color:"#ef2b2b" }}>▪ 体調申告</span>）</div>
            <canvas ref={cvE} className="pb-tl-cv" style={{ height:"36px" }} onPointerMove={lanePoint} onPointerDown={lanePoint} /></div>
          <div className="pb-tl-lane">
            <div className="pb-tl-lab pb-mono">HOOPER 朝の体調スコア（高い=しんどい）
              {(function(){ var t = tlTrend(full.hooperSum, full.dates); if(t == null) return null;
                var cls = t >= 1 ? "bad" : t <= -1 ? "good" : "flat";
                var tx = t >= 1 ? "▲ 上がり気味・注意" : t <= -1 ? "▼ 落ち着いてきた" : "→ 横ばい";
                return <span className={"pb-tl-chip " + cls}>{tx}</span>; })()}
              <span className="pb-tl-items">{ITEMS.map(function(it){
                var on = (item || "") === it[0];
                return <button key={it[0] || "sum"} type="button" className={"pb-zg-span" + (on ? " pb-zg-span-on" : "")}
                  onClick={function(){ setItem(it[0] || null); }}>{it[1]}</button>; })}</span>
            </div>
            <canvas ref={cvH} className="pb-tl-cv" style={{ height:"84px" }} onPointerMove={lanePoint} onPointerDown={lanePoint} /></div>
          <div className="pb-tl-lane"><div className="pb-tl-lab pb-mono">日次TSS [TSS] ＝ その日の練習のきつさ×時間（棒が高い=負荷が大きい日）</div>
            <canvas ref={cvT} className="pb-tl-cv" style={{ height:"64px" }} onPointerMove={lanePoint} onPointerDown={lanePoint} /></div>
          <div className="pb-tl-lane">
            <div className="pb-tl-lab pb-mono"><span style={{ color:"#4ec77f" }}>― CTL 体力</span>　<span style={{ color:"var(--warn)" }}>┄ ATL 疲労</span>　<span style={{ color:"#7fb0ff" }}>― TSB 調子（＋=フレッシュ）</span>
              <span className="pb-tl-items">{FITS.map(function(f){
                var on = (fit || "") === f[0];
                return <button key={f[0] || "all"} type="button" className={"pb-zg-span" + (on ? " pb-zg-span-on" : "")}
                  onClick={function(){ setFit(f[0] || null); }}>{f[1]}</button>; })}</span>
            </div>
            <canvas ref={cvF} className="pb-tl-cv" style={{ height:"84px" }} onPointerMove={lanePoint} onPointerDown={lanePoint} /></div>
          <div className="pb-tl-lane"><div className="pb-tl-lab pb-mono">HRV [ms] 心拍のゆらぎ（高い=回復できている・点線=期間平均で上が好調）
            {(function(){ var t = tlTrend(full.hrv, full.dates); if(t == null) return null;
              var cls = t >= 2 ? "good" : t <= -2 ? "bad" : "flat";
              var tx = t >= 2 ? "▲ 回復いい方向" : t <= -2 ? "▼ 低下・要観察" : "→ 横ばい";
              return <span className={"pb-tl-chip " + cls}>{tx}</span>; })()}</div>
            <canvas ref={cvV} className="pb-tl-cv" style={{ height:"56px" }} onPointerMove={lanePoint} onPointerDown={lanePoint} /></div>
          <div className="pb-tl-lane"><div className="pb-tl-lab pb-mono">安静時心拍 [bpm]（低い=回復できている・点線=期間平均で下が好調）
            {(function(){ var t = tlTrend(full.rhr, full.dates); if(t == null) return null;
              var cls = t <= -1 ? "good" : t >= 1 ? "bad" : "flat";
              var tx = t <= -1 ? "▼ 落ち着いている" : t >= 1 ? "▲ 上がり気味・要観察" : "→ 横ばい";
              return <span className={"pb-tl-chip " + cls}>{tx}</span>; })()}</div>
            <canvas ref={cvR} className="pb-tl-cv" style={{ height:"56px" }} onPointerMove={lanePoint} onPointerDown={lanePoint} /></div>
          {full.hasSleep && (
            <div className="pb-tl-lane"><div className="pb-tl-lab pb-mono">睡眠 [h]（点線=期間平均・無い日は計測なし）</div>
              <canvas ref={cvS} className="pb-tl-cv" style={{ height:"56px" }} onPointerMove={lanePoint} onPointerDown={lanePoint} /></div>
          )}
          <div className="pb-tl-lane"><div className="pb-tl-lab pb-mono">体重 [kg]{weightTargetOf() != null ? "（点線=目標 " + weightTargetOf().toFixed(1) + "）" : ""}</div>
            <canvas ref={cvW} className="pb-tl-cv" style={{ height:"56px" }} onPointerMove={lanePoint} onPointerDown={lanePoint} /></div>
          <div className="pb-tl-axis pb-mono"><span>{fmtD(dates[0])}</span><span>{fmtD(dates[Math.floor((dates.length - 1) / 2)])}</span><span>{fmtD(dates[dates.length - 1])}</span></div>

          {full.canEdit && (
            <div className="pb-tl-form">
              <div className="pb-eyebrow" style={{ marginBottom:6 }}>離脱の記録（コーチ・トレーナーのみ表示）</div>
              <div className="pb-tl-frow">
                <select className="pb-tl-in" value={form.category} onChange={function(e){ setForm(Object.assign({}, form, { category:e.target.value })); }}>
                  {TL_CATS.map(function(cx){ return <option key={cx} value={cx}>{cx}</option>; })}
                </select>
                <input className="pb-tl-in" type="date" value={form.start} onChange={function(e){ setForm(Object.assign({}, form, { start:e.target.value })); }} />
                <span className="pb-tl-tilde pb-mono">〜</span>
                <input className="pb-tl-in" type="date" value={form.end} onChange={function(e){ setForm(Object.assign({}, form, { end:e.target.value })); }} />
                <input className="pb-tl-in pb-tl-note" type="text" maxLength="200" placeholder="メモ（任意）" value={form.note} onChange={function(e){ setForm(Object.assign({}, form, { note:e.target.value })); }} />
                <button type="button" className="pb-trim-reset" disabled={busy || !form.start}
                  onClick={function(){ postInjury({ action:"create", start_date:form.start, end_date:form.end || null, category:form.category, note:form.note }, function(){ setForm({ category:"腰", start:"", end:"", note:"" }); }); }}>記録</button>
              </div>
              <div className="pb-tl-fhint">終了日が未定（現在も離脱中）の場合は終了日を空のまま記録 → 復帰したら「今日で終了」</div>
              {full.injuries.length > 0 && (
                <div className="pb-tl-list">
                  {full.injuries.map(function(inj){
                    return <div key={inj.id} className="pb-tl-lrow pb-mono">
                      <span className="pb-tl-lcat">{inj.category}</span>
                      <span>{fmtD(inj.start)} 〜 {inj.end ? fmtD(inj.end) : "継続中"}</span>
                      {inj.note ? <span className="pb-tl-lnote">{inj.note}</span> : null}
                      {!inj.end && <button type="button" className="pb-trim-reset" disabled={busy}
                        onClick={function(){ postInjury({ action:"update", id:inj.id, end_date: new Date(Date.now() + 9 * 3600e3).toISOString().slice(0, 10) }); }}>今日で終了</button>}
                      <button type="button" className="pb-trim-reset pb-tl-del" disabled={busy}
                        onClick={function(){ if(confirm("この離脱記録を削除しますか？")) postInjury({ action:"delete", id:inj.id }); }}>削除</button>
                    </div>; })}
                </div>
              )}
            </div>
          )}
          <div className="pb-cond-foot pb-mono">赤帯=離脱期間・赤点=体調申告。欠測は線を切って表示（推定で埋めない）。この画面は原因を断定せず、仮説を立てるための道具。</div>
        </React.Fragment>
      )}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────────────────
// ── 朝Hooper未入力なら、その日の初回表示でポップアップ（入力促進・2026-06-16） ──
//  ・本人(athlete)・当日未入力・本日まだ「あとで」していない 時だけ最前面に出す
//  ・記録 or「あとで」で閉じる（「あとで」はその日もう自動表示しない）
// 朝Hooper 入力ポップアップ（コンセプト "RISING"：昇る日の丸＋ゴールド・2026-06-18 hi-fi化）
//  デザインハンドオフ（Crew Japan / RISING）に忠実。本人・当日未入力・未スキップ時のみ全面表示。
const RS_SCALES = [
  ["sleep", "睡眠の質", "1ぐっすり↔7眠れず"],
  ["fatigue", "疲労感", "1元気↔7疲労"],
  ["stress", "ストレス", "1リラックス↔7極度"],
  ["soreness", "筋肉痛・張り", "1なし↔7ひどい"],
];
function HooperPopup() {
  const [state, setState] = React.useState({ phase: "loading" });
  const [open, setOpen] = React.useState(false);
  const [done, setDone] = React.useState(false);
  const [vals, setVals] = React.useState({ sleep: null, fatigue: null, stress: null, soreness: null });
  const [illness, setIllness] = React.useState("none");
  const [notes, setNotes] = React.useState("");
  const [temp, setTemp] = React.useState("");   // 体温（「あり」のときだけ・任意）
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);
  const sparksRef = React.useRef(null);
  React.useEffect(() => {
    if (IS_EMBED) return;
    let dead = false;
    fetch("/api/checkin/status?id=" + encodeURIComponent(ATH.id), { credentials: "same-origin", cache: "no-store" })
      .then((r) => (r.ok ? r.json() : null))
      .then((j) => {
        if (dead || !j) return;
        setState({ phase: "ok", data: j });
        // 本人ページ以外（コーチ等が選手ページを開いた場合）は入力ポップアップを出さない
        const notOwnerCoach = j.viewer_role && j.viewer_role !== "athlete" && !j.is_owner;
        let skipped = false;
        try { skipped = localStorage.getItem("pb-hooper-skip-" + j.date) === "1"; } catch (e) {}
        if (!notOwnerCoach && !j.today_wellness && !skipped) setOpen(true);
      })
      .catch(() => {});
    return () => { dead = true; };
  }, []);

  // 金色の花火（日の出が赤く染まりきった後、選手名の着火に同期して打ち上げ）
  React.useEffect(() => {
    if (!open || done) return;
    const reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (reduce) return;
    const layer = sparksRef.current;
    if (!layer) return;
    const timers = [];
    const nChars = ((ATH.jp || "").replace(/\s/g, "").length) || 4;
    const IGN = 4050; // 染まりきった直後＝選手名の着火開始(ms)
    function burst(cx, cy, n, spread, big) {
      for (let i = 0; i < n; i++) {
        const a = (Math.PI * 2 * i) / n + Math.random() * 0.5;
        const dist = spread * (0.5 + Math.random() * 0.85);
        const tx = Math.cos(a) * dist, ty = Math.sin(a) * dist + dist * 0.35;
        const sz = (big ? 6 : 3) + Math.random() * (big ? 7 : 4);
        const s = document.createElement("div");
        s.style.cssText = "position:absolute;left:" + cx + "px;top:" + cy + "px;width:" + sz + "px;height:" + sz + "px;margin:" + (-sz / 2) + "px;border-radius:50%;pointer-events:none;background:radial-gradient(circle,#fff 0%,#ffe9a8 38%,#caa24a 70%,rgba(202,162,74,0) 72%);box-shadow:0 0 8px rgba(255,230,163,.9);--tx:" + tx.toFixed(1) + "px;--ty:" + ty.toFixed(1) + "px;animation:pb-sparkfly " + (0.8 + Math.random() * 0.6).toFixed(2) + "s cubic-bezier(.12,.6,.3,1) forwards;";
        layer.appendChild(s);
        timers.push(setTimeout(function () { s.remove(); }, 1700));
      }
      const ring = document.createElement("div"); const rs = big ? 110 : 40;
      ring.style.cssText = "position:absolute;left:" + cx + "px;top:" + cy + "px;width:" + rs + "px;height:" + rs + "px;margin:" + (-rs / 2) + "px;border-radius:50%;pointer-events:none;border:1.5px solid rgba(231,207,140," + (big ? 0.55 : 0.4) + ");animation:pb-ringpop " + (big ? 0.9 : 0.7) + "s ease-out forwards;";
      layer.appendChild(ring);
      timers.push(setTimeout(function () { ring.remove(); }, 1100));
    }
    const W = layer.clientWidth || 320, cy = 100, T = IGN + nChars * 130;
    timers.push(setTimeout(function () { burst(W * 0.5, cy, 16, 72, false); }, IGN + 120));
    // フィナーレ（5発同時）
    timers.push(setTimeout(function () {
      burst(W * 0.5, cy - 8, 34, 130, true);
      burst(W * 0.18, cy + 4, 22, 100, true);
      burst(W * 0.82, cy + 4, 22, 100, true);
      burst(W * 0.34, cy - 24, 18, 95, true);
      burst(W * 0.66, cy - 24, 18, 95, true);
    }, T + 260));
    // シャワー（6発・時間差）
    timers.push(setTimeout(function () { for (let i = 0; i < 6; i++) { (function (k) { timers.push(setTimeout(function () { burst(W * (0.12 + k * 0.15), cy - 12 + (k % 2) * 26, 18, 100, true); }, k * 100)); })(i); } }, T + 560));
    // ラストの大玉（締め・数発）
    timers.push(setTimeout(function () {
      burst(W * 0.5, cy - 10, 46, 155, true);
      burst(W * 0.26, cy + 8, 26, 115, true);
      burst(W * 0.74, cy + 8, 26, 115, true);
      timers.push(setTimeout(function () { burst(W * 0.5, cy - 2, 30, 130, true); }, 220));
    }, T + 1350));
    return function () { timers.forEach(function (t) { clearTimeout(t); }); };
  }, [open, done]);

  if (!open || state.phase !== "ok") return null;

  const dateLabel = (state.data.date || "").replace(/-/g, " . ");
  const allSet = RS_SCALES.every((s) => vals[s[0]] != null);
  const stk = state.data.streak || {};
  const streakLine = stk.ending_yesterday > 0
    ? "🔥 " + stk.ending_yesterday + "日連続中 — 今日の記録で " + (stk.ending_yesterday + 1) + "日目"
    : (stk.total > 0 ? "今日からまた1日目（練習前の記録 累計 " + stk.total + "日は消えません）" : "今日が記録の1日目");
  function later() {
    try { localStorage.setItem("pb-hooper-skip-" + state.data.date, "1"); } catch (e) {}
    setOpen(false);
  }
  function save() {
    if (busy || !allSet) return;
    setBusy(true); setErr(null);
    const bodyTemp = illness === "yes" ? parseTemp(temp) : null;
    fetch("/api/checkin/hooper", {
      method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ sleep: vals.sleep, fatigue: vals.fatigue, stress: vals.stress, soreness: vals.soreness, has_health_issue: illness === "yes", health_notes: illness === "yes" ? (notes.trim() || null) : null, body_temp: bodyTemp }),
    })
      .then(jsonOrThrow)
      .then((j) => {
        // カード側（HooperCard / CheckIn の履歴）へ通知 — 再取得せず detail で更新してもらう
        emitHooperRecorded({
          date: j.date || state.data.date,
          sleep: vals.sleep, fatigue: vals.fatigue, stress: vals.stress, soreness: vals.soreness,
          has_health_issue: illness === "yes", notes: illness === "yes" ? (notes.trim() || null) : null, body_temp: bodyTemp,
          is_pre_exercise: !!j.is_pre_exercise,
          streak: { current: j.streak_after, ending_yesterday: j.streak_ending_yesterday, total: j.total_recorded_days, recorded_today: !!j.is_pre_exercise },
        });
        setDone(true); setTimeout(() => setOpen(false), 2000);
      })
      .catch((e) => setErr(failMsg(e)))
      .finally(() => setBusy(false));
  }

  return (
    <div className="pb-hpop-ov" role="dialog" aria-modal="true">
      <div className="pb-hpop">
        <div className="pb-rs-horizon" aria-hidden="true">
          <div className="pb-rs-riser"><div className="pb-rs-sun"></div></div>
          <div className="pb-rs-sparks" ref={sparksRef}></div>
          <div className="pb-rs-wm">Crew Japan</div>
          {(function () {
            var jpP = (ATH.jp || "").split(/\s+/).filter(Boolean);
            var enP = (ATH.en || "").split(/[·・]/).map(function (s) { return s.trim(); }).filter(Boolean);
            if (jpP.length === 2 && enP.length === 2) {
              var parts = [{ jp: jpP[0], en: enP[1] }, { jp: jpP[1], en: enP[0] }];  // 苗字（→姓ローマ字）, 名前（→名ローマ字）
              var ci = 0;
              return (
                <div className="pb-rs-name">
                  <div className="pb-rs-cj" style={{ animationDelay: "4.05s" }}>Crew Japan</div>
                  <div className="pb-rs-row2">
                  {parts.map(function (p, pi) {
                    var start = ci; ci += p.jp.length;
                    return (
                      <div className="pb-rs-part" key={pi}>
                        <div className="pb-rs-pjp">{p.jp.split("").map(function (c, k) { return <span key={k} style={{ animationDelay: (4.05 + (start + k) * 0.13 + pi * 0.12) + "s" }}>{c}</span>; })}</div>
                        <div className="pb-rs-pen" style={{ animationDelay: (4.05 + start * 0.13 + pi * 0.12) + "s" }}>{p.en}</div>
                      </div>
                    );
                  })}
                  </div>
                </div>
              );
            }
            return (
              <div className="pb-rs-name">
                <div className="pb-rs-cj" style={{ animationDelay: "4.05s" }}>Crew Japan</div>
                <div className="pb-rs-nm-jp">{(ATH.jp || "").split("").map(function (c, i) { return c === " " ? <span key={i} className="sp"> </span> : <span key={i} style={{ animationDelay: (4.05 + i * 0.13) + "s" }}>{c}</span>; })}</div>
                <div className="pb-rs-nm-en" style={{ animationDelay: (4.05 + (ATH.jp || "").length * 0.13 + 0.15) + "s" }}>{ATH.en}</div>
              </div>
            );
          })()}
          <div className="pb-rs-hline"></div>
        </div>
        {done ? (
          <div className="pb-rs-done">
            <div className="pb-rs-check">✓</div>
            <div className="pb-rs-msg">今日のコンディションを記録できました</div>
          </div>
        ) : (
          <React.Fragment>
            <div className="pb-rs-head">
              <div className="pb-rs-date">{dateLabel}</div>
              <div className="pb-rs-title">おはようございます</div>
              <div className="pb-rs-sub">今朝のコンディションを記録しましょう（約10秒）。</div>
              <div className="pb-rs-streak">{streakLine}</div>
            </div>
            <div className="pb-rs-form">
              {RS_SCALES.map(([k, label, hint]) => (
                <div className="pb-rs-row" key={k}>
                  <div className="pb-rs-rowhead"><span className="pb-rs-label">{label}{k === "stress" && <InfoDot k="stress" />}</span><span className="pb-rs-hint">{hint}</span></div>
                  <div className="pb-rs-scale">
                    <span className="pb-hooper-ends">良い</span>
                    {[1, 2, 3, 4, 5, 6, 7].map((n) => (
                      <div key={n} className={"pb-rs-chip" + (vals[k] === n ? " pb-rs-on" : "")} onClick={() => setVals((v) => Object.assign({}, v, { [k]: n }))}>{n}</div>
                    ))}
                    <span className="pb-hooper-ends">悪い</span>
                  </div>
                </div>
              ))}
              <div className="pb-rs-toggrow">
                <span className="pb-rs-label">熱・咳・ケガなど</span>
                <div className={"pb-rs-tog" + (illness === "none" ? " pb-rs-on" : "")} onClick={() => setIllness("none")}>なし</div>
                <div className={"pb-rs-tog" + (illness === "yes" ? " pb-rs-on" : "")} onClick={() => setIllness("yes")}>あり</div>
              </div>
              {illness === "yes" && (
                <textarea className="pb-rs-notes" rows={2} maxLength={500} placeholder="症状メモ（例: のど痛い・頭痛・右肩のハリ）" value={notes} onChange={(e) => setNotes(e.target.value)} />
              )}
              {illness === "yes" && <TempRow value={temp} onChange={setTemp} />}
              {illness === "yes" && <ContactBtn url={state.data.contact_url || null} />}
              {err && <div className="pb-rs-err">{err}</div>}
            </div>
            <div className="pb-rs-foot">
              {!allSet && <div className="pb-rs-need">4項目をタップして選んでください</div>}
              <div className={"pb-rs-save" + (busy ? " pb-rs-busy" : "") + (allSet ? "" : " pb-rs-dis")} onClick={save}>{busy ? "記録中…" : "記録する"}</div>
              <div className="pb-rs-later" onClick={later}>あとで（今日はもう表示しない）</div>
            </div>
          </React.Fragment>
        )}
      </div>
    </div>
  );
}

function App() {
  const [tab, setTab] = useTab();
  const today = useToday();
  useLiveMenu(((ATH.groups || []).concat([ATH.id])).join(","));

  return (
    <div className="pb-app">
      {!IS_EMBED && <TopBar tab={tab} setTab={setTab} today={today} />}
      <div className="pb-main">
        {tab === "cover" && <Cover today={today} setTab={setTab} />}
        {tab === "today" && <Today today={today} />}
        {tab === "course" && <Course today={today} />}
        {tab === "wake" && <Wake />}
        {tab === "grid" && <Grid />}
        {tab === "tuning" && <Tuning />}
        {tab === "hall" && <Hall />}
      </div>
      <SCModal />
      {!IS_EMBED && <HooperPopup />}
    </div>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(<App />);
