import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from "https://esm.sh/react@18.3.1";
import { createRoot } from "https://esm.sh/react-dom@18.3.1/client";
import {
  BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
  CartesianGrid, Legend, LineChart, Line, ReferenceLine,
} from "https://esm.sh/recharts@2.12.7?deps=react@18.3.1,react-dom@18.3.1";

/* ============================================================
   PRACTICE DESK — a writing surface for swim coaches
   ============================================================ */

const APP_VERSION = "Practice Desk 3.0";

const CSS = `
@import url('https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500;600&display=swap');

.pd {
  --disp:'Barlow Condensed',ui-sans-serif,system-ui,sans-serif;
  --body:'Inter',ui-sans-serif,system-ui,sans-serif;
  --mono:'IBM Plex Mono',ui-monospace,'SF Mono',Menlo,monospace;
  /* dark is the default deck at night */
  --bg:#061F26; --bg2:#082B34; --panel:#0B2F39; --raise:#10404D;
  --line:#1B5A69; --soft:#0F3742; --track:#123E4A; --field:#082830;
  --hover:#155364; --sel:#0F4351; --focusbg:#0A343F; --flash:#0E4351;
  --ink:#E8F4F2; --muted:#87ABB3; --faint:#5E8892; --ghost:#3F6772;
  --aqua:#4FD3C4; --clock:#FF4438; --clock2:#E13A2F;
  --ring:rgba(255,255,255,.16); --scrim:rgba(3,15,19,.9); --float:rgba(0,0,0,.55);
  background:var(--bg); color:var(--ink); font-family:var(--body);
  min-height:100vh; font-size:14px; -webkit-font-smoothing:antialiased;
}
.pd[data-theme="light"] {
  --bg:#F2F6F7; --bg2:#FFFFFF; --panel:#FFFFFF; --raise:#EDF3F4;
  --line:#D2DEE1; --soft:#E6EDEF; --track:#E1EAEC; --field:#F7FAFB;
  --hover:#DEE9EC; --sel:#DDF0ED; --focusbg:#F0F8F8; --flash:#DDF0ED;
  --ink:#0D2B32; --muted:#5C777F; --faint:#7C959C; --ghost:#A6BBC1;
  --aqua:#0C8878; --clock:#CE3327; --clock2:#B22A20;
  --ring:rgba(13,43,50,.22); --scrim:rgba(210,222,225,.86); --float:rgba(13,43,50,.16);
}
.pd *{box-sizing:border-box;}
.pd button{font-family:inherit;color:inherit;cursor:pointer;}
.pd input,.pd textarea,.pd select{font-family:inherit;color:inherit;}
.pd :focus-visible{outline:2px solid var(--aqua);outline-offset:2px;}

/* ---- shell ---- */
.pd-top{display:flex;align-items:flex-end;gap:22px;padding:14px 20px 0;
  border-bottom:1px solid var(--line);background:linear-gradient(180deg,var(--bg2),var(--bg));
  position:sticky;top:0;z-index:20;flex-wrap:wrap;}
.pd-mark{font-family:var(--disp);font-weight:700;font-size:24px;letter-spacing:.14em;
  text-transform:uppercase;line-height:1;padding-bottom:12px;white-space:nowrap;}
.pd-mark em{font-style:normal;color:var(--clock);}
.pd-topseason{font-family:var(--disp);font-size:14px;letter-spacing:.16em;text-transform:uppercase;
  color:var(--muted);padding-bottom:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
  max-width:34vw;}
.pd-tabs{display:flex;gap:2px;margin-left:auto;}
.pd-tab{background:none;border:0;border-bottom:2px solid transparent;padding:8px 16px 10px;
  font-family:var(--disp);font-size:16px;letter-spacing:.1em;text-transform:uppercase;
  color:var(--muted);font-weight:600;}
.pd-tab:hover{color:var(--ink);}
.pd-tab[data-on="1"]{color:var(--ink);border-bottom-color:var(--clock);}

/* ---- generic ---- */
.pd-wrap{padding:18px 20px 60px;max-width:1500px;margin:0 auto;}
.pd-card{background:var(--panel);border:1px solid var(--line);border-radius:10px;}
.pd-eyebrow{font-family:var(--disp);font-size:12px;letter-spacing:.18em;text-transform:uppercase;
  color:var(--muted);font-weight:600;}
.pd-btn{background:var(--raise);border:1px solid var(--line);border-radius:6px;padding:7px 12px;
  font-size:12px;font-weight:500;letter-spacing:.02em;}
.pd-btn:hover{background:var(--hover);}
.pd-btn[data-key="1"]{background:var(--clock);border-color:var(--clock);color:#fff;font-weight:600;}
.pd-btn[data-key="1"]:hover{background:var(--clock2);}
.pd-btn[data-ghost="1"]{background:transparent;}
.pd-btn:disabled{opacity:.4;cursor:not-allowed;}
.pd-in{background:var(--field);border:1px solid var(--line);border-radius:6px;padding:7px 10px;
  font-size:13px;width:100%;}
.pd-in::placeholder{color:var(--faint);}
.pd-num{font-family:var(--mono);font-variant-numeric:tabular-nums;}

/* ---- write layout ---- */
.pd-grid{display:grid;grid-template-columns:minmax(0,1fr) 310px;gap:14px;align-items:start;}
@media(max-width:1080px){.pd-grid{grid-template-columns:1fr;}}
.pd-col{display:grid;gap:12px;min-width:0;}

/* ---- saved set strip ---- */
.pd-strip{display:flex;align-items:center;gap:10px;padding:7px 9px;min-width:0;}
.pd-strip select{flex:none;width:132px;padding:6px 8px;font-size:12px;font-weight:500;
  background:var(--field);border:1px solid var(--line);border-radius:6px;}
.pd-chips{display:flex;gap:6px;overflow-x:auto;flex:1;min-width:0;scrollbar-width:thin;}
.pd-chips::-webkit-scrollbar{height:5px;}
.pd-chips::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
.pd-snip{flex:none;white-space:nowrap;background:var(--field);border:1px solid var(--line);border-radius:6px;
  padding:6px 10px;font-size:12.5px;display:flex;gap:8px;align-items:center;}
.pd-snip:hover{border-color:var(--aqua);background:var(--hover);}
.pd-snip span{color:var(--faint);font-family:var(--mono);font-size:10.5px;}
.pd-strip .pd-none{color:var(--muted);font-size:12px;padding:6px 2px;}

/* ---- editor ---- */
.pd-edhead{display:flex;gap:8px;padding:10px 12px;border-bottom:1px solid var(--line);
  align-items:center;flex-wrap:wrap;}
.pd-edbody{display:flex;}
.pd-gut{width:44px;flex:none;border-right:1px solid var(--soft);overflow:hidden;padding:14px 0;
  font-family:var(--mono);font-size:11px;color:var(--ghost);text-align:right;}
.pd-gut div,.pd-mrail div{height:26px;line-height:26px;padding:0 8px;white-space:nowrap;}
.pd-ta{flex:1;min-width:0;background:transparent;border:0;resize:none;padding:14px 12px;
  font-family:var(--mono);font-size:14px;line-height:26px;height:640px;white-space:pre;
  overflow:auto;letter-spacing:.01em;}
.pd-ta:focus{outline:none;}
.pd-mrail{width:170px;flex:none;border-left:1px solid var(--soft);overflow:hidden;padding:14px 0;
  font-family:var(--mono);font-size:11px;}
.pd-mrail div{display:flex;justify-content:flex-end;gap:7px;align-items:center;color:var(--faint);}
.pd-mrail b{color:var(--aqua);font-weight:600;}
.pd-mrail i{font-style:normal;color:var(--faint);}
.pd-dot{width:7px;height:7px;border-radius:50%;flex:none;}

/* ---- totals ---- */
.pd-tot{position:sticky;top:70px;}
.pd-big{font-family:var(--disp);font-weight:700;line-height:.9;letter-spacing:-.01em;}
.pd-rope{display:flex;height:16px;border-radius:9px;overflow:hidden;background:var(--track);gap:1px;}
.pd-rope div{background-image:repeating-linear-gradient(90deg,rgba(4,20,25,.42) 0 2px,transparent 2px 11px);}
.pd-brk{display:flex;justify-content:space-between;gap:8px;padding:5px 0;font-size:12.5px;
  border-bottom:1px solid var(--soft);align-items:center;}
.pd-brk:last-child{border-bottom:0;}
.pd-bar{height:4px;border-radius:2px;background:var(--track);overflow:hidden;margin-top:4px;}
.pd-bar i{display:block;height:100%;border-radius:2px;}

/* ---- groups & blocks ---- */
.pd-groupbar{display:flex;align-items:center;gap:8px;padding:7px 9px;flex-wrap:wrap;}
.pd-gtag{display:inline-flex;align-items:center;gap:6px;background:var(--field);border:1px solid var(--line);
  border-radius:6px;padding:3px 4px 3px 8px;}
.pd-gtag i{width:9px;height:9px;border-radius:2px;flex:none;}
.pd-gtag input{background:none;border:0;font-size:12.5px;font-weight:500;width:88px;padding:2px 0;}
.pd-gtag input.pd-base{width:44px;font-family:var(--mono);font-size:11px;color:var(--muted);
  text-align:right;border-left:1px solid var(--line);padding-left:6px;margin-left:2px;}
.pd-cell{padding:5px 6px;font-size:12px;}
.pd-minisel{background:var(--field);border:1px solid var(--line);border-radius:5px;padding:2px 4px;
  font-size:10.5px;color:var(--muted);max-width:112px;}
.pd-minisel:hover{border-color:var(--aqua);color:var(--ink);}
.pd-x{background:none;border:0;color:var(--faint);padding:0 5px;font-size:14px;line-height:1;}
.pd-x:hover{color:var(--clock);}

.pd-cell[data-test="1"]{background:rgba(224,64,47,.06);}
.pd-cell[data-test="1"] .pd-cellbar{background:rgba(224,64,47,.15);border-bottom-color:#A8453B;}
.pd-cell[data-test="1"] .pd-cellfoot{border-top-color:#A8453B;}
.pd-testtag{display:inline-flex;align-items:center;gap:5px;font-family:var(--disp);font-size:11.5px;
  letter-spacing:.12em;text-transform:uppercase;color:var(--clock);font-weight:600;white-space:nowrap;}
.pd-seg{border:1px solid var(--line);border-radius:9px;background:var(--panel);overflow:hidden;margin-bottom:10px;}
.pd-segbar{display:flex;align-items:center;gap:6px;padding:4px 8px;background:var(--field);
  border-bottom:1px solid var(--line);}
.pd-cells{display:flex;align-items:stretch;}
.pd-cell{flex:1 1 0;min-width:0;border-left:1px solid var(--soft);display:flex;flex-direction:column;}
.pd-cell:first-child{border-left:0;}
.pd-cellbar{display:flex;align-items:center;gap:6px;padding:4px 6px 4px 9px;border-bottom:1px solid var(--soft);
  flex-wrap:wrap;}
.pd-lab{color:var(--g,var(--ink));font-family:var(--disp);font-size:12px;letter-spacing:.1em;text-transform:uppercase;font-weight:600;
  display:inline-flex;align-items:center;gap:5px;}
.pd-lab i{width:8px;height:8px;border-radius:2px;flex:none;}
.pd-cellbody{display:flex;flex:1;}
.pd-cta{flex:1;min-width:0;background:transparent;border:0;resize:none;padding:8px 10px;
  font-family:var(--mono);font-size:13px;line-height:26px;white-space:pre;overflow-x:auto;overflow-y:hidden;}
.pd-cta:focus{outline:none;background:var(--focusbg);}
.pd-crail{width:54px;flex:none;border-left:1px solid var(--soft);padding:8px 0;font-family:var(--mono);
  font-size:10.5px;color:var(--faint);text-align:right;}
.pd-crail div{height:26px;line-height:26px;padding:0 7px;white-space:nowrap;overflow:hidden;}
.pd-cellfoot{display:flex;gap:9px;padding:5px 9px;border-top:1px solid var(--soft);font-size:11px;
  align-items:center;font-family:var(--mono);}
.pd-mini{background:none;border:1px solid var(--line);border-radius:5px;padding:2px 7px;font-size:10.5px;
  color:var(--muted);}
.pd-mini:hover{border-color:var(--aqua);color:var(--ink);}

.pd[data-theme="light"] .pd-lab{color:var(--ink);}
.pd[data-theme="light"] .pd-rope div{background-image:repeating-linear-gradient(90deg,rgba(255,255,255,.55) 0 2px,transparent 2px 11px);}
.pd[data-theme="light"] .pd-card{box-shadow:0 1px 2px rgba(13,43,50,.05);}

/* ---- folding panels and roll call ---- */
.pd-fold{display:flex;align-items:center;gap:10px;padding:10px 14px;width:100%;background:none;
  border:0;text-align:left;font-size:13px;}
.pd-fold b{font-weight:600;}
.pd-fold i{font-style:normal;color:var(--faint);font-size:11px;margin-left:auto;}
.pd-fold span.pd-chev{color:var(--faint);font-size:11px;width:10px;}
.pd-roll{display:inline-flex;gap:3px;}
.pd-roll button{border:1px solid var(--line);background:var(--field);border-radius:5px;
  padding:3px 10px;font-size:11px;color:var(--muted);font-family:var(--disp);letter-spacing:.1em;}
.pd-roll button:hover{border-color:var(--aqua);}
.pd-roll button[data-on="1"]{color:#fff;border-color:transparent;}

/* ---- practice toolbar ---- */
.pd-topbar{display:flex;align-items:center;gap:8px;padding:8px 10px;flex-wrap:wrap;}
.pd-topbar-id{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;padding-left:6px;min-width:0;}
.pd-topbar-id>b{font-weight:600;font-size:14px;}
.pd-topbar-id>span{font-family:var(--disp);font-size:12.5px;letter-spacing:.14em;
  text-transform:uppercase;color:var(--muted);}
.pd-topbar-g{display:inline-flex;align-items:center;gap:5px;font-family:var(--disp);font-size:12px;
  letter-spacing:.1em;text-transform:uppercase;color:var(--muted);}
.pd-topbar-g i{width:8px;height:8px;border-radius:2px;flex:none;}
.pd-topbar-g em{font-style:normal;font-family:var(--mono);font-size:10.5px;
  letter-spacing:0;color:var(--faint);}

/* ---- slides ---- */
.pd-slides{position:fixed;inset:0;z-index:90;background:var(--bg);display:flex;flex-direction:column;}
.pd-slide-top{display:flex;align-items:baseline;gap:14px;padding:14px 22px;border-bottom:1px solid var(--line);
  flex:none;flex-wrap:wrap;}
.pd-slide-title{font-family:var(--disp);font-size:22px;font-weight:700;letter-spacing:.06em;
  text-transform:uppercase;line-height:1;}
.pd-slide-sub{font-family:var(--disp);font-size:14px;letter-spacing:.16em;text-transform:uppercase;
  color:var(--muted);}
.pd-slide-body{flex:1;min-height:0;display:flex;padding:26px 5vw 52px;}
.pd-slide-cells{flex:1;min-height:0;display:flex;gap:34px;align-items:stretch;}
.pd-slide-cell{min-width:0;display:flex;flex-direction:column;gap:14px;}
.pd-slide-cell+.pd-slide-cell{border-left:1px solid var(--line);padding-left:34px;}
.pd-slide-tag{flex:none;font-family:var(--disp);font-size:clamp(15px,1.5vw,22px);letter-spacing:.16em;
  text-transform:uppercase;color:var(--muted);border-bottom:1px solid var(--line);padding-bottom:8px;}
.pd-slide-text{flex:1;min-height:0;font-family:var(--mono);line-height:1.5;white-space:pre-wrap;
  letter-spacing:.005em;}
.pd-slide-hit{position:absolute;top:64px;bottom:44px;background:none;border:0;display:flex;
  align-items:center;padding:0 22px;}
.pd-slide-hit:disabled{cursor:default;}
.pd-slide-arrow{font-size:44px;line-height:1;color:var(--faint);transition:color .15s;}
.pd-slide-hit:hover .pd-slide-arrow{color:var(--ink);}
.pd-slide-dots{position:absolute;left:0;right:0;bottom:16px;display:flex;justify-content:center;gap:7px;}
.pd-slide-dots button{width:9px;height:9px;padding:0;border-radius:50%;border:0;background:var(--track);}
.pd-slide-dots button[data-on="1"]{background:var(--clock);}
@media print{.pd-slides{display:none !important;}}

/* ---- print sheet ---- */
.pg{background:#fff;color:#0B0B0B;margin:0 auto;overflow:hidden;box-shadow:0 12px 50px rgba(0,0,0,.55);}
.pg-in{padding:30px 32px;font-family:var(--body);}
.pg-hd{display:flex;align-items:baseline;gap:12px;border-bottom:2px solid #111;padding-bottom:5px;margin-bottom:9px;}
.pg-hd h1{font-family:var(--disp);font-size:2.3em;margin:0;letter-spacing:.04em;text-transform:uppercase;line-height:1;}
.pg-hd span{font-family:var(--disp);letter-spacing:.14em;text-transform:uppercase;font-size:1em;color:#555;}
.pg table{width:100%;border-collapse:collapse;table-layout:fixed;}
.pg th{font-family:var(--disp);text-transform:uppercase;letter-spacing:.1em;font-size:1.1em;
  padding:3px 7px;text-align:left;border-bottom:1.2px solid #111;}
.pg td{border-bottom:1px solid #D3DADC;padding:5px 7px;vertical-align:top;font-family:var(--mono);
  font-size:1em;line-height:1.34;}
.pg .pg-blk{display:inline-block;text-align:left;white-space:pre-wrap;}
.pg td.pg-span{text-align:center;background:#F3F7F7;}
.pg td.pg-test{border-left:3px solid #C0392B;}
.pg .pg-testtag{display:block;font-family:var(--disp);text-transform:uppercase;letter-spacing:.16em;
  font-size:.8em;color:#C0392B;margin-bottom:2px;}
.pg .pg-tag{display:block;text-align:center;font-family:var(--disp);text-transform:uppercase;
  letter-spacing:.17em;font-size:.82em;color:#5A6A6E;margin-bottom:3px;
  border-bottom:1px solid #DCE4E5;padding-bottom:2px;}
.pg td+td,.pg th+th{border-left:1px solid #D3DADC;}
.pg .pg-tot td{border-top:1.6px solid #111;border-bottom:0;font-family:var(--disp);letter-spacing:.05em;
  font-size:1.15em;padding-top:5px;}
.pg .pg-sw{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:5px;}
@media print{
  .pd-noprint{display:none !important;}
  .pg{break-after:page;page-break-after:always;}
  .pg:last-child{break-after:auto;page-break-after:auto;}
  .pd-printwrap{position:static !important;overflow:visible !important;background:none !important;padding:0 !important;}
  .pd{background:#fff !important;min-height:0 !important;}
  .pd-wrap{padding:0 !important;max-width:none !important;}
  .pg{box-shadow:none !important;width:auto !important;height:auto !important;}
  .pg-in{padding:0 !important;}
}

/* ---- settings sub navigation ---- */
.pd-sub{display:flex;gap:2px;border-bottom:1px solid var(--line);margin-bottom:16px;flex-wrap:wrap;}
.pd-subtab{background:none;border:0;border-bottom:2px solid transparent;padding:7px 15px 9px;
  font-family:var(--disp);font-size:15px;letter-spacing:.1em;text-transform:uppercase;
  color:var(--muted);font-weight:600;}
.pd-subtab:hover{color:var(--ink);}
.pd-subtab[data-on="1"]{color:var(--ink);border-bottom-color:var(--aqua);}
.pd-seg2{display:inline-flex;gap:3px;background:var(--field);border:1px solid var(--line);
  border-radius:7px;padding:3px;flex-wrap:wrap;}
.pd-seg2 button{background:none;border:0;border-radius:5px;padding:5px 12px;font-size:12px;color:var(--muted);}
.pd-seg2 button[data-on="1"]{background:var(--raise);color:var(--ink);}
.pd-trig{display:inline-flex;align-items:center;gap:4px;background:var(--field);border:1px solid var(--line);
  border-radius:99px;padding:3px 5px 3px 10px;font-size:12px;font-family:var(--mono);}
.pd-trig button{background:none;border:0;color:var(--faint);padding:0 3px;line-height:1;font-size:13px;}
.pd-trig button:hover{color:var(--clock);}
.pd-add{background:transparent;border:1px dashed var(--line);border-radius:99px;padding:3px 11px;
  font-size:11.5px;color:var(--faint);font-family:var(--mono);}
.pd-add:hover{border-color:var(--aqua);color:var(--ink);}

/* ---- season report ---- */
.pg-stat{display:flex;gap:30px;margin:12px 0 4px;}
.pg-stat b{display:block;font-family:var(--disp);font-size:2.5em;line-height:1;font-weight:700;}
.pg-stat span{font-family:var(--disp);text-transform:uppercase;letter-spacing:.15em;
  font-size:.88em;color:#5A6A6E;}
.pg-sec{font-family:var(--disp);text-transform:uppercase;letter-spacing:.15em;font-size:1.05em;
  border-bottom:1.4px solid #111;padding-bottom:2px;margin:16px 0 5px;}
.pg-tbl{width:100%;border-collapse:collapse;table-layout:auto;}
.pg-tbl th{font-family:var(--disp);text-transform:uppercase;letter-spacing:.1em;font-size:.92em;
  text-align:left;border-bottom:1px solid #111;padding:2px 6px;}
.pg-tbl td{padding:2.5px 6px;border-bottom:1px solid #E4EAEB;font-family:var(--mono);
  font-size:.95em;vertical-align:top;}
.pg-tbl td:first-child{font-family:var(--body);}
.pg-tbl .pg-num{text-align:right;white-space:nowrap;}
.pg-mini{height:5px;background:#E4EAEB;border-radius:3px;overflow:hidden;margin-top:4px;}
.pg-mini i{display:block;height:100%;background:#2C3A3E;}
.pg-half{border-top:1.6px solid #111;padding-top:6px;margin-bottom:16px;}
.pg-half-hd{display:flex;align-items:baseline;gap:10px;margin-bottom:4px;}
.pg-half-hd b{font-family:var(--disp);font-size:1.45em;letter-spacing:.03em;text-transform:uppercase;}
.pg-half-hd span{font-family:var(--disp);letter-spacing:.13em;text-transform:uppercase;
  font-size:.95em;color:#5A6A6E;}

/* ---- attendance grid ---- */
.pg-att td,.pg-att th{padding:2px 4px;}
.pg-att th{font-size:.95em;text-align:center;line-height:1.15;}
.pg-att th:first-child{text-align:left;}
.pg-att .pg-name{font-family:var(--body);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.pg-att .pg-box{border:1px solid #C3CCCF;height:1.55em;}
.pg-att th.pg-meet{background:#E8EDEE;border-bottom:2px solid #111;}
.pg-att .pg-grp td{background:#EDF2F3;font-family:var(--disp);text-transform:uppercase;
  letter-spacing:.11em;font-size:.95em;border-bottom:1px solid #98A6AA;padding:3px 5px;}

/* ---- tables / lists ---- */
.pd-row{display:grid;gap:10px;align-items:center;padding:9px 12px;border-bottom:1px solid var(--soft);font-size:13px;}
.pd-row:last-child{border-bottom:0;}
.pd-row[data-click="1"]{cursor:pointer;text-align:left;}
.pd-row[data-click="1"]:hover{background:var(--sel);}
.pd-head{color:var(--muted);font-family:var(--disp);letter-spacing:.14em;text-transform:uppercase;
  font-size:11.5px;font-weight:600;background:var(--field);}
.pd-chip{display:inline-block;padding:2px 7px;border-radius:99px;font-size:10.5px;font-weight:600;
  letter-spacing:.06em;text-transform:uppercase;font-family:var(--disp);}
.pd-empty{padding:34px 20px;text-align:center;color:var(--muted);font-size:13px;line-height:1.6;}
.pd-modal{position:fixed;inset:0;background:var(--scrim);display:flex;align-items:center;
  justify-content:center;padding:20px;z-index:60;}
.pd-sheet{background:var(--panel);border:1px solid var(--line);border-radius:12px;width:100%;
  max-width:620px;max-height:88vh;overflow:auto;}
`;

/* ============================================================
   1. VOCABULARY — the language rules the parser listens for
   ============================================================ */

const STROKES = [
  ["frim",   [/\bfrim\b/i, /\bfree[\s-]*im\b/i],                           "#4FD3C4"],
  ["uwd",    [/\b(uwd|uw|u\/w|sdk|underwater[s]?|dolphins?)\b/i,
              /\b(dolphin|streamline)\s*kick\b/i],                          "#59A6FF"],
  ["fly",    [/\b(fly|butterfly|flies|bf)\b/i],                             "#FF5A4E"],
  ["back",   [/\b(back|backstroke|bk|bac)\b/i],                             "#FFC24B"],
  ["breast", [/\b(breast|breaststroke|brst|brs|br)\b/i],                    "#B07BFF"],
  ["im",     [/\b(im|i\.m\.|medley)\b/i],                                   "#FF9E4F"],
  ["free",   [/\b(free|freestyle|fr|crawl)\b/i],                            "#4FD3C4"],
  ["choice", [/\b(choice|ch|stroke)\b/i],                                   "#7E9FA8"],
];

/* IM and Free-IM are not strokes so much as recipes. A share handed to one is
   divided again by these weights. Everything else is simply itself. */
const STROKE_PARTS = {
  im:   [["fly", 1], ["back", 1], ["breast", 1], ["free", 1]],
  frim: [["free", 2], ["back", 1], ["breast", 1]],
};

const MODES = [
  ["kick",  [/\b(kick(ing|s)?|kck)\b/i, /\bK\b/],   "#4FA8FF"],
  ["pull",  [/\b(pull(ing)?)\b/i, /\bP\b/],         "#8CE05C"],
  ["drill", [/\b(drill[s]?|dr)\b/i, /\bD\b/],       "#E0D45C"],
  ["scull", [/\b(scull(ing)?)\b/i],                 "#5CE0B4"],
  ["swim",  [/\b(swim|swimming)\b/i, /\bS\b/],      "#4FD3C4"],
];

const GEAR = [
  ["fins",     [/\b(fins?|zoomers?)\b/i]],
  ["paddles",  [/\b(paddles?|pdls?)\b/i]],
  ["buoy",     [/\b(buoy|pull\s*buoy)\b/i]],
  ["snorkel",  [/\b(snorkel)\b/i]],
  ["board",    [/\b(board|kickboard)\b/i]],
  ["band",     [/\b(band|ankle\s*band)\b/i]],
  ["chute",    [/\b(chute|parachute)\b/i]],
  ["tempo",    [/\b(tempo\s*trainer|tt)\b/i]],
];

/* ------------------------------------------------------------
   The color system, after Jon Urbanchek — as laid out in Coach
   Max Thomas's workbook. Ten zones, each carrying every name it
   answers to. Coaches write in whichever vocabulary they think
   in; the app reads all of them and labels in whichever one the
   coach prefers to read.
   ------------------------------------------------------------ */
const COLOR_SYSTEM = [
  { key: "platinum", color: "Platinum", code: "SP3", name: "Speed development",
    zone: "Zone 5 · High velocity output", trains: "Sprint / creatine phosphate",
    hr: "Short bursts of extreme power", lac: "15–30 yd efforts", rest: "60s+ · 1:5",
    vol: "0 to 6–10 sec at maximum effort", swatch: "#CBD5E1" },

  { key: "gold", color: "Gold", code: "SP1", name: "Lactate tolerance",
    zone: "Zone 4 · LP / critical speed / LT", trains: "Anaerobic power",
    hr: "—", lac: "8+ mMol/L", rest: "1:1 – 1:4",
    vol: "3–10 min of total effort", swatch: "#F2B01E" },

  { key: "green", color: "Green", code: "SP2", name: "Lactate production",
    zone: "Zone 4 · LP / critical speed / LT", trains: "Anaerobic capacity",
    hr: "—", lac: "8+ mMol/L", rest: "1:2 – 1:8",
    vol: "3–10 min of total effort", swatch: "#4FBF5F" },

  { key: "purple", color: "Purple", code: "EN3", name: "Aerobic overload",
    zone: "Zone 3 · MVO2", trains: "Aerobic power",
    hr: "180–190+ bpm", lac: "6–8 mMol/L", rest: ":30 – 2:00",
    vol: "8–22 minutes", swatch: "#A66BE8" },

  { key: "blue", color: "Blue", code: "EN2+", name: "Anaerobic threshold",
    zone: "Zone 2 · A3 / AT + lactate removal", trains: "Aerobic capacity II",
    hr: "160–180 bpm", lac: "4–5 mMol/L", rest: "20–30 sec",
    vol: "20–30 minutes", swatch: "#3E8CF0" },

  { key: "red", color: "Red", code: "EN2", name: "Anaerobic threshold",
    zone: "Zone 2 · A3 / AT + lactate removal", trains: "Aerobic capacity II",
    hr: "150–170 bpm", lac: "3–4 mMol/L", rest: "10–20 sec",
    vol: "30–40 minutes", swatch: "#F04438" },

  { key: "pink", color: "Pink", code: "EN1+", name: "Aerobic development",
    zone: "Zone 1 · A1–A2", trains: "Aerobic capacity",
    hr: "130–150 bpm", lac: "1–3 mMol/L", rest: "10–30 sec",
    vol: "30+ min · 1500–3000 yd", swatch: "#F778A8" },

  { key: "white", color: "White", code: "EN1", name: "Aerobic maintenance",
    zone: "Zone 1 · A1–A2", trains: "Aerobic capacity",
    hr: "130–150 bpm", lac: "1–3 mMol/L", rest: "10–30 sec",
    vol: "30+ min · 1500–3000 yd", swatch: "#F4F9FB" },

  { key: "clear2", color: "Clear 2", code: "REC", name: "Aerobic recovery",
    zone: "Zone 1 · A1–A2", trains: "Regeneration",
    hr: "130 bpm and below", lac: "0–2 mMol/L", rest: "—",
    vol: "3000+ yards", swatch: "#A9CEDC" },

  { key: "clear1", color: "Clear 1", code: "REC", name: "Aerobic recovery",
    zone: "Zone 1 · A1–A2", trains: "Regeneration",
    hr: "130 bpm and below", lac: "0–2 mMol/L", rest: "—",
    vol: "3000+ yards", swatch: "#DCEEF4" },
];

// Order hardest → easiest above; charts read better easiest → hardest.
const ZONE_ORDER = [...COLOR_SYSTEM].map((z) => z.key).reverse().concat("untagged");
const ZONE_BY_KEY = {};
COLOR_SYSTEM.forEach((z) => (ZONE_BY_KEY[z.key] = z));

// "fast" and "hard" mean different things on a 25 than on a 200.
const VAGUE = /\b(fast|hard|strong|quick)\b/i;
function vagueZone(dist) {
  if (!dist) return "gold";
  if (dist <= 25) return "platinum";
  if (dist <= 50) return "gold";
  if (dist <= 100) return "purple";
  return "red";
}

// Practices written before the color system came in used these.
const LEGACY_ZONE = { easy: "clear2", aerobic: "white", thresh: "red",
  vo2: "purple", race: "gold", sprint: "platinum" };

/* Triggers are data, not code. Every zone starts life answering only to
   its own color; a coach adds whatever else they actually write. */
const DEFAULT_TRIGGERS = {};
COLOR_SYSTEM.forEach((z) => (DEFAULT_TRIGGERS[z.key] = [z.color.toLowerCase()]));

const TRIGGER_IDEAS = {
  platinum: ["SP3", "alactate", "speed", "top speed", "sprint", "all out", "max effort"],
  gold: ["SP1", "lactate tolerance", "anaerobic power", "race pace", "RP", "goal pace"],
  green: ["SP2", "lactate production", "anaerobic capacity"],
  purple: ["EN3", "VO2", "MVO2", "aerobic overload", "aerobic power"],
  blue: ["EN2+", "aerobic capacity II"],
  red: ["EN2", "A3", "threshold", "T-pace", "best average", "BA", "critical speed"],
  pink: ["EN1+", "aerobic development"],
  white: ["EN1", "A2", "aerobic maintenance", "aerobic", "steady", "base"],
  clear2: ["A1", "REC", "recovery", "easy", "EZ", "loosen", "smooth"],
  clear1: ["clear", "regen"],
};

function triggerRe(word) {
  const esc = String(word).trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\s+/g, "\\s*");
  return (/^\w/.test(word) ? "\\b" : "") + esc + (/\w$/.test(word) ? "\\b" : "");
}
function buildZoneMatchers(triggers) {
  const out = [];
  COLOR_SYSTEM.forEach((z) => {
    const list = ((triggers && triggers[z.key]) || []).filter((w) => String(w).trim());
    if (!list.length) return;
    try { out.push({ key: z.key, re: new RegExp("(" + list.map(triggerRe).join("|") + ")", "i") }); }
    catch (e) { /* a trigger that will not compile is simply skipped */ }
  });
  return out;
}
let _matcherCache = { src: undefined, val: null };
function zoneMatchers(triggers) {
  const src = triggers || DEFAULT_TRIGGERS;
  if (_matcherCache.src !== src) _matcherCache = { src, val: buildZoneMatchers(src) };
  return _matcherCache.val;
}


const VOCABS = [["color", "Color"], ["code", "EN / SP code"], ["name", "What it trains"]];
function zoneLabel(key, vocab) {
  if (!key || key === "untagged") return "Untagged";
  const z = ZONE_BY_KEY[LEGACY_ZONE[key] || key];
  if (!z) return key;
  if (vocab === "code") return z.code === "REC" ? `${z.code} (${z.color})` : z.code;
  if (vocab === "name") return z.name;
  return z.color;
}
function zoneLabels(vocab) {
  const m = { untagged: "Untagged" };
  COLOR_SYSTEM.forEach((z) => (m[z.key] = zoneLabel(z.key, vocab)));
  Object.keys(LEGACY_ZONE).forEach((k) => (m[k] = zoneLabel(k, vocab)));
  return m;
}

/* Set building guide from the workbook: repetitions that keep a set
   inside its color, at 10–30 seconds rest. White/Pink share a column. */
const REP_CHART = {
  25:  { gold: [8, 16], purple: [18, 40], blue: [24, 50], red: [30, 60], white: [62, null] },
  50:  { gold: [2, 4],  purple: [8, 16],  blue: [8, 22],  red: [12, 26], white: [27, null] },
  75:  { gold: [1, 2],  purple: [5, 12],  blue: [6, 16],  red: [10, 16], white: [17, null] },
  100: { gold: [1, 1],  purple: [4, 8],   blue: [6, 10],  red: [8, 12],  white: [13, null] },
  150: {                purple: [3, 6],   blue: [3, 8],   red: [4, 8],   white: [9, null] },
  200: {                purple: [2, 4],   blue: [2, 6],   red: [3, 6],   white: [7, null] },
  400: {                purple: [1, 1],   blue: [1, 3],   red: [2, 4],   white: [4, null] },
};
function repRange(dist, zone) {
  const row = REP_CHART[dist];
  if (!row) return null;
  const col = zone === "pink" ? "white" : zone;
  return row[col] || null;
}
function chartFlags(rows) {
  const out = [];
  rows.forEach((r) => {
    if (r.kind !== "set" || !r.zone || (r.zones && r.zones.length > 1)) return;
    const range = repRange(r.dist, r.zone);
    if (!range) return;
    const [lo, hi] = range;
    if (r.totalReps < lo) out.push({ r, want: hi ? `${lo}–${hi}` : `${lo}+`, how: "under" });
    else if (hi && r.totalReps > hi) out.push({ r, want: `${lo}–${hi}`, how: "over" });
  });
  return out;
}

const STROKE_LABEL = { fly: "Butterfly", back: "Backstroke", breast: "Breaststroke",
  free: "Freestyle", uwd: "Underwater dolphin", choice: "Choice / stroke",
  im: "IM", frim: "Free-IM", untagged: "Untagged" };
const STROKE_ORDER = ["free", "back", "breast", "fly", "uwd", "choice", "untagged"];
const MODE_LABEL = { swim: "Swim", kick: "Kick", pull: "Pull", drill: "Drill",
  scull: "Scull", untagged: "Untagged" };

const COLOR = {};
STROKES.forEach(([k, , c]) => (COLOR[k] = c));
MODES.forEach(([k, , c]) => (COLOR["m_" + k] = c));
COLOR_SYSTEM.forEach((z) => (COLOR["z_" + z.key] = z.swatch));
Object.entries(LEGACY_ZONE).forEach(([k, v]) => (COLOR["z_" + k] = ZONE_BY_KEY[v].swatch));
COLOR.untagged = "#456E79";

/* ============================================================
   2. TIME HELPERS
   ============================================================ */

function toSec(raw) {
  if (raw == null) return null;
  const s = String(raw).trim();
  if (!s) return null;
  if (s.includes(":")) {
    const [m, sec] = s.split(":");
    return (parseInt(m || "0", 10) || 0) * 60 + (parseInt(sec || "0", 10) || 0);
  }
  return parseInt(s, 10) || 0;
}

function fmt(sec) {
  if (sec == null || !isFinite(sec)) return "—";
  const s = Math.round(sec);
  const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), r = s % 60;
  const pad = (n) => String(n).padStart(2, "0");
  return h ? `${h}:${pad(m)}:${pad(r)}` : `${m}:${pad(r)}`;
}

const comma = (n) => Math.round(n).toLocaleString("en-US");

/* ============================================================
   3. THE PARSER
   Reads a practice the way a coach writes one.
   ============================================================ */

const RE_ROUNDS = /^(\d+)\s*(?:rounds?|rds?|times?\s*through|x\s*through|through)\b/i;
const RE_REPS   = /^\s*((?:\d+\s*[x×*]\s*)+)?(\d{2,5})\b/;
const RE_INT    = /@\s*(\d{1,2}:\d{2}|:\d{2}|\d{1,4})/;
const RE_REST   = /(?:\br(?:est)?\s*[:=]?\s*|\+\s*)(\d{1,2}:\d{2}|:\d{2}|\d{1,3})(?=\s|$)/i;
const RE_PCT    = /\b(\d{2,3})\s*%/;
const RE_BREAK  = /\b(break|huddle|talk|dryland|out\s*of\s*the\s*water)\b/i;

// A break line carries time but no distance. "Break 5:00", "5 min break", "break :90"
function breakSeconds(body) {
  let m = body.match(/(\d{1,2}:\d{2})/);
  if (m) return toSec(m[1]);
  m = body.match(/(?:^|\s):(\d{1,3})\b/);            // ":30" reads as seconds
  if (m) return parseInt(m[1], 10) || 0;
  m = body.match(/(\d{1,3})\s*(?:min(?:ute)?s?|m)\b/i);
  if (m) return (parseInt(m[1], 10) || 0) * 60;
  m = body.match(/(\d{1,3})\s*(?:sec(?:ond)?s?|s)\b/i);
  if (m) return parseInt(m[1], 10) || 0;
  m = body.match(/(\d{1,3})/);
  if (m) return (parseInt(m[1], 10) || 0) * 60; // a bare number on a break line reads as minutes
  return 0;
}

/* Collect every term a line names, in priority order, refusing any match that
   sits on top of one already claimed. That is what keeps "dolphin kick" from
   counting as both UW and kick, or "EN2+" as both Blue and Red. */
function matchSpans(defs, text) {
  const claimed = [];
  const found = [];
  for (const [key, res] of defs) {
    let took = false;
    for (const re of (Array.isArray(res) ? res : [res])) {
      const g = new RegExp(re.source, re.flags.indexOf("g") >= 0 ? re.flags : re.flags + "g");
      let m;
      while ((m = g.exec(text)) !== null) {
        if (!m[0].length) { g.lastIndex++; continue; }
        const a = m.index, b = a + m[0].length;
        if (claimed.some((c) => a < c[1] && b > c[0])) continue;
        claimed.push([a, b]);
        took = true;
      }
    }
    if (took) found.push(key);
  }
  return found;
}

// Two pieces of equipment are usually worn at once; alternated only when the
// line says so.
const ALTERNATES = /(\/|\bodds?\b|\bevens?\b|\balt(ernat\w*)?\b)/i;

function tagLine(text, matchers) {
  const strokes = matchSpans(STROKES.map((d) => [d[0], d[1]]), text);
  const modes = matchSpans(MODES.map((d) => [d[0], d[1]]), text);
  const zones = matchSpans(matchers.map((m) => [m.key, [m.re]]), text);
  const gear = matchSpans(GEAR.map((d) => [d[0], d[1]]), text);
  // "w/" is not an alternation slash
  const bare = text.replace(/\bw\/|\bu\/w\b/gi, " ");
  return { strokes, modes, zones, gear,
    splitGear: gear.length > 1 && ALTERNATES.test(bare),
    vague: !zones.length && VAGUE.test(text) };
}

const pickList = (a, b) => (a && a.length ? a : (b || []));

/* Stage one shares the distance evenly between the names on the line. Stage two
   lets each name divide its share again, which is where IM and Free-IM live.
   Whole yards out, summing back to exactly what went in. */
function spread(total, tokens, expand) {
  if (!tokens || !tokens.length) return {};
  const per = total / tokens.length;
  const raw = {};
  tokens.forEach((tok) => {
    const parts = (expand && expand[tok]) || [[tok, 1]];
    const w = parts.reduce((a, x) => a + x[1], 0);
    parts.forEach(([name, weight]) => (raw[name] = (raw[name] || 0) + (per * weight) / w));
  });
  const keys = Object.keys(raw);
  const out = {};
  let used = 0;
  keys.forEach((k) => { out[k] = Math.floor(raw[k]); used += out[k]; });
  // hand the rounding remainder to whoever lost the most to it
  const order = keys.slice().sort((a, b) => (raw[b] - out[b]) - (raw[a] - out[a]));
  for (let i = 0; used < total && order.length; i++, used++) out[order[i % order.length]] += 1;
  return out;
}

// A line naming nothing falls to the practice default, then to untagged.
const orDefault = (tokens, fallback) =>
  tokens.length ? tokens : (fallback && fallback !== "none" ? [fallback] : ["untagged"]);
function merge(own, inherited) {
  return {
    strokes: pickList(own.strokes, inherited.strokes),
    modes:   pickList(own.modes, inherited.modes),
    zones:   pickList(own.zones, inherited.zones),
    gear:    pickList(own.gear, inherited.gear),
    splitGear: own.gear.length ? own.splitGear : !!inherited.splitGear,
    vague:   own.vague || (!own.zones.length && inherited.vague) || false,
  };
}

function parsePractice(text, settings) {
  const basePace = settings.basePace || 90; // seconds per 100 at cruise
  const prime = settings.primary || {};
  const matchers = zoneMatchers(settings.zoneTriggers);
  const useVague = settings.vagueByDistance !== false;
  const rows = [];
  const stack = []; // { indent, rounds, ctx, label, isSection, mode }

  const lines = (text || "").split("\n").map((raw) => ({
    raw, indent: raw.length - raw.replace(/^\s*/, "").length, body: raw.trim(),
  }));
  const nextReal = (i) => { for (let j = i + 1; j < lines.length; j++) if (lines[j].body) return lines[j]; return null; };

  for (let i = 0; i < lines.length; i++) {
    const { raw, indent, body } = lines[i];

    // A blank line closes an open rounds block but leaves the section alone.
    if (!body) {
      while (stack.length && stack[stack.length - 1].rounds > 1 && stack[stack.length - 1].mode === "flat") stack.pop();
      rows.push({ i, kind: "blank", raw });
      continue;
    }

    const roundsMatch = body.match(RE_ROUNDS);
    const repMatch = body.match(RE_REPS);
    const isHeaderLine = !!roundsMatch || (body.endsWith(":") && !repMatch);

    // Leave any scope this line has stepped out of.
    while (stack.length) {
      const top = stack[stack.length - 1];
      if (top.mode === "indent") {
        if (indent > top.indent) break;
      } else {
        if (indent > top.indent) break;
        if (!(isHeaderLine && indent <= top.indent) && indent >= top.indent) break;
      }
      stack.pop();
    }

    const mult = stack.reduce((a, b) => a * b.rounds, 1);
    const inherited = stack.reduce(
      (a, b) => ({ strokes: pickList(b.ctx.strokes, a.strokes), modes: pickList(b.ctx.modes, a.modes),
                   zones: pickList(b.ctx.zones, a.zones), gear: pickList(b.ctx.gear, a.gear),
                   splitGear: b.ctx.gear.length ? b.ctx.splitGear : a.splitGear,
                   vague: b.ctx.vague || a.vague }),
      { strokes: [], modes: [], zones: [], gear: [], splitGear: false, vague: false }
    );
    const sectionEntry = [...stack].reverse().filter((s) => s.isSection)[0];
    const section = sectionEntry ? sectionEntry.label : "Practice";
    const own = tagLine(body, matchers);

    if (isHeaderLine) {
      const nx = nextReal(i);
      const scope = nx && nx.indent > indent ? "indent" : "flat";
      const rounds = roundsMatch ? parseInt(roundsMatch[1], 10) || 1 : 1;
      const label = body.replace(/:$/, "").trim();
      stack.push({ indent, rounds, ctx: merge(own, inherited), label, isSection: !roundsMatch, mode: scope });
      rows.push({ i, kind: roundsMatch ? "rounds" : "header", raw, rounds, label, mult });
      continue;
    }

    if (RE_BREAK.test(body) && !RE_INT.test(body) && !/^\s*\d+\s*[x\u00d7*]/.test(body)) {
      rows.push({ i, kind: "break", raw, seconds: breakSeconds(body) * mult, mult });
      continue;
    }

    if (repMatch) {
      let reps = 1;
      if (repMatch[1]) {
        repMatch[1].split(/[x\u00d7*]/).forEach((n) => {
          const v = parseInt(n.trim(), 10);
          if (v) reps *= v;
        });
      }
      const dist = parseInt(repMatch[2], 10);
      const pctM = body.match(RE_PCT);
      const pct = pctM && +pctM[1] >= 50 && +pctM[1] <= 100 ? +pctM[1] : null;
      const intM = body.match(RE_INT);
      const restM = body.match(RE_REST);
      let interval = intM ? toSec(intM[1]) : null;
      // A bare "@2" means two minutes, not two seconds — catch intervals that
      // come out faster than anyone could physically swim the distance.
      if (interval != null && intM && !intM[1].includes(":") && interval < (dist / 100) * basePace * 0.5) {
        interval *= 60;
      }
      const rest = restM ? toSec(restM[1]) : null;

      const swimSec = (dist / 100) * basePace;
      let per, estimated = false;
      if (interval) per = interval;
      else if (rest != null) { per = swimSec + rest; estimated = true; }
      else { per = swimSec; estimated = true; }

      const tags = merge(own, inherited);
      if (!tags.zones.length && tags.vague && useVague) tags.zones = [vagueZone(dist)];
      const totalReps = reps * mult;
      const yards = totalReps * dist;

      const strokeDist = spread(yards, orDefault(tags.strokes, prime.stroke), STROKE_PARTS);
      const zoneDist = spread(yards, orDefault(tags.zones, prime.zone));
      /* Underwater dolphin carries kick with it, but only for its own share.
         A type written on the line takes the whole line instead. */
      let modeDist;
      if (tags.modes.length) modeDist = spread(yards, tags.modes);
      else {
        const uwd = strokeDist.uwd || 0;
        modeDist = uwd
          ? { kick: uwd, ...(yards > uwd ? spread(yards - uwd, orDefault([], prime.mode)) : {}) }
          : spread(yards, orDefault([], prime.mode));
        if (uwd && modeDist.kick !== uwd) modeDist.kick = (modeDist.kick || 0) + uwd;
      }

      const top = (d) => Object.keys(d).sort((a, b) => d[b] - d[a])[0] || null;
      rows.push({
        i, kind: "set", raw, reps, dist, mult, totalReps,
        yards, seconds: totalReps * per,
        interval, rest, estimated, section, pct, ...tags,
        strokeDist, modeDist, zoneDist,
        // the biggest share is what the interface paints with
        stroke: top(strokeDist), mode: top(modeDist), zone: top(zoneDist),
      });
      continue;
    }

    rows.push({ i, kind: "note", raw, mult });
  }

  // ---- aggregate
  const t = { yards: 0, seconds: 0, breakSeconds: 0, estimated: false, sets: 0,
    stroke: {}, mode: {}, zone: {}, gear: {}, sections: [] };
  const secMap = new Map();

  rows.forEach((r) => {
    if (r.kind === "break") { t.seconds += r.seconds; t.breakSeconds += r.seconds; return; }
    if (r.kind !== "set") return;
    t.yards += r.yards; t.seconds += r.seconds; t.sets += 1;
    if (r.estimated) t.estimated = true;
    const add = (bucket, dist) =>
      Object.entries(dist).forEach(([k, v]) => (bucket[k] = (bucket[k] || 0) + v));
    add(t.stroke, r.strokeDist);
    add(t.mode, r.modeDist);
    add(t.zone, r.zoneDist);
    if (r.splitGear) add(t.gear, spread(r.yards, r.gear));
    else r.gear.forEach((g) => (t.gear[g] = (t.gear[g] || 0) + r.yards));
    const cur = secMap.get(r.section) || { label: r.section, yards: 0, seconds: 0 };
    cur.yards += r.yards; cur.seconds += r.seconds;
    secMap.set(r.section, cur);
  });
  t.sections = [...secMap.values()];
  return { rows, totals: t };
}

/* ============================================================
   4. STORAGE — survives refreshes; falls back to memory
   ============================================================ */

const mem = {};
// Prefer the host's own storage when there is one, otherwise the browser's.
// Either way the data never leaves this machine.
const hasLS = (() => {
  try { localStorage.setItem("__pd_test", "1"); localStorage.removeItem("__pd_test"); return true; }
  catch (e) { return false; }
})();
const store = {
  async get(key, fallback) {
    try {
      if (typeof window !== "undefined" && window.storage) {
        const r = await window.storage.get(key);
        return r ? JSON.parse(r.value) : fallback;
      }
      if (hasLS) {
        const v = localStorage.getItem(key);
        return v == null ? fallback : JSON.parse(v);
      }
    } catch (e) { /* fall through to memory */ }
    return key in mem ? mem[key] : fallback;
  },
  async set(key, value) {
    mem[key] = value;
    try {
      if (typeof window !== "undefined" && window.storage) {
        await window.storage.set(key, JSON.stringify(value));
        return true;
      }
      if (hasLS) { localStorage.setItem(key, JSON.stringify(value)); return true; }
      return false;
    } catch (e) { console.error("save failed", e); return false; }
  },
  async remove(key) {
    delete mem[key];
    try {
      if (typeof window !== "undefined" && window.storage) { await window.storage.delete(key); return true; }
      if (hasLS) { localStorage.removeItem(key); return true; }
    } catch (e) { /* nothing more to do */ }
    return true;
  },
};

/* Practices are stored one key apiece rather than as a single block. A save
   then writes a couple of kilobytes instead of the whole season, and a write
   that goes wrong can only ever damage one day. */
const K = {
  lib: "pdesk:library", season: "pdesk:season", seasons: "pdesk:seasons",
  cfg: "pdesk:settings", index: "pdesk:index", legacyLog: "pdesk:practices",
  tests: "pdesk:tests",
  practice: (id) => `pdesk:practice:${id}`,
};

async function loadPractices() {
  const ids = await store.get(K.index, null);
  if (ids) {
    const all = await Promise.all(ids.map((id) => store.get(K.practice(id), null)));
    return all.filter(Boolean).map(upgradeRecord);
  }
  // First run after the split: fan the old single block out into its own keys.
  const legacy = (await store.get(K.legacyLog, [])) || [];
  const list = legacy.map(upgradeRecord);
  await Promise.all(list.map((p) => store.set(K.practice(p.id), p)));
  await store.set(K.index, list.map((p) => p.id));
  return list;
}
const uid = () => Math.random().toString(36).slice(2, 10);
const today = () => new Date().toISOString().slice(0, 10);

function mondayOf(dateStr) {
  const d = new Date(dateStr + "T00:00:00");
  const shift = (d.getDay() + 6) % 7;
  d.setDate(d.getDate() - shift);
  return d.toISOString().slice(0, 10);
}
function addDays(dateStr, n) {
  const d = new Date(dateStr + "T00:00:00");
  d.setDate(d.getDate() + n);
  return d.toISOString().slice(0, 10);
}
function pretty(dateStr) {
  const d = new Date(dateStr + "T00:00:00");
  return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}

/* ============================================================
   4b. THE PRACTICE DOCUMENT
   A practice is a stack of blocks. Each block is split into
   cells, and every cell covers one or more groups — so a block
   can be shared by everybody, split three ways, or anything
   between. Cells always sit in group order, which is what lets
   the same structure print as a table.
   ============================================================ */

/* Recharts writes SVG attributes, which do not resolve CSS variables. */
const CHART = {
  dark:  { grid: "#123E4A", tick: "#87ABB3", axis: "#1B5A69", plan: "#1B5A69",
           act: "#4FD3C4", line: "#FF4438", card: "#0B2F39", edge: "#1B5A69" },
  light: { grid: "#E1EAEC", tick: "#5C777F", axis: "#C6D5D8", plan: "#B9CCD1",
           act: "#0C8878", line: "#CE3327", card: "#FFFFFF", edge: "#D2DEE1" },
};
const chartInk = (cfg) => CHART[cfg && cfg.theme === "light" ? "light" : "dark"];

const GROUP_COLORS = ["var(--aqua)", "#FFC24B", "#FF7A5C", "#B07BFF", "#8CE05C", "#59A6FF"];
const COURSES = [["scy", "SCY", "yd"], ["scm", "SCM", "m"], ["lcm", "LCM", "m"]];
const courseUnit = (c) => (c === "scm" || c === "lcm" ? "m" : "yd");
const courseName = (c) => String(c || "scy").toUpperCase();
// older copies stored plain "yards" / "meters"
const toCourse = (v) => (v === "meters" ? "scm" : v === "yards" ? "scy" : v || "scy");

const DEFAULT_BASE = 90; // seconds per 100
const newGroup = (name, i, base) =>
  ({ id: uid(), name, color: GROUP_COLORS[i % GROUP_COLORS.length], base: base || DEFAULT_BASE });

// Intervals are written ":45" and "1:30", never "0:45".
function clockText(sec) {
  const m = Math.floor(sec / 60), r = Math.round(sec % 60);
  return m ? `${m}:${String(r).padStart(2, "0")}` : `:${String(r).padStart(2, "0")}`;
}

/* Take a set written for one group and rework it for another.
   Intervals move with the difference in base pace, then repetitions are
   trimmed so the block still finishes at the same time on the clock. */
function adaptText(text, srcBase, dstBase, cfg) {
  const from = srcBase || DEFAULT_BASE, to = dstBase || DEFAULT_BASE;
  const ratio = to / from;
  if (Math.abs(ratio - 1) < 0.005) return text;
  const lines = text.split("\n");
  const readCfg = { ...cfg, basePace: from };
  const round5 = (n) => Math.max(5, Math.round(n / 5) * 5);

  parsePractice(text, readCfg).rows.forEach((r) => {
    if (r.kind !== "set") return;
    const line = lines[r.i];
    const m = line.match(RE_REPS);
    if (!m) return;
    const ws = line.slice(0, line.length - line.replace(/^\s*/, "").length);
    let tail = line.slice(m[0].length);
    let reps = r.reps, dist = r.dist;

    if (r.interval && r.reps >= 2) {
      let ni = round5(r.interval * ratio);
      ni = Math.max(ni, round5((dist / 100) * to));           // never faster than they can swim
      reps = Math.max(1, Math.round((r.reps * r.interval) / ni));
      tail = tail.replace(RE_INT, "@ " + clockText(ni));
    } else if (r.interval) {
      dist = r.dist >= 100 ? Math.max(25, Math.round((r.dist / ratio) / 25) * 25) : r.dist;
      let ni = round5(r.interval * (dist / r.dist) * ratio);
      ni = Math.max(ni, round5((dist / 100) * to));
      tail = tail.replace(RE_INT, "@ " + clockText(ni));
    } else if (r.rest == null && r.dist >= 100) {
      dist = Math.max(25, Math.round((r.dist / ratio) / 25) * 25);
    } else return;

    lines[r.i] = ws + (reps > 1 ? reps + "x" : "") + dist + tail;
  });
  return lines.join("\n");
}

function blankDoc() {
  const g = newGroup("Everyone", 0);
  return { groups: [g], blocks: [{ id: uid(), cells: [{ id: uid(), groups: [g.id], text: "" }] }] };
}
const sharedBlock = (doc, text = "") =>
  ({ id: uid(), cells: [{ id: uid(), groups: doc.groups.map((g) => g.id), text }] });

// Keep cells and their group lists in column order so colspans stay honest.
function normalize(doc) {
  const order = {};
  doc.groups.forEach((g, i) => (order[g.id] = i));
  return {
    ...doc,
    blocks: doc.blocks.map((b) => ({
      ...b,
      cells: b.cells
        .map((c) => ({ ...c, groups: [...c.groups].sort((x, y) => order[x] - order[y]) }))
        .filter((c) => c.groups.length)
        .sort((x, y) => order[x.groups[0]] - order[y.groups[0]]),
    })),
  };
}

function addGroupToDoc(doc, name, copyFromId, base) {
  const g = newGroup(name || `Group ${doc.groups.length + 1}`, doc.groups.length, base);
  const all = doc.groups.map((x) => x.id);
  const blocks = doc.blocks.map((b) => {
    // A block everybody already shares simply stretches to cover the newcomer.
    if (b.cells.length === 1 && b.cells[0].groups.length === all.length)
      return { ...b, cells: [{ ...b.cells[0], groups: [...b.cells[0].groups, g.id] }] };
    const src = b.cells.find((c) => c.groups.includes(copyFromId));
    return { ...b, cells: [...b.cells, { id: uid(), groups: [g.id], text: src ? src.text : "" }] };
  });
  return normalize({ ...doc, groups: [...doc.groups, g], blocks });
}

function removeGroupFromDoc(doc, gid) {
  if (doc.groups.length < 2) return doc;
  const blocks = doc.blocks.map((b) => ({
    ...b,
    cells: b.cells.map((c) => ({ ...c, groups: c.groups.filter((x) => x !== gid) })).filter((c) => c.groups.length),
  }));
  return normalize({ ...doc, groups: doc.groups.filter((g) => g.id !== gid), blocks });
}

const mapBlock = (doc, id, fn) =>
  normalize({ ...doc, blocks: doc.blocks.map((b) => (b.id === id ? fn(b) : b)) });

function shareBlock(doc, id) {
  return mapBlock(doc, id, (b) => {
    const text = b.cells.map((c) => c.text).find((t) => t.trim()) || "";
    return { ...b, cells: [{ id: uid(), groups: doc.groups.map((g) => g.id), text }] };
  });
}
function splitBlock(doc, id) {
  return mapBlock(doc, id, (b) => ({
    ...b,
    cells: doc.groups.map((g) => {
      const src = b.cells.find((c) => c.groups.includes(g.id));
      return { id: uid(), groups: [g.id], text: src ? src.text : "" };
    }),
  }));
}
function mergeRight(doc, id, cellId) {
  return mapBlock(doc, id, (b) => {
    const i = b.cells.findIndex((c) => c.id === cellId);
    if (i < 0 || i === b.cells.length - 1) return b;
    const [a, n] = [b.cells[i], b.cells[i + 1]];
    const text = a.text.trim() ? a.text : n.text;
    const cells = [...b.cells];
    cells.splice(i, 2, { id: uid(), groups: [...a.groups, ...n.groups], text });
    return { ...b, cells };
  });
}
function unmergeCell(doc, id, cellId) {
  return mapBlock(doc, id, (b) => {
    const i = b.cells.findIndex((c) => c.id === cellId);
    if (i < 0 || b.cells[i].groups.length < 2) return b;
    const cells = [...b.cells];
    cells.splice(i, 1, ...b.cells[i].groups.map((g) => ({ id: uid(), groups: [g], text: b.cells[i].text })));
    return { ...b, cells };
  });
}

// ---- totals, one column at a time
const emptyTot = () => ({ yards: 0, seconds: 0, breakSeconds: 0, sets: 0, estimated: false,
  stroke: {}, mode: {}, zone: {}, gear: {} });

function absorb(into, t) {
  into.yards += t.yards; into.seconds += t.seconds;
  into.breakSeconds += t.breakSeconds || 0; into.sets += t.sets;
  if (t.estimated) into.estimated = true;
  ["stroke", "mode", "zone", "gear"].forEach((k) =>
    Object.entries(t[k]).forEach(([kk, v]) => (into[k][kk] = (into[k][kk] || 0) + v)));
  return into;
}

function docTotals(doc, cfg) {
  const per = {};
  doc.groups.forEach((g) => (per[g.id] = emptyTot()));
  doc.blocks.forEach((b) => b.cells.forEach((c) => {
    // Sets written on rest are timed off the group's own base pace, so a cell
    // shared by three groups is read three ways.
    const cache = {};
    c.groups.forEach((gid) => {
      if (!per[gid]) return;
      const g = doc.groups.filter((x) => x.id === gid)[0];
      const base = (g && g.base) || DEFAULT_BASE;
      if (!cache[base]) cache[base] = parsePractice(c.text, { ...cfg, basePace: base }).totals;
      absorb(per[gid], cache[base]);
    });
  }));
  return per;
}

// Old single column practices upgrade into a document with one group.
function toDoc(v) {
  if (v && v.groups && v.blocks) {
    // A test used to be marked on the block; it belongs to each column now.
    if (v.blocks.some((b) => b.testId))
      return { ...v, blocks: v.blocks.map((b) => b.testId
        ? { ...b, testId: null, cells: b.cells.map((c) => ({ ...c, testId: c.testId || b.testId })) }
        : b) };
    return v;
  }
  const d = blankDoc();
  d.blocks[0].cells[0].text = typeof v === "string" ? v : "";
  return d;
}
function upgradeRecord(p) {
  if (p.byGroup) return p;
  return { ...p, doc: toDoc(p.text),
    byGroup: [{ name: "Everyone", yards: p.yards || 0, seconds: p.seconds || 0,
      stroke: p.stroke || {}, zone: p.zone || {}, mode: p.mode || {}, gear: p.gear || {} }] };
}
const PRIMARY_STROKES = [["free", "Freestyle"], ["back", "Backstroke"], ["breast", "Breaststroke"],
  ["fly", "Butterfly"], ["uwd", "Underwater dolphin"], ["choice", "Choice"], ["im", "IM"],
  ["frim", "Free-IM"], ["none", "Leave untagged"]];
const PRIMARY_MODES = [["swim", "Swim"], ["kick", "Kick"], ["pull", "Pull"], ["drill", "Drill"],
  ["none", "Leave untagged"]];
const DEFAULT_PRIMARY = { stroke: "free", mode: "swim", zone: "white" };
const primaryOf = (x) => ({ ...DEFAULT_PRIMARY, ...((x && x.primary) || {}) });
// hand a practice's defaults to the parser alongside the settings
const primed = (cfg, x) => ({ ...cfg, primary: primaryOf(x) });

function PrimaryPicker({ value, onChange, vocab }) {
  const v = { ...DEFAULT_PRIMARY, ...(value || {}) };
  const set = (k) => (e) => onChange({ ...v, [k]: e.target.value });
  return (
    <div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
      <div style={{ flex: "1 1 130px" }}>
        <Field label="Stroke">
          <select className="pd-in" value={v.stroke} onChange={set("stroke")}>
            {PRIMARY_STROKES.map(([k, l]) => <option key={k} value={k}>{l}</option>)}
          </select>
        </Field>
      </div>
      <div style={{ flex: "1 1 110px" }}>
        <Field label="Type">
          <select className="pd-in" value={v.mode} onChange={set("mode")}>
            {PRIMARY_MODES.map(([k, l]) => <option key={k} value={k}>{l}</option>)}
          </select>
        </Field>
      </div>
      <div style={{ flex: "1 1 130px" }}>
        <Field label="Effort">
          <select className="pd-in" value={v.zone} onChange={set("zone")}>
            {COLOR_SYSTEM.slice().reverse().map((z) => (
              <option key={z.key} value={z.key}>{zoneLabel(z.key, vocab)}</option>
            ))}
            <option value="none">Leave untagged</option>
          </select>
        </Field>
      </div>
    </div>
  );
}

const emptyDraft = () => ({ id: null, title: "", date: today(), doc: blankDoc(),
  seasonId: null, course: "scy", primary: { ...DEFAULT_PRIMARY } });

/* Times are stored against the test and the swimmer, never the column id —
   columns are rebuilt whenever a block is split or adapted. */
const resultsOf = (rec) => (rec && rec.results) || {};

/* The set a test was actually swum as, read back off the practice. A plan says
   what should happen; this says what did. */
function testShape(p, testId, cfg) {
  const doc = toDoc(p.doc || p.text);
  let cell = null;
  doc.blocks.forEach((b) => b.cells.forEach((c) => { if (c.testId === testId && !cell) cell = c; }));
  if (!cell) return null;
  const lead = parsePractice(cell.text, primed(cfg, p)).rows.filter((r) => r.kind === "set")[0];
  if (!lead) return null;
  return { reps: lead.totalReps, dist: lead.dist, planned: !!cell.checkpointId,
    label: `${lead.totalReps}x${lead.dist}` };
}
function readResult(rec, testId, swimmerId) {
  const t = resultsOf(rec)[testId];
  return (t && t[swimmerId]) || [];
}
function writeResult(rec, testId, swimmerId, idx, secs) {
  const all = { ...resultsOf(rec) };
  const forTest = { ...(all[testId] || {}) };
  const list = [...(forTest[swimmerId] || [])];
  while (list.length <= idx) list.push(null);
  list[idx] = secs;
  forTest[swimmerId] = list.every((x) => x == null) ? undefined : list;
  if (forTest[swimmerId] === undefined) delete forTest[swimmerId];
  all[testId] = forTest;
  return all;
}
const meanOf = (list) => {
  const v = (list || []).filter((x) => typeof x === "number" && isFinite(x));
  return v.length ? v.reduce((a, b) => a + b, 0) / v.length : null;
};

// Every test column in a practice, with the reps and goal each swimmer is chasing.
function testColumns(doc, tests, season, cfg, sameCourse) {
  const out = [];
  doc.blocks.forEach((b) => b.cells.forEach((c) => {
    if (!c.testId) return;
    const test = tests.filter((t) => t.id === c.testId)[0];
    if (!test) return;
    const g = doc.groups.filter((x) => x.id === c.groups[0])[0];
    const rows = parsePractice(c.text, { ...cfg, basePace: (g && g.base) || DEFAULT_BASE }).rows
      .filter((r) => r.kind === "set");
    const lead = rows[0] || null;
    const names = c.groups.map((id) => (doc.groups.filter((x) => x.id === id)[0] || {}).name);
    const swimmers = season && sameCourse
      ? names.reduce((acc, n) => acc.concat(rosterForGroupName(season, n)), [])
      : [];
    const mode = testMode(test, c.text);
    out.push({
      cell: c, test, names, swimmers, mode, cpId: c.checkpointId || null,
      allReps: lead ? lead.totalReps : 1,
      reps: mode === "rep" && lead ? Math.min(lead.totalReps, 24) : 1,
      dist: lead ? lead.dist : null, zone: lead ? lead.zone : null, pct: lead ? lead.pct : null,
      title: (c.text.split("\n").filter((l) => l.trim())[0] || test.name).replace(/:$/, "").trim(),
    });
  }));
  return out;
}
const columnGoal = (col, sw) => col.zone && PACE_MULT[col.zone]
  ? aerobicGoal(sw.tPace, col.zone, col.dist)
  : (col.pct ? sprintGoal(sw.free50, col.pct, col.dist) : null);

function recordFrom(d, cfg) {
  const per = docTotals(d.doc, primed(cfg, d));
  const byGroup = d.doc.groups.map((g) => ({
    gid: g.id, name: g.name, yards: per[g.id].yards, seconds: per[g.id].seconds,
    stroke: per[g.id].stroke, zone: per[g.id].zone, mode: per[g.id].mode, gear: per[g.id].gear,
  }));
  const top = byGroup.reduce((a, b) => (b.yards > a.yards ? b : a), { yards: 0, seconds: 0 });
  return { id: d.id, date: d.date, title: (d.title || "").trim(), doc: d.doc, byGroup,
    yards: top.yards, seconds: top.seconds, seasonId: d.seasonId || null,
    course: toCourse(d.course), results: d.results || {}, primary: primaryOf(d),
    attendance: d.attendance || "none", roll: d.roll || {} };
}

/* Practices are written against a group by name, but names change. Matching on
   the id first means renaming a group does not strand the history behind it. */
function groupEntry(p, g) {
  const bg = (p && p.byGroup) || [];
  if (!g) return null;
  return bg.filter((x) => x.gid && x.gid === g.id)[0] || bg.filter((x) => x.name === g.name)[0] || null;
}

const EMPTY_GROUP = (name) =>
  ({ name, yards: 0, seconds: 0, stroke: {}, zone: {}, mode: {}, gear: {} });

/* `sel` is a group id where one is known, and a bare name otherwise, so both
   a renamed group and an older practice resolve to the same row. */
function pickGroup(p, sel, group) {
  const bg = p.byGroup && p.byGroup.length ? p.byGroup
    : [{ name: "Everyone", yards: p.yards || 0, seconds: p.seconds || 0,
         stroke: p.stroke || {}, zone: p.zone || {}, mode: p.mode || {}, gear: p.gear || {} }];
  if (!sel || sel === "__top__") return bg.reduce((a, b) => (b.yards > a.yards ? b : a));
  const g = group || { id: sel, name: sel };
  return bg.filter((x) => x.gid && x.gid === g.id)[0]
    || bg.filter((x) => x.name === g.name)[0]
    || EMPTY_GROUP(g.name);
}

/* ============================================================
   4c. SEASONS
   A season owns its calendar, the group structures practices are
   written against, and the roster those groups are drawn from.
   ============================================================ */

function makeStructure(name, groupNames) {
  return { id: uid(), name, groups: groupNames.map((n, i) => newGroup(n, i)) };
}
function makeSeason(name, startDate, weekCount) {
  const st = makeStructure("Whole team", ["Everyone"]);
  const start = mondayOf(startDate || today());
  return { id: uid(), name: name || "New season", startDate: start, primary: st.id,
    course: "scy", structures: [st], roster: [], meets: [], trainingDays: [1, 2, 3, 4, 5],
    weeks: Array.from({ length: weekCount || 16 }, (_, i) =>
      ({ id: uid(), start: addDays(start, i * 7), targets: {}, focus: "" })) };
}
// A single plan from before seasons existed becomes the first season, intact.
function upgradeSeasons(seasons, legacy) {
  if (seasons && seasons.length) return seasons;
  if (legacy && legacy.weeks && legacy.weeks.length) {
    const st = makeStructure("Whole team", ["Everyone"]);
    const gid = st.groups[0].id;
    return [{ id: uid(), name: legacy.name || "Season", startDate: legacy.startDate || today(),
      primary: st.id, structures: [st], roster: [], meets: [], trainingDays: [1, 2, 3, 4, 5],
      weeks: legacy.weeks.map((w) => ({ id: w.id || uid(), start: w.start, focus: w.focus || "",
        targets: { [gid]: Number(w.target) || 0 } })) }];
  }
  return [];
}
const primaryStructure = (season) =>
  (season && season.structures && (season.structures.filter((s) => s.id === season.primary)[0] || season.structures[0])) || null;

// Practices record group names, so that is the join back to a season's groups.
/* Keyed by group id where there is one, and by name as well so anything
   written before ids were stored still lines up. */
function weekActuals(log) {
  const m = {};
  log.forEach((p) => {
    const k = mondayOf(p.date);
    if (!m[k]) m[k] = { __total: 0 };
    (p.byGroup || []).forEach((g) => {
      if (g.gid) m[k][g.gid] = (m[k][g.gid] || 0) + g.yards;
      m[k][g.name] = (m[k][g.name] || 0) + g.yards;
    });
    m[k].__total += (p.byGroup || []).reduce((a, g) => (g.yards > a ? g.yards : a), 0);
  });
  return m;
}
const weekFor = (acts, start, g) => {
  const w = acts[start] || {};
  if (!g) return 0;
  return g.id && w[g.id] != null ? w[g.id] : (w[g.name] || 0);
};

// Rebuild a practice's columns from a season's structure, keeping the writing.
function applyStructure(doc, structure) {
  const groups = structure.groups.map((g) => ({ id: g.id, name: g.name, color: g.color, base: g.base || DEFAULT_BASE }));
  const ids = groups.map((g) => g.id);
  const blocks = doc.blocks.map((b) => {
    if (b.cells.length === 1)
      return { ...b, cells: [{ id: uid(), groups: [...ids], text: b.cells[0].text }] };
    return { ...b, cells: groups.map((g, i) => ({ id: uid(), groups: [g.id],
      text: (b.cells[i] || b.cells[b.cells.length - 1]).text })) };
  });
  return normalize({ ...doc, groups, blocks });
}

/* ---- portable seasons -------------------------------------------------
   A bundle is the season plus everything that belongs to it. Plain JSON,
   readable in any text editor, so a coach's work outlives this app.       */

function seasonRange(season) {
  const w = (season && season.weeks) || [];
  if (!w.length) return { from: season ? season.startDate : today(), to: season ? season.startDate : today() };
  return { from: w[0].start, to: addDays(w[w.length - 1].start, 6) };
}
function practicesInSeason(log, season) {
  if (!season) return log;
  const { from, to } = seasonRange(season);
  return log.filter((p) =>
    p.seasonId === season.id || (!p.seasonId && p.date >= from && p.date <= to));
}
function buildBundle(season, log, cfg) {
  return {
    format: "practicedesk.season", version: 1,
    exportedAt: new Date().toISOString(),
    course: season.course || "scy",
    season, practices: practicesInSeason(log, season),
  };
}
function readBundle(text) {
  const b = JSON.parse(text);
  if (!b || b.format !== "practicedesk.season" || !b.season)
    throw new Error("That does not look like a Practice Desk season file.");
  return b;
}
// Imported work never overwrites what is already here.
function mergeBundle(bundle, seasons, log) {
  const taken = new Set(seasons.map((s) => s.id));
  const season = JSON.parse(JSON.stringify(bundle.season));
  const clash = taken.has(season.id);
  if (clash) { season.id = uid(); season.name = `${season.name} (imported)`; }
  const known = new Set(log.map((p) => p.id));
  const added = (bundle.practices || []).map((p) => {
    const q = { ...p, seasonId: season.id };
    if (known.has(q.id)) q.id = uid();
    return upgradeRecord(q);
  });
  return { seasons: [...seasons, season], log: [...log, ...added],
    summary: { name: season.name, practices: added.length, renamed: clash,
      swimmers: (season.roster || []).filter((r) => r.name && r.name.trim()).length } };
}
function downloadJSON(filename, data) {
  try {
    const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url; a.download = filename;
    document.body.appendChild(a); a.click();
    setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 800);
    return true;
  } catch (e) { return false; }
}
const slug = (s) => String(s || "season").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");

/* ---- attendance ---- */
const ATT_KINDS = [["mandatory", "Mandatory"], ["optional", "Optional"], ["none", "Not tracked"]];
const ATT_MARKS = [["P", "Present"], ["E", "Excused"], ["U", "Unexcused"]];
const EXCUSED_RULES = [["neutral", "Does not count either way"],
  ["present", "Counts as attending"], ["absent", "Counts as a miss"]];
const attKind = (p) => (p && p.attendance) || "none";
const MARK_INK = { P: "#2E9E7E", E: "#D19A17", U: "#CE3327" };
const rollOf = (p) => (p && p.roll) || {};
const attRule = (season) => ({ extraCredit: false, excused: "neutral", perWeek: 5,
  weeks: (season && (season.weeks || []).length) || 16, goal: 90,
  ...((season && season.attendance) || {}) });

/* One row per swimmer. Optional practices attended lift the numerator without
   lifting the denominator, so extra credit can carry someone past 100%. */
function seasonAttendance(season, log) {
  const rule = attRule(season);
  const target = (Number(rule.perWeek) || 0) * (Number(rule.weeks) || 0);
  const goal = Number(rule.goal) || 0;
  const held = practicesInSeason(log, season).filter((p) => attKind(p) !== "none");
  const st = primaryStructure(season);
  const groups = st ? st.groups : [];

  const rows = ((season && season.roster) || [])
    .filter((s) => s.name && s.name.trim())
    .map((sw) => {
      const g = groups.filter((x) => x.id === sw.groupId)[0];
      const gname = g ? g.name : null;
      const mine = held.filter((p) => !g || !!groupEntry(p, g));
      let mP = 0, mE = 0, mU = 0, oP = 0, oE = 0, blank = 0;
      mine.forEach((p) => {
        const mark = rollOf(p)[sw.id];
        if (!mark) { blank += 1; return; }
        if (attKind(p) === "mandatory") { if (mark === "P") mP += 1; else if (mark === "E") mE += 1; else mU += 1; }
        else if (mark === "P") oP += 1; else oE += 1;
      });

      const bonus = rule.extraCredit ? oP : 0;
      const kept = rule.excused === "present" ? mP + mE : mP;
      const denom = rule.excused === "neutral" ? mP + mU : mP + mE + mU;
      const pct = denom ? ((kept + bonus) / denom) * 100 : null;

      // How much room is left before the season goal is out of reach.
      const owed = Math.max(0, target - (rule.excused === "neutral" ? mE : 0));
      const need = Math.ceil((owed * goal) / 100);
      const left = Math.max(0, target - (mP + mE + mU));
      const slack = target && goal ? kept + bonus + left - need : null;

      return { sw, gname, mP, mE, mU, oP, oE, blank, pct, need, slack,
        mandatory: mP + mE + mU, optional: oP + oE };
    });
  return { rows, rule, target, goal, held: held.length,
    mandatoryHeld: held.filter((p) => attKind(p) === "mandatory").length };
}

/* ---- meets ---- */
const MEET_LEVELS = [["A", "A · championship"], ["B", "B · invitational"], ["C", "C · dual"]];
const MEET_INK = { A: "#E0402F", B: "#D19A17", C: "#8FA6AC" };
const meetLevel = (m) => (m && m.level) || "C";

const sortedMeets = (season) =>
  [...((season && season.meets) || [])].filter((m) => m.date)
    .sort((a, b) => (a.date < b.date ? -1 : 1));

const meetsInWeek = (season, weekStart) =>
  sortedMeets(season).filter((m) => mondayOf(m.date) === weekStart);

// The next meet on or after a given day, and how far out it is.
function nextMeetFrom(season, date) {
  const m = sortedMeets(season).filter((x) => x.date >= date)[0];
  if (!m) return null;
  const a = new Date(mondayOf(date) + "T00:00:00");
  const b = new Date(mondayOf(m.date) + "T00:00:00");
  const weeksOut = Math.round((b - a) / (7 * 86400000));
  const daysOut = Math.round(
    (new Date(m.date + "T00:00:00") - new Date(date + "T00:00:00")) / 86400000);
  return { ...m, weeksOut, daysOut };
}

const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
function seasonDates(season) {
  const days = season.trainingDays && season.trainingDays.length ? season.trainingDays : [1, 2, 3, 4, 5];
  const out = [];
  (season.weeks || []).forEach((w) => {
    for (let i = 0; i < 7; i++) {
      const d = addDays(w.start, i);
      const dow = new Date(d + "T00:00:00").getDay();
      if (days.indexOf(dow) >= 0) out.push(d);
    }
  });
  return out;
}


const SEED_TESTS = [
  { name: "T-30", mode: "one", text: "30:00 free for distance",
    note: "Total distance in thirty minutes. Average per 100 becomes the T pace." },
  { name: "Progressive 200s", mode: "total", progressive: true,
    step: { kind: "reps", by: 1, count: 6 }, text: "6x200 @ r:30 best average",
    note: "Adds a repeat each time it comes round." },
  { name: "10x100 best average", mode: "rep", text: "10x100 @ 1:30 best average", note: "" },
  { name: "5x100 descend", mode: "rep", text: "5x100 @ 1:40 descend 1-5", note: "" },
  { name: "Broken 200", mode: "one", text: "200 broken at the 50s, 10 seconds rest",
    note: "Add ten seconds per break to the total." },
];
const seedTests = () => SEED_TESTS.map((t) =>
  ({ progressive: false, step: { kind: "reps", by: 1, count: 6 }, note: "", ...t, id: uid() }));

const BREAKS = [
  { group: "Break", name: "1 min", text: "Break 1:00" },
  { group: "Break", name: "2 min", text: "Break 2:00" },
  { group: "Break", name: "5 min", text: "Break 5:00" },
  { group: "Break", name: "10 min", text: "Break 10:00" },
];

// Breaks are the one thing every copy starts with.
function withBreaks(lib) {
  const have = new Set(lib.filter((s) => s.group === "Break").map((s) => s.name));
  const add = BREAKS.filter((b) => !have.has(b.name)).map((b) => ({ ...b, id: uid() }));
  return add.length ? [...lib, ...add] : lib;
}

/* ============================================================
   4d. PERIODISATION
   The ten colors roll up into the seven codes coaches plan in.
   Each group carries its own block plan: a run of weeks, and the
   share of distance each zone should take across them.
   ============================================================ */

const CODES = ["REC", "EN1", "EN2", "EN3", "SP1", "SP2", "SP3"];
const ZONE_CODE = {};
COLOR_SYSTEM.forEach((z) => (ZONE_CODE[z.key] = z.code.replace("+", "")));
Object.entries(LEGACY_ZONE).forEach(([k, v]) => (ZONE_CODE[k] = ZONE_CODE[v]));
const CODE_COLOR = { REC: "#A9CEDC", EN1: "#F4F9FB", EN2: "#F04438", EN3: "#A66BE8",
  SP1: "#F2B01E", SP2: "#4FBF5F", SP3: "#CBD5E1" };
const CODE_NAME = { REC: "Recovery", EN1: "Aerobic base", EN2: "Threshold",
  EN3: "Aerobic power", SP1: "Lactate tolerance", SP2: "Lactate production", SP3: "Speed" };

/* A planning code can cover more than one color — EN1 is White and Pink,
   EN2 is Red and Blue — so labels are built from whatever sits inside. */
const CODE_MEMBERS = {};
CODES.forEach((c) => (CODE_MEMBERS[c] = []));
[...COLOR_SYSTEM].reverse().forEach((z) => {
  const c = z.code.replace("+", "");
  if (CODE_MEMBERS[c]) CODE_MEMBERS[c].push(z);
});
function codeLabel(code, vocab) {
  const ms = CODE_MEMBERS[code] || [];
  if (!ms.length) return code;
  if (vocab === "name") return CODE_NAME[code] || code;
  const parts = vocab === "color"
    ? ms.map((z) => z.color.replace(/\s*\d+$/, ""))   // Clear 1 + Clear 2 -> Clear
    : ms.map((z) => z.code);
  return [...new Set(parts)].join("/");
}
const codeSecond = (code, vocab) =>
  vocab === "name" ? codeLabel(code, "code") : CODE_NAME[code];

/* Starting shapes. Distance leans on EN1/EN2 and barely touches the sprint
   zones; sprint carries less aerobic volume and far more SP work — the two
   archetypes Maglischo describes. Middle distance sits between them. */
const PHASES = {
  distance: {
    open:  { REC: 16, EN1: 40, EN2: 20, EN3: 12, SP1: 6, SP2: 3, SP3: 3 },
    base:  { REC: 18, EN1: 52, EN2: 16, EN3: 8,  SP1: 3, SP2: 2, SP3: 1 },
    build: { REC: 17, EN1: 48, EN2: 20, EN3: 9,  SP1: 3, SP2: 2, SP3: 1 },
    peak:  { REC: 16, EN1: 43, EN2: 23, EN3: 11, SP1: 4, SP2: 2, SP3: 1 },
    taper: { REC: 18, EN1: 44, EN2: 20, EN3: 10, SP1: 4, SP2: 2, SP3: 2 },
  },
  mid: {
    open:  { REC: 15, EN1: 35, EN2: 20, EN3: 13, SP1: 7, SP2: 5, SP3: 5 },
    base:  { REC: 21, EN1: 48, EN2: 13, EN3: 8,  SP1: 4, SP2: 3, SP3: 3 },
    build: { REC: 19, EN1: 44, EN2: 16, EN3: 11, SP1: 4, SP2: 3, SP3: 3 },
    peak:  { REC: 17, EN1: 39, EN2: 20, EN3: 13, SP1: 5, SP2: 3, SP3: 3 },
    taper: { REC: 17, EN1: 40, EN2: 18, EN3: 12, SP1: 5, SP2: 4, SP3: 4 },
  },
  sprint: {
    open:  { REC: 18, EN1: 32, EN2: 16, EN3: 12, SP1: 10, SP2: 6, SP3: 6 },
    base:  { REC: 22, EN1: 42, EN2: 14, EN3: 10, SP1: 6,  SP2: 3, SP3: 3 },
    build: { REC: 21, EN1: 38, EN2: 14, EN3: 12, SP1: 7,  SP2: 4, SP3: 4 },
    peak:  { REC: 20, EN1: 33, EN2: 13, EN3: 13, SP1: 9,  SP2: 6, SP3: 6 },
    taper: { REC: 21, EN1: 32, EN2: 11, EN3: 12, SP1: 10, SP2: 7, SP3: 7 },
  },
};

function archetypeFor(name) {
  const n = String(name || "").toLowerCase();
  if (/sprint|speed|\b50\b|\b100\b/.test(n)) return "sprint";
  if (/dist|mile|distance|\b500\b|\b1000\b|\b1650\b|\b800\b|\b1500\b/.test(n)) return "distance";
  return "mid";
}

// A default plan: open, base, build, peak, taper, sized to the season.
function seedPeriod(weeks, arch) {
  const P = PHASES[arch] || PHASES.mid;
  const n = Math.max(1, weeks || 16);
  if (n <= 4) return [{ id: uid(), name: "Season", span: n, mix: { ...P.build } }];
  const taper = Math.max(2, Math.round(n * 0.18));
  const open = n >= 10 ? 1 : 0;
  const rest = n - taper - open;
  const base = Math.max(1, Math.round(rest * 0.36));
  const build = Math.max(1, Math.round(rest * 0.32));
  const peak = Math.max(0, rest - base - build);
  const out = [];
  if (open) out.push({ name: "Open", span: open, mix: { ...P.open } });
  out.push({ name: "Base", span: base, mix: { ...P.base } });
  out.push({ name: "Build", span: build, mix: { ...P.build } });
  if (peak) out.push({ name: "Peak", span: peak, mix: { ...P.peak } });
  out.push({ name: "Taper", span: taper, mix: { ...P.taper } });
  return out.map((b) => ({ ...b, id: uid() }));
}

const periodFor = (group, weeks) =>
  (group && group.period && group.period.length)
    ? group.period
    : seedPeriod(weeks, archetypeFor(group && group.name));

function blockForWeek(period, i) {
  let acc = 0;
  for (const b of period) { acc += Math.max(1, b.span || 1); if (i < acc) return b; }
  return period[period.length - 1] || null;
}
const mixTotal = (mix) => CODES.reduce((a, c) => a + (Number(mix[c]) || 0), 0);

// Push the difference into whichever zone can carry it.
function balanceMix(mix) {
  const delta = 100 - mixTotal(mix);
  if (!delta) return mix;
  const order = [...CODES].sort((a, b) => (Number(mix[b]) || 0) - (Number(mix[a]) || 0));
  const target = order.filter((c) => (Number(mix[c]) || 0) + delta >= 0)[0] || order[0];
  return { ...mix, [target]: Math.max(0, (Number(mix[target]) || 0) + delta) };
}

// Fold a practice's zone map into the seven planning codes.
function toCodes(zoneMap) {
  const out = {};
  CODES.forEach((c) => (out[c] = 0));
  let untagged = 0;
  Object.entries(zoneMap || {}).forEach(([k, v]) => {
    const c = ZONE_CODE[k];
    if (c) out[c] += v; else untagged += v;
  });
  return { codes: out, untagged };
}

/* ============================================================
   4e. PACING
   Lifted from the Pace Modifiers sheet of the color workbook.
   A swimmer's T-pace in seconds, times the multiplier for the
   zone and distance, is their goal time. Reproduces the sheet
   to within four milliseconds.
   ============================================================ */

const PACE_DISTANCES = [50, 75, 100, 125, 150, 175, 200, 250, 300, 400, 500];
const PACE_MULT = {
  clear1: [0.4927, 0.7745, 1.0636, 1.3364, 1.6091, 1.8836, 2.1564, 2.7055, 3.2545, 4.3545, 5.4546],
  clear2: [0.4764, 0.7564, 1.0291, 1.3036, 1.5782, 1.8527, 2.1291, 2.6691, 3.2127, 4.3109, 5.4436],
  white:  [0.4673, 0.7345, 1.0091, 1.2673, 1.5255, 1.7836, 2.0436, 2.5636, 3.0836, 4.1254, 5.1691],
  pink:   [0.4509, 0.7164, 0.9745, 1.2345, 1.4945, 1.7564, 2.0164, 2.5291, 3.0436, 4.0836, 5.1582],
  red:    [0.4436, 0.6982, 0.9582, 1.2036, 1.4491, 1.6945, 1.9418, 2.4345, 2.9291, 3.9200, 4.9091],
  blue:   [0.4291, 0.6800, 0.9273, 1.1727, 1.4200, 1.6673, 1.9164, 2.4018, 2.8909, 3.8800, 4.9000],
  purple: [0.4127, 0.6556, 0.8927, 1.1309, 1.3691, 1.6091, 1.8473, 2.3164, 2.7891, 3.7418, 4.7255],
};
const PACE_ZONES = ["clear1", "clear2", "white", "pink", "red", "blue", "purple"];
const T_TESTS = ["T-30", "T-20", "T-15", "T-10"];

/* A test set is a block you mean to time. Naming it from a library is what
   lets October's 10x100 and January's be compared. */
const TEST_MODES = [
  ["total", "Total time for the set"],
  ["rep", "A time for every rep"],
  ["one", "One time, as swum"],
];
const STEP_KINDS = [["reps", "Add a repeat"], ["dist", "Add distance to each repeat"],
  ["rest", "Take time off the rest"], ["none", "Stays the same"]];

const newTest = (name, text) => ({ id: uid(), name: name || "New test", mode: "auto",
  note: "", text: text || "", progressive: false,
  step: { kind: "reps", by: 1, count: 6 } });

/* A best average swum on rest needs a start and a finish, so one total is
   enough. On a send-off you want every repeat. The set itself says which. */
function testMode(test, text) {
  if (test && test.mode && test.mode !== "auto") return test.mode;
  const rows = parsePractice(text || (test && test.text) || "", { basePace: 90 }).rows
    .filter((r) => r.kind === "set");
  const lead = rows[0];
  if (!lead || lead.totalReps < 2) return "one";
  return lead.interval ? "rep" : "total";
}

/* Grow a set by one step. Only the first set line moves; anything else in the
   block is left exactly as written. */
function stepText(text, step) {
  const kind = (step && step.kind) || "reps";
  const by = Number(step && step.by) || 1;
  if (kind === "none") return text;
  const lines = text.split("\n");
  const rows = parsePractice(text, { basePace: 90 }).rows.filter((r) => r.kind === "set");
  const first = rows[0];
  if (!first) return text;
  const line = lines[first.i];
  const m = line.match(RE_REPS);
  if (!m) return text;
  const ws = line.slice(0, line.length - line.replace(/^\s*/, "").length);
  let tail = line.slice(m[0].length);
  let reps = first.reps, dist = first.dist;

  if (kind === "reps") reps = Math.max(1, reps + by);
  else if (kind === "dist") dist = Math.max(25, dist + by);
  else if (kind === "rest" && first.rest != null) {
    const r = Math.max(0, first.rest - by);
    tail = tail.replace(RE_REST, (mm) => mm.replace(/\d{1,2}:\d{2}|:\d{2}|\d{1,3}/,
      r >= 60 ? clockText(r) : ":" + String(r).padStart(2, "0")));
  } else if (kind === "rest" && first.interval) {
    tail = tail.replace(RE_INT, "@ " + clockText(Math.max(5, first.interval - by)));
  }
  return lines.map((l, i) => (i === first.i
    ? ws + (reps > 1 ? reps + "x" : "") + dist + tail : l)).join("\n");
}

/* Build the checkpoints a plan entry produces. Weeks come either from a repeat
   every so often, or from a list the coach types. */
function buildCheckpoints(test, entry) {
  const weeks = entry.weeks && entry.weeks.length
    ? entry.weeks.slice().sort((a, b) => a - b)
    : Array.from({ length: Math.max(1, Number(entry.count) || 1) },
        (_, i) => (Number(entry.start) || 1) + i * Math.max(1, Number(entry.every) || 1));
  let text = (entry.text || (test && test.text) || "").trim();
  const steps = Math.max(1, Number(test && test.step && test.step.count) || weeks.length);
  return weeks.map((w, i) => {
    const out = { id: uid(), week: w, text };
    if (test && test.progressive) {
      // once the pattern runs out it holds, rather than growing forever
      text = i + 1 < steps ? stepText(text, test.step) : text;
    }
    return out;
  });
}

const newPlanEntry = (testId) => ({ id: uid(), testId, start: 1, every: 1, count: 6,
  weeks: null, text: "", checkpoints: [] });
const newTestPlan = (name) => ({ id: uid(), name: name || "Testing plan", groupIds: [], entries: [] });

const testPlansFor = (season, groupId) =>
  ((season && season.testPlans) || []).filter((p) =>
    !p.groupIds.length || p.groupIds.indexOf(groupId) >= 0);

// every checkpoint due in a given week, for the groups in this practice
function checkpointsForWeek(season, weekIndex, groupIds, tests) {
  const out = [];
  ((season && season.testPlans) || []).forEach((plan) => {
    const applies = !plan.groupIds.length ||
      (groupIds || []).some((g) => plan.groupIds.indexOf(g) >= 0);
    if (!applies) return;
    (plan.entries || []).forEach((entry) => {
      const test = tests.filter((t) => t.id === entry.testId)[0];
      (entry.checkpoints || []).forEach((cp) => {
        if (cp.week === weekIndex + 1 && test) out.push({ plan, entry, cp, test });
      });
    });
  });
  return out;
}

// "1:12.5" / ":45.3" / "72.5" all mean the same thing.
function paceToSec(txt) {
  const s = String(txt == null ? "" : txt).trim();
  if (!s) return null;
  const m = s.match(/^(?:(\d+):)?(\d{1,2}(?:\.\d+)?)$/);
  if (!m) return null;
  const sec = (parseInt(m[1] || "0", 10) || 0) * 60 + parseFloat(m[2]);
  return isFinite(sec) && sec > 0 ? sec : null;
}
function secToPace(sec, hundredths) {
  if (sec == null || !isFinite(sec)) return "—";
  const m = Math.floor(sec / 60);
  const r = sec - m * 60;
  const body = hundredths ? r.toFixed(2).padStart(5, "0") : String(Math.round(r)).padStart(2, "0");
  return m ? `${m}:${body}` : (hundredths ? r.toFixed(2) : String(Math.round(r)));
}

/* Goal time for an aerobic rep. Distances between the charted ones are
   interpolated; anything outside 50–500 gets nothing rather than a guess. */
function aerobicGoal(tSec, zoneKey, dist) {
  const row = PACE_MULT[zoneKey];
  if (!row || !tSec || !dist) return null;
  const i = PACE_DISTANCES.indexOf(dist);
  if (i >= 0) return tSec * row[i];
  if (dist < PACE_DISTANCES[0] || dist > PACE_DISTANCES[PACE_DISTANCES.length - 1]) return null;
  for (let k = 0; k < PACE_DISTANCES.length - 1; k++) {
    const a = PACE_DISTANCES[k], b = PACE_DISTANCES[k + 1];
    if (dist > a && dist < b) {
      const t = (dist - a) / (b - a);
      return tSec * (row[k] + (row[k + 1] - row[k]) * t);
    }
  }
  return null;
}

// Green work is set against a share of the swimmer's 50: PB divided by the percentage.
const SPRINT_25_SHARE = 0.465;   // first 25 of a 50, from the workbook
function sprintGoal(free50Sec, pct, dist) {
  if (!free50Sec || !pct || pct <= 0) return null;
  const fifty = free50Sec / (pct / 100);
  if (dist === 50) return fifty;
  if (dist === 25) return fifty * SPRINT_25_SHARE;
  return null;
}


/* ============================================================
   5. SMALL PIECES
   ============================================================ */

function Rope({ data, colorKey = "", order }) {
  let entries = Object.entries(data).filter(([, v]) => v > 0);
  entries = order
    ? entries.sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0]))
    : entries.sort((a, b) => b[1] - a[1]);
  const total = entries.reduce((a, b) => a + b[1], 0);
  if (!total) return <div className="pd-rope"><div style={{ flex: 1, background: "var(--soft)" }} /></div>;
  return (
    <div className="pd-rope" role="img" aria-label="Distance split">
      {entries.map(([k, v]) => (
        <div key={k} style={{ flex: v, background: COLOR[colorKey + k] || COLOR.untagged }} />
      ))}
    </div>
  );
}

function Breakdown({ data, labels, colorKey = "", total, order }) {
  let entries = Object.entries(data).filter(([, v]) => v > 0);
  entries = order
    ? entries.sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0]))
    : entries.sort((a, b) => b[1] - a[1]);
  if (!entries.length) return <div style={{ color: "var(--muted)", fontSize: 12, padding: "6px 0" }}>Nothing tagged yet.</div>;
  return (
    <div>
      {entries.map(([k, v]) => (
        <div key={k} style={{ padding: "6px 0", borderBottom: "1px solid var(--soft)" }}>
          <div className="pd-brk" style={{ border: 0, padding: 0 }}>
            <span style={{ display: "flex", alignItems: "center", gap: 7 }}>
              <i className="pd-dot" style={{ background: COLOR[colorKey + k] || COLOR.untagged }} />
              {labels[k] || k}
            </span>
            <span className="pd-num" style={{ color: "var(--muted)" }}>
              {comma(v)} <em style={{ fontStyle: "normal", opacity: .6 }}>({Math.round((v / total) * 100)}%)</em>
            </span>
          </div>
          <div className="pd-bar">
            <i style={{ width: `${(v / total) * 100}%`, background: COLOR[colorKey + k] || COLOR.untagged }} />
          </div>
        </div>
      ))}
    </div>
  );
}

function TrackPicker({ opts, cfg, setCfg, inline }) {
  if (!opts.length && !inline) return null;
  return (
    <label style={{ display: "flex", alignItems: "center", gap: 8, marginLeft: inline ? 0 : "auto" }}>
      <span className="pd-eyebrow">Counting</span>
      <select className="pd-in" style={{ width: 170 }} value={cfg.track}
        onChange={(e) => setCfg({ ...cfg, track: e.target.value })}>
        <option value="__top__">Highest group</option>
        {opts.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
      </select>
    </label>
  );
}

function Field({ label, children }) {
  return (
    <label style={{ display: "block", marginBottom: 12 }}>
      <div className="pd-eyebrow" style={{ marginBottom: 5 }}>{label}</div>
      {children}
    </label>
  );
}

/* ============================================================
   6. WRITE
   ============================================================ */

// When one column runs short of the longest, say roughly how much to add.
function syncHint(parsed, gap) {
  const sets = parsed.rows.filter((r) => r.kind === "set" && r.interval);
  if (!sets.length) return "";
  const big = sets.reduce((a, b) => (b.totalReps * b.interval > a.totalReps * a.interval ? b : a));
  const n = Math.round(gap / big.interval);
  return n >= 1 ? ` · try +${n}×${big.dist}` : "";
}

function railLabel(r) {
  if (r.kind === "set") return comma(r.yards);
  if (r.kind === "break") return fmt(r.seconds);
  if (r.kind === "rounds") return "×" + r.rounds;
  return "";
}

function WriteTab({ lib, cfg, tests, setTests, season, seasonLog, draft, setDraft,
  onPrint, onSlides, onEdit, onClose, saveState, weekStat }) {
  const doc = draft.doc;
  const setDoc = (d) => setDraft({ ...draft, doc: d });

  const [cat, setCat] = useState("Warm up");
  const [view, setView] = useState("stroke");
  const [zoneView, setZoneView] = useState("today");
  const [showRoll, setShowRoll] = useState(false);
  const [showTests, setShowTests] = useState(false);
  const [focusG, setFocusG] = useState(null);
  const caret = useRef({ blockId: null, cellId: null, el: null });

  const parsed = useMemo(() => {
    const m = {};
    doc.blocks.forEach((b) => b.cells.forEach((c) => {
      const g = doc.groups.filter((x) => x.id === c.groups[0])[0];
      m[c.id] = parsePractice(c.text, { ...primed(cfg, draft), basePace: (g && g.base) || DEFAULT_BASE });
    }));
    return m;
  }, [doc, cfg]);
  const per = useMemo(() => docTotals(doc, primed(cfg, draft)), [doc, cfg, draft]);
  const sameCourse = !!season && toCourse(draft.course) === toCourse(season.course);
  const testCols = useMemo(() => testColumns(doc, tests, season, cfg, sameCourse),
    [doc, tests, season, cfg, sameCourse]);
  const weekIndex = useMemo(() => {
    if (!season) return -1;
    return (season.weeks || []).findIndex((w) => w.start === mondayOf(draft.date || today()));
  }, [season, draft.date]);
  const planned = useMemo(() => (season && weekIndex >= 0)
    ? checkpointsForWeek(season, weekIndex, doc.groups.map((g) => g.id), tests) : [],
    [season, weekIndex, doc.groups, tests]);

  /* What this group did the last time they met this test. On a progressive set
     the pace goal barely moves while the set gets harder, so beating your own
     last average is the target that means something. */
  const history = useMemo(() => {
    const m = {};
    seasonLog.filter((p) => p.id !== draft.id && p.date <= draft.date)
      .sort((a, b) => (a.date < b.date ? -1 : 1))
      .forEach((p) => Object.entries(p.results || {}).forEach(([tid, byId]) => {
        Object.entries(byId || {}).forEach(([sid, list]) => {
          const v = meanOf(list);
          if (v != null) m[`${tid}|${sid}`] = v;
        });
      }));
    return m;
  }, [seasonLog, draft.id, draft.date]);
  const prevOf = (col) => Object.keys(history).some((k) => k.indexOf(col.test.id + "|") === 0);
  const lastResult = (col, sid) => {
    const v = history[`${col.test.id}|${sid}`];
    return v == null ? null : (col.mode === "total" ? v / Math.max(1, col.allReps) : v);
  };

  const rollList = useMemo(() => {
    if (!season || attKind(draft) === "none") return [];
    return doc.groups.reduce((acc, g) => acc.concat(rosterForGroupName(season, g.name)), []);
  }, [season, doc, draft.attendance]);
  const marked = rollList.filter((sw) => (draft.roll || {})[sw.id]).length;

  const focus = focusG && per[focusG] ? focusG : (doc.groups[0] && doc.groups[0].id);
  const totals = per[focus] || emptyTot();

  /* ---- edits ---- */
  const setText = (blockId, cellId, text) => setDoc({ ...doc, blocks: doc.blocks.map((b) =>
    b.id !== blockId ? b : { ...b, cells: b.cells.map((c) => (c.id === cellId ? { ...c, text } : c)) }) });
  const baseOf = (g) => (g && g.base) || DEFAULT_BASE;
  const testName = (id) => (tests.filter((t) => t.id === id)[0] || {}).name || "Test";
  const markTest = (blockId, cellId, testId, text, cpId) => setDoc({ ...doc, blocks: doc.blocks.map((b) =>
    b.id !== blockId ? b : { ...b, cells: b.cells.map((c) => c.id !== cellId ? c
      : { ...c, testId: testId || null, checkpointId: cpId || null,
          text: text != null ? text : c.text }) }) });

  const pickTest = (b, c, value) => {
    if (value.indexOf("cp:") === 0) {
      const hit = planned.filter((x) => x.cp.id === value.slice(3))[0];
      if (hit) markTest(b.id, c.id, hit.test.id,
        !c.text.trim() ? hit.cp.text : undefined, hit.cp.id);
      return;
    }
    if (value === "__new__") {
      const head = (c.text.split("\n").filter((l) => l.trim())[0] || "Test set")
        .replace(/:$/, "").trim().slice(0, 40);
      const t = newTest(head, c.text);
      setTests([...tests, t]);
      markTest(b.id, c.id, t.id);
      return;
    }
    const t = tests.filter((x) => x.id === value)[0];
    // Dropping a test into an empty column brings its set with it.
    markTest(b.id, c.id, value || null, t && t.text && !c.text.trim() ? t.text : undefined);
  };

  const addBlockAfter = (idx) => {
    const blocks = [...doc.blocks];
    blocks.splice(idx + 1, 0, sharedBlock(doc));
    setDoc({ ...doc, blocks });
  };
  const removeBlock = (id) => doc.blocks.length > 1 &&
    setDoc({ ...doc, blocks: doc.blocks.filter((b) => b.id !== id) });

  /* ---- saved sets ---- */
  const cats = useMemo(() => {
    const saved = cfg.setCategories && cfg.setCategories.length ? cfg.setCategories : DEFAULT_CATS;
    const used = [...new Set(lib.map((s) => s.group || "Other"))];
    return [...saved, ...used.filter((u) => saved.indexOf(u) < 0)];
  }, [cfg.setCategories, lib]);
  const shown = useMemo(() => (cat === "All" ? lib : lib.filter((s) => (s.group || "Other") === cat)), [lib, cat]);
  useEffect(() => { if (cats.length && cat !== "All" && !cats.includes(cat)) setCat(cats[0]); }, [cats, cat]);

  const drop = (sn) => {
    const f = caret.current;
    let block = doc.blocks.find((b) => b.id === f.blockId);
    let cell = block && block.cells.find((c) => c.id === f.cellId);
    if (!cell) {
      block = doc.blocks[doc.blocks.length - 1];
      cell = block.cells[block.cells.length - 1];
    }
    const at = f.el && cell.id === f.cellId && f.el.selectionStart != null ? f.el.selectionStart : cell.text.length;
    const before = cell.text.slice(0, at), after = cell.text.slice(at);
    const lead = before && !before.endsWith("\n") ? "\n" : "";
    const tail = after && !after.startsWith("\n") ? "\n" : "";
    setText(block.id, cell.id, before + lead + sn.text + "\n" + tail + after);
    const pos = (before + lead + sn.text + "\n").length;
    const el = f.el;
    if (el && cell.id === f.cellId) requestAnimationFrame(() => { el.focus(); el.setSelectionRange(pos, pos); });
  };

  const zLabels = useMemo(() => zoneLabels(cfg.zoneVocab), [cfg.zoneVocab]);
  const views = { stroke: [totals.stroke, STROKE_LABEL, "", STROKE_ORDER], mode: [totals.mode, MODE_LABEL, "m_", null],
    zone: [totals.zone, zLabels, "z_", ZONE_ORDER] };
  const [vData, vLabels, vKey, vOrder] = views[view];

  // Where a set sits outside the workbook's repetition range for its color.
  const flags = useMemo(() => {
    const out = [];
    doc.blocks.forEach((b) => b.cells.forEach((c) => {
      if (!c.groups.includes(focus)) return;
      chartFlags(parsed[c.id].rows).forEach((f) => out.push(f));
    }));
    return out;
  }, [doc, parsed, focus]);
  const multi = doc.groups.length > 1;

  return (
    <div className="pd-grid">
      <div className="pd-col">
        {/* ---------- what this practice is ---------- */}
        <div className="pd-card pd-topbar">
          <button className="pd-btn" onClick={onClose} title="Save and go back to your practices"
            style={{ fontWeight: 600 }}>← Done</button>
          <button className="pd-btn" onClick={onEdit}>Edit</button>
          <button className="pd-btn" onClick={onPrint}>Print sheet</button>
          <button className="pd-btn" onClick={onSlides}>Generate slides</button>
          <span className="pd-topbar-id">
            <b>{draft.title || "Untitled practice"}</b>
            <span>{pretty(draft.date)}</span>
            <span>{courseName(draft.course)}</span>
            {doc.groups.map((g) => (
              <span key={g.id} className="pd-topbar-g">
                <i style={{ background: g.color }} />{g.name}
                <em>{clockText(baseOf(g))}</em>
              </span>
            ))}
          </span>
          <span className="pd-num" style={{ marginLeft: "auto", fontSize: 11,
            color: saveState === "saved" ? "var(--aqua)" : "var(--faint)" }}>
            {saveState === "saving" ? "saving…" : saveState === "saved" ? "saved" : ""}
          </span>
        </div>

        {/* ---------- saved sets ---------- */}
        <div className="pd-card pd-strip">
          <select value={cat} onChange={(e) => setCat(e.target.value)} aria-label="Saved set category">
            <option value="All">All sets</option>
            {cats.map((c) => <option key={c} value={c}>{c}</option>)}
          </select>
          <div className="pd-chips">
            {!shown.length && <div className="pd-none">Nothing saved under this heading yet.</div>}
            {shown.map((sn) => {
              const t = parsePractice(sn.text, cfg).totals;
              return (
                <button className="pd-snip" key={sn.id} onClick={() => drop(sn)} title="Insert at cursor">
                  {sn.name}<span>{t.yards ? comma(t.yards) : fmt(t.seconds)}</span>
                </button>
              );
            })}
          </div>
        </div>

        {/* ---------- blocks ---------- */}
        {doc.blocks.map((b, bi) => {
          const times = b.cells.map((c) => parsed[c.id].totals.seconds);
          const longest = Math.max(...times, 0);
          const isShared = b.cells.length === 1 && b.cells[0].groups.length === doc.groups.length;
          return (
            <div className="pd-seg" key={b.id}>
              <div className="pd-segbar">
                {multi && (
                  <>
                    <button className="pd-mini" disabled={isShared}
                      style={{ opacity: isShared ? .4 : 1 }}
                      onClick={() => setDoc(shareBlock(doc, b.id))}>Same for everyone</button>
                    <button className="pd-mini" disabled={b.cells.length === doc.groups.length}
                      style={{ opacity: b.cells.length === doc.groups.length ? .4 : 1 }}
                      onClick={() => setDoc(splitBlock(doc, b.id))}>Split by group</button>
                  </>
                )}
                <div style={{ marginLeft: "auto", display: "flex", gap: 6 }}>
                  <button className="pd-mini" onClick={() => addBlockAfter(bi)}>Add block below</button>
                  {doc.blocks.length > 1 &&
                    <button className="pd-mini" onClick={() => removeBlock(b.id)}>Remove</button>}
                </div>
              </div>

              <div className="pd-cells">
                {b.cells.map((c, ci) => {
                  const pr = parsed[c.id];
                  const t = pr.totals;
                  const gap = longest - t.seconds;
                  const lines = c.text.split("\n").length;
                  const tags = c.groups.map((gid) => doc.groups.find((g) => g.id === gid)).filter(Boolean);
                  return (
                    <div className="pd-cell" key={c.id} data-test={c.testId ? "1" : "0"}
                      style={{ flex: `${c.groups.length} 1 0` }}>
                      {(multi || !!tests.length) && (
                        <div className="pd-cellbar">
                          {multi && tags.map((g) => (
                            <span className="pd-lab" key={g.id} style={{ "--g": g.color }}>
                              <i style={{ background: g.color }} />{g.name}
                            </span>
                          ))}
                          {c.testId && (
                            <span className="pd-testtag" title={testName(c.testId)}>
                              ● {testName(c.testId)}
                              {weekIndex >= 0 && c.checkpointId ? ` · Wk ${weekIndex + 1}` : ""}
                            </span>
                          )}
                          <span style={{ marginLeft: "auto", display: "flex", gap: 5, alignItems: "center" }}>
                            <select className="pd-minisel" value={c.testId || ""} aria-label="Time this as a test"
                              title="Mark this column as a test set"
                              style={c.testId ? { borderColor: "var(--clock)", color: "var(--clock)" } : undefined}
                              onChange={(e) => pickTest(b, c, e.target.value)}>
                              <option value="">Not a test</option>
                              {!!planned.length && (
                                <optgroup label={`Planned for week ${weekIndex + 1}`}>
                                  {planned.map((x) => (
                                    <option key={x.cp.id} value={`cp:${x.cp.id}`}>{x.test.name}</option>
                                  ))}
                                </optgroup>
                              )}
                              <optgroup label="Any test">
                                {tests.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
                              </optgroup>
                              <option value="__new__">＋ New test from this…</option>
                            </select>
                            {b.cells.length > 1 && (
                              <select className="pd-minisel" value="" aria-label="Copy and adapt a set"
                                title="Copy another group's set and rework it for this one"
                                onChange={(e) => {
                                  const src = b.cells.filter((x) => x.id === e.target.value)[0];
                                  if (!src) return;
                                  const sg = doc.groups.filter((g) => g.id === src.groups[0])[0];
                                  const dg = doc.groups.filter((g) => g.id === c.groups[0])[0];
                                  setText(b.id, c.id, adaptText(src.text, baseOf(sg), baseOf(dg), cfg));
                                }}>
                                <option value="">Adapt from…</option>
                                {b.cells.filter((x) => x.id !== c.id).map((x) => (
                                  <option key={x.id} value={x.id}>
                                    {x.groups.map((id) => (doc.groups.filter((g) => g.id === id)[0] || {}).name).join(" + ")}
                                  </option>
                                ))}
                              </select>
                            )}
                            {c.groups.length > 1 && b.cells.length > 1 &&
                              <button className="pd-mini" onClick={() => setDoc(unmergeCell(doc, b.id, c.id))}>Unmerge</button>}
                            {ci < b.cells.length - 1 &&
                              <button className="pd-mini" onClick={() => setDoc(mergeRight(doc, b.id, c.id))}>Merge →</button>}
                          </span>
                        </div>
                      )}

                      <div className="pd-cellbody">
                        <textarea className="pd-cta" wrap="off" spellCheck={false} value={c.text}
                          style={{ height: lines * 26 + 16 }}
                          placeholder={bi === 0 ? "Warm up:\n  400 free easy\n  8x50 @ :50 drill" : "10x100 @ 1:30 free EN2"}
                          onFocus={(e) => (caret.current = { blockId: b.id, cellId: c.id, el: e.target })}
                          onChange={(e) => setText(b.id, c.id, e.target.value)} />
                        <div className="pd-crail">
                          {pr.rows.map((r, n) => <div key={n}>{railLabel(r)}</div>)}
                        </div>
                      </div>

                      <div className="pd-cellfoot">
                        <b style={{ color: "var(--aqua)", fontWeight: 600 }}>{comma(t.yards)}</b>
                        <span style={{ color: "var(--faint)" }}>{fmt(t.seconds)}{t.estimated ? "~" : ""}</span>
                        {b.cells.length > 1 && longest > 0 && (
                          gap <= 45
                            ? <span style={{ color: "var(--aqua)", marginLeft: "auto" }}>in sync</span>
                            : <span style={{ color: "var(--clock)", marginLeft: "auto" }}>
                                {fmt(gap)} short{syncHint(pr, gap)}
                              </span>
                        )}
                      </div>
                    </div>
                  );
                })}
              </div>
            </div>
          );
        })}

        {!!rollList.length && (
          <div className="pd-card">
            <button className="pd-fold" onClick={() => setShowRoll(!showRoll)}>
              <span className="pd-chev">{showRoll ? "▾" : "▸"}</span>
              <b>Attendance</b>
              <span className="pd-eyebrow" style={{ color: attKind(draft) === "optional" ? "#D19A17" : "var(--muted)" }}>
                {attKind(draft) === "optional" ? "Optional" : "Mandatory"}
              </span>
              <i style={{ color: marked < rollList.length ? "var(--clock)" : "var(--aqua)" }}>
                {marked} of {rollList.length} marked
              </i>
            </button>
            {showRoll && (
              <div style={{ borderTop: "1px solid var(--soft)" }}>
                <div style={{ display: "flex", gap: 8, padding: "9px 14px", flexWrap: "wrap" }}>
                  <button className="pd-mini" onClick={() => {
                    const roll = { ...(draft.roll || {}) };
                    rollList.forEach((sw) => (roll[sw.id] = "P"));
                    setDraft({ ...draft, roll });
                  }}>Everyone present</button>
                  <button className="pd-mini" onClick={() => setDraft({ ...draft, roll: {} })}>Clear</button>
                </div>
                {rollList.map((sw) => {
                  const mark = (draft.roll || {})[sw.id] || "";
                  const choices = attKind(draft) === "optional"
                    ? ATT_MARKS.filter(([k]) => k !== "U") : ATT_MARKS;
                  return (
                    <div className="pd-row" key={sw.id}
                      style={{ gridTemplateColumns: "minmax(0,1fr) auto", alignItems: "center" }}>
                      <div style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                        {sw.name}
                      </div>
                      <div className="pd-roll">
                        {choices.map(([k, label]) => (
                          <button key={k} title={label} data-on={mark === k ? "1" : "0"}
                            style={mark === k ? { background: MARK_INK[k] } : undefined}
                            onClick={() => setDraft({ ...draft,
                              roll: { ...(draft.roll || {}), [sw.id]: mark === k ? undefined : k } })}>
                            {k}
                          </button>
                        ))}
                      </div>
                    </div>
                  );
                })}
              </div>
            )}
          </div>
        )}

        {!!testCols.length && (
          <div className="pd-card" style={{ borderColor: "#A8453B" }}>
            <button className="pd-fold" onClick={() => setShowTests(!showTests)}
              style={{ borderBottom: showTests ? "1px solid #A8453B" : "0" }}>
              <span className="pd-chev">{showTests ? "▾" : "▸"}</span>
              <span className="pd-testtag">● Test results</span>
              <i>{testCols.length === 1 ? "1 test column" : `${testCols.length} test columns`}</i>
            </button>

            {showTests && testCols.map((col) => {
              const many = col.mode === "rep" && col.reps > 1;
              const isTotal = col.mode === "total";
              const grid = many
                ? `minmax(120px,1.4fr) 70px repeat(${col.reps},minmax(56px,1fr)) 74px`
                : `minmax(150px,1.6fr) 80px minmax(90px,120px) 90px`;
              const minW = many ? 300 + col.reps * 60 : 430;
              return (
                <div key={col.cell.id} style={{ borderBottom: "1px solid var(--soft)" }}>
                  <div style={{ padding: "9px 14px", display: "flex", gap: 10, alignItems: "baseline",
                    flexWrap: "wrap" }}>
                    <b style={{ fontWeight: 600 }}>{col.test.name}</b>
                    <span className="pd-num" style={{ fontSize: 11.5, color: "var(--muted)" }}>{col.title}</span>
                    <span className="pd-eyebrow" style={{ marginLeft: "auto" }}>{col.names.join(" + ")}</span>
                  </div>

                  {!col.swimmers.length ? (
                    <div style={{ padding: "0 14px 12px", fontSize: 12, color: "var(--muted)" }}>
                      No swimmers to record. Add a roster to the season, and check that the practice course
                      matches it.
                    </div>
                  ) : (
                    <div style={{ overflowX: "auto" }}>
                      <div className="pd-row pd-head" style={{ gridTemplateColumns: grid, minWidth: minW }}>
                        <div>Swimmer</div><div>{prevOf(col) ? "Last" : "Goal"}</div>
                        {many ? Array.from({ length: col.reps }, (_, k) => <div key={k}>{k + 1}</div>)
                              : <div>{isTotal ? `Total for ${col.allReps}` : "Time"}</div>}
                        <div>{many ? "Average" : isTotal ? "Average" : "vs goal"}</div>
                      </div>
                      {col.swimmers.map((sw) => {
                        const got = readResult(draft, col.test.id, sw.id);
                        const goal = columnGoal(col, sw);
                        const last = prevOf(col) ? lastResult(col, sw.id) : null;
                        const avg = many ? meanOf(got)
                          : typeof got[0] === "number"
                            ? (isTotal ? got[0] / Math.max(1, col.allReps) : got[0])
                            : null;
                        const delta = last != null && avg != null ? avg - last
                          : goal && avg != null ? avg - goal : null;
                        return (
                          <div className="pd-row" key={sw.id} style={{ gridTemplateColumns: grid, minWidth: minW }}>
                            <div style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                              {sw.name}
                            </div>
                            <div className="pd-num" style={{ color: "var(--muted)", fontSize: 12 }}>
                              {last != null ? secToPace(last, col.dist <= 50)
                                : goal ? secToPace(goal, col.dist <= 50) : "—"}
                            </div>
                            {Array.from({ length: many ? col.reps : 1 }, (_, k) => (
                              <div key={k}>
                                <input className="pd-in pd-num pd-cell" style={{ textAlign: "right" }}
                                  aria-label={`${sw.name} rep ${k + 1}`} placeholder="—"
                                  defaultValue={typeof got[k] === "number" ? secToPace(got[k], true) : ""}
                                  onBlur={(e) => setDraft({ ...draft,
                                    results: writeResult(draft, col.test.id, sw.id, k, paceToSec(e.target.value)) })} />
                              </div>
                            ))}
                            <div className="pd-num" style={{ fontSize: 12,
                              color: delta == null ? "var(--muted)" : delta <= 0 ? "var(--aqua)" : "var(--clock)" }}>
                              {avg == null ? "—" : (
                                <>
                                  {secToPace(avg, col.dist <= 50)}
                                  {delta != null && (
                                    <span style={{ fontSize: 10.5, marginLeft: 5 }}>
                                      {delta <= 0 ? "−" : "+"}{secToPace(Math.abs(delta), true)}
                                    </span>
                                  )}
                                </>
                              )}
                            </div>
                          </div>
                        );
                      })}
                    </div>
                  )}
                </div>
              );
            })}
            {showTests && (
              <div style={{ padding: "9px 14px", fontSize: 11.5, color: "var(--muted)" }}>
                Times are kept against the swimmer, so they survive splitting the block again.
              </div>
            )}
          </div>
        )}

        <div style={{ display: "flex", gap: 14, padding: "2px 4px 0", fontSize: 11.5,
          color: "var(--muted)", flexWrap: "wrap" }}>
          <span><b style={{ color: "var(--aqua)" }}>10x100 @ 2:00</b> reps × distance @ interval</span>
          <span><b style={{ color: "var(--aqua)" }}>r:20</b> rest instead of interval</span>
          <span><b style={{ color: "var(--aqua)" }}>3 rounds:</b> multiplies the indented lines below</span>
          <span><b style={{ color: "var(--aqua)" }}>Break 5:00</b> time on the clock, no distance</span>
          <span style={{ flexBasis: "100%", color: "var(--faint)" }}>
            Blocks are the rows of the practice. Keep the warm up in its own block so everyone can share it,
            and give the main set a block of its own to split.
          </span>
        </div>
      </div>

      {/* ---------- totals ---------- */}
      <div className="pd-tot">
        <div className="pd-card" style={{ padding: 16, marginBottom: 12 }}>
          <div className="pd-eyebrow">
            {multi ? (doc.groups.filter((g) => g.id === focus)[0] || {}).name || "Total" : "Total distance"}
          </div>
          <div className="pd-big" style={{ fontSize: 58, marginTop: 2 }}>
            {comma(totals.yards)}
            <span style={{ fontSize: 19, color: "var(--muted)", marginLeft: 8 }}>{courseUnit(draft.course)}</span>
          </div>
          <div style={{ display: "flex", gap: 22, margin: "10px 0 14px" }}>
            <div>
              <div className="pd-eyebrow">On the clock</div>
              <div className="pd-big" style={{ fontSize: 28, color: "var(--clock)" }}>
                {fmt(totals.seconds)}{totals.estimated && <span style={{ fontSize: 13 }}> est</span>}
              </div>
              {totals.breakSeconds > 0 && <div style={{ fontSize: 11, color: "var(--muted)", marginTop: 2 }}>
                {fmt(totals.breakSeconds)} out of the water</div>}
            </div>
            <div>
              <div className="pd-eyebrow">Sets</div>
              <div className="pd-big" style={{ fontSize: 28 }}>{totals.sets}</div>
            </div>
          </div>
          <Rope data={vData} colorKey={vKey} order={vOrder} />
        </div>

        {multi && (
          <div className="pd-card" style={{ padding: 12, marginBottom: 12 }}>
            <div className="pd-eyebrow" style={{ marginBottom: 6 }}>Every group</div>
            {doc.groups.map((g) => {
              const t = per[g.id];
              return (
                <button key={g.id} onClick={() => setFocusG(g.id)}
                  style={{ display: "flex", width: "100%", gap: 8, alignItems: "center", padding: "6px 6px",
                    background: g.id === focus ? "var(--sel)" : "transparent", border: 0, borderRadius: 6,
                    fontSize: 13, textAlign: "left" }}>
                  <i className="pd-dot" style={{ background: g.color, borderRadius: 2 }} />
                  <span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{g.name}</span>
                  <span className="pd-num">{comma(t.yards)}</span>
                  <span className="pd-num" style={{ color: "var(--muted)", width: 52, textAlign: "right" }}>{fmt(t.seconds)}</span>
                </button>
              );
            })}
          </div>
        )}

        <div className="pd-card" style={{ padding: 14, marginBottom: 12 }}>
          <div style={{ display: "flex", gap: 4, marginBottom: 10 }}>
            {[["stroke", "Stroke"], ["mode", "Type"], ["zone", "Effort"]].map(([k, l]) => (
              <button key={k} className="pd-btn" data-ghost={view === k ? "0" : "1"}
                style={{ flex: 1, borderColor: view === k ? "var(--aqua)" : "var(--line)" }}
                onClick={() => setView(k)}>{l}</button>
            ))}
          </div>
          <Breakdown data={vData} labels={vLabels} colorKey={vKey} order={vOrder} total={totals.yards || 1} />
          {!!Object.keys(totals.gear).length && (
            <div style={{ marginTop: 12, paddingTop: 10, borderTop: "1px solid var(--line)" }}>
              <div className="pd-eyebrow" style={{ marginBottom: 6 }}>Equipment</div>
              {Object.entries(totals.gear).sort((a, b) => b[1] - a[1]).map(([g, v]) => (
                <div className="pd-brk" key={g}><span style={{ textTransform: "capitalize" }}>{g}</span>
                  <span className="pd-num" style={{ color: "var(--muted)" }}>{comma(v)}</span></div>
              ))}
            </div>
          )}
        </div>

        {!!flags.length && (
          <div className="pd-card" style={{ padding: 14, marginBottom: 12 }}>
            <div className="pd-eyebrow" style={{ marginBottom: 7 }}>Chart check</div>
            {flags.slice(0, 6).map((f, i) => (
              <div className="pd-brk" key={i}>
                <span style={{ display: "flex", alignItems: "center", gap: 7 }}>
                  <i className="pd-dot" style={{ background: COLOR["z_" + f.r.zone] }} />
                  <span className="pd-num">{f.r.totalReps}×{f.r.dist}</span>
                  <span style={{ color: "var(--muted)" }}>{zoneLabel(f.r.zone, cfg.zoneVocab)}</span>
                </span>
                <span style={{ color: "var(--muted)", fontSize: 11.5 }}>chart says {f.want}</span>
              </div>
            ))}
            <div style={{ fontSize: 11, color: "var(--faint)", marginTop: 7, lineHeight: 1.5 }}>
              Repetition ranges from the workbook, at 10 to 30 seconds rest. A guide, not a rule.
            </div>
          </div>
        )}

        {weekStat && (
          <div className="pd-card" style={{ padding: 14 }}>
            <div className="pd-eyebrow">Week of {pretty(weekStat.start)} · plan check</div>
            {!weekStat.attached && (
              <div style={{ fontSize: 11, color: "var(--faint)", marginTop: 4, lineHeight: 1.5 }}>
                Measured against {weekStat.seasonName}. Attach the practice to a season above to be sure.
              </div>
            )}
            {weekStat.next && (
              <div style={{ display: "flex", alignItems: "baseline", gap: 7, marginTop: 6,
                fontSize: 12, lineHeight: 1.4 }}>
                <span className="pd-chip" style={{ background: MEET_INK[meetLevel(weekStat.next)],
                  color: "#fff" }}>{meetLevel(weekStat.next)}</span>
                <b style={{ fontWeight: 600 }}>{weekStat.next.name || "Meet"}</b>
                <span style={{ color: "var(--muted)", marginLeft: "auto", whiteSpace: "nowrap" }}>
                  {weekStat.next.daysOut <= 0 ? "today"
                    : weekStat.next.weeksOut <= 0 ? `${weekStat.next.daysOut}d, this week`
                    : weekStat.next.weeksOut === 1 ? "1 week out" : `${weekStat.next.weeksOut} weeks out`}
                </span>
              </div>
            )}
            {weekStat.focus && (
              <div style={{ fontSize: 12, color: "var(--muted)", margin: "5px 0 2px", lineHeight: 1.5 }}>
                {weekStat.focus}
              </div>
            )}
            <div style={{ marginTop: 8 }}>
              {weekStat.rows.map((r) => {
                const done = r.actual + r.draft;
                const pct = r.target ? Math.min(100, (done / r.target) * 100) : 0;
                return (
                  <div key={r.name} style={{ marginBottom: 10 }}>
                    <div className="pd-brk" style={{ border: 0, padding: 0 }}>
                      <span style={{ display: "flex", gap: 7, alignItems: "center" }}>
                        <i className="pd-dot" style={{ background: r.color, borderRadius: 2 }} />{r.name}
                      </span>
                      <span className="pd-num" style={{ color: "var(--muted)" }}>
                        {comma(done)} / {r.target ? comma(r.target) : "—"}
                      </span>
                    </div>
                    <div className="pd-bar" style={{ marginTop: 4 }}>
                      <i style={{ width: `${pct}%`,
                        background: r.target && done >= r.target ? "var(--aqua)" : "var(--clock)" }} />
                    </div>
                    {r.draft > 0 && (
                      <div className="pd-num" style={{ fontSize: 10.5, color: "var(--aqua)", marginTop: 3 }}>
                        +{comma(r.draft)} from this draft
                      </div>
                    )}
                  </div>
                );
              })}
            </div>

            {(() => {
              const g = doc.groups.filter((x) => x.id === focus)[0];
              const r = g && weekStat.rows.filter((x) => x.name === g.name)[0];
              if (!r || !r.target) return null;
              const today = zoneView === "today";
              const thin = r.tagged < 0.6;
              return (
                <div style={{ marginTop: 4, paddingTop: 12, borderTop: "1px solid var(--line)" }}>
                  <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 9 }}>
                    <span className="pd-eyebrow" style={{ color: r.color }}>
                      {r.name}{r.phase ? ` · ${r.phase}` : ""}
                    </span>
                    <span className="pd-seg2" style={{ marginLeft: "auto", padding: 2 }}>
                      {[["today", "Today"], ["week", "Week"]].map(([k, l]) => (
                        <button key={k} data-on={zoneView === k ? "1" : "0"}
                          style={{ padding: "3px 9px", fontSize: 11 }}
                          onClick={() => setZoneView(k)}>{l}</button>
                      ))}
                    </span>
                  </div>

                  {!r.mixOk ? (
                    <div style={{ fontSize: 11.5, color: "var(--clock)", lineHeight: 1.55 }}>
                      This block’s percentages do not add to 100. Fix it under
                      Plan → Effort plan.
                    </div>
                  ) : (
                    <div style={{ opacity: thin ? .45 : 1 }}>
                      {r.zones.map((z) => {
                        const have = today ? z.mine : z.weekDone;
                        const want = today ? z.share : z.weekTarget;
                        const pct = want ? Math.min(100, (have / want) * 100) : 0;
                        const met = want > 0 && have >= want;
                        return (
                          <div key={z.code} style={{ marginBottom: 7 }}>
                            <div className="pd-brk" style={{ border: 0, padding: 0, fontSize: 12 }}>
                              <span style={{ display: "flex", gap: 7, alignItems: "center" }}>
                                <i style={{ width: 8, height: 8, borderRadius: 2, flex: "none",
                                  background: CODE_COLOR[z.code], boxShadow: "0 0 0 1px var(--ring)" }} />
                                <b style={{ fontWeight: 600 }}>{codeLabel(z.code, cfg.zoneVocab)}</b>
                                <span style={{ color: "var(--faint)", fontSize: 11 }}>
                                  {codeSecond(z.code, cfg.zoneVocab)}
                                </span>
                              </span>
                              <span className="pd-num" style={{ color: "var(--muted)", fontSize: 11.5 }}>
                                {comma(have)} / {comma(want)}
                              </span>
                            </div>
                            <div className="pd-bar" style={{ marginTop: 3, height: 3 }}>
                              <i style={{ width: `${pct}%`,
                                background: met ? "var(--aqua)" : CODE_COLOR[z.code] }} />
                            </div>
                          </div>
                        );
                      })}
                    </div>
                  )}

                  <div style={{ fontSize: 11, color: thin ? "var(--clock)" : "var(--faint)",
                    marginTop: 9, lineHeight: 1.5 }}>
                    {Math.round(r.tagged * 100)}% of this week’s distance carries a zone.
                    {thin && " Too little to read much into. Tag more of your sets."}
                    {!thin && today && ` Spread over ${r.left} more ${r.left === 1 ? "practice" : "practices"}.`}
                  </div>
                </div>
              );
            })()}
          </div>
        )}
      </div>

    </div>
  );
}

/* ============================================================
   9. LOG — what actually got written, inside the season at hand
   ============================================================ */

function LogTab({ log, cfg, setCfg, seasons, tests, onOpen, onNew, onDelete }) {
  const ch = chartInk(cfg);
  const season = seasons.filter((s) => s.id === cfg.activeSeason)[0] || seasons[0] || null;
  const struct = season ? primaryStructure(season) : null;
  const [week, setWeek] = useState("__all__");
  const [mode, setMode] = useState("practices");
  const [testId, setTestId] = useState("");
  const [per100, setPer100] = useState(false);

  // value is the group id when the season knows it, the name when it does not
  const groupOpts = useMemo(() => struct
    ? struct.groups.map((g) => ({ value: g.id, label: g.name, group: g }))
    : [...new Set(log.flatMap((p) => (p.byGroup || []).map((g) => g.name)))].sort()
        .map((n) => ({ value: n, label: n, group: { id: n, name: n } })),
    [struct, log]);
  const tracked = groupOpts.filter((o) => o.value === cfg.track)[0];
  const colorOf = () => (tracked && tracked.group.color) || "var(--aqua)";

  const seasonKey = season ? season.id : "";
  useEffect(() => { setWeek("__all__"); }, [seasonKey]);
  useEffect(() => {
    if (cfg.track !== "__top__" && !groupOpts.some((o) => o.value === cfg.track))
      setCfg({ ...cfg, track: "__top__" });
  }, [groupOpts.map((o) => o.value).join("|"), seasonKey]); // eslint-disable-line

  const scoped = useMemo(() => practicesInSeason(log, season), [log, season]);
  const shown = useMemo(() => week === "__all__" ? scoped
    : scoped.filter((p) => mondayOf(p.date) === week), [scoped, week]);
  const sorted = useMemo(() => [...shown].sort((a, b) => (a.date < b.date ? 1 : -1)), [shown]);

  const weeks = (season && season.weeks) || [];
  const activeWeek = weeks.filter((w) => w.start === week)[0];

  const series = useMemo(() => {
    if (week !== "__all__") {
      return sorted.slice().reverse().map((p) => ({
        name: pretty(p.date), Distance: pickGroup(p, cfg.track, tracked && tracked.group).yards,
      }));
    }
    if (weeks.length) {
      const m = {};
      scoped.forEach((p) => {
        const k = mondayOf(p.date);
        m[k] = (m[k] || 0) + pickGroup(p, cfg.track, tracked && tracked.group).yards;
      });
      return weeks.map((w, i) => ({ name: `W${i + 1}`, Distance: m[w.start] || 0 }));
    }
    const m = {};
    scoped.forEach((p) => { const k = mondayOf(p.date); m[k] = (m[k] || 0) + pickGroup(p, cfg.track, tracked && tracked.group).yards; });
    return Object.entries(m).sort().slice(-14).map(([k, v]) => ({ name: pretty(k), Distance: v }));
  }, [scoped, sorted, weeks, week, cfg.track]);

  const rollup = useMemo(() => {
    const acc = { stroke: {}, zone: {}, mode: {}, yards: 0, seconds: 0 };
    shown.forEach((p) => {
      const g = pickGroup(p, cfg.track, tracked && tracked.group);
      acc.yards += g.yards || 0; acc.seconds += g.seconds || 0;
      ["stroke", "zone", "mode"].forEach((k) => {
        Object.entries(g[k] || {}).forEach(([kk, v]) => {
          const key = k === "zone" ? (LEGACY_ZONE[kk] || kk) : kk;
          acc[k][key] = (acc[k][key] || 0) + v;
        });
      });
    });
    return acc;
  }, [shown, cfg.track]);

  const picker = (
    <div style={{ display: "flex", gap: 14, alignItems: "center", flexWrap: "wrap" }}>
      {!!groupOpts.length && (
        <label style={{ display: "flex", alignItems: "center", gap: 8 }}>
          <span className="pd-eyebrow">Counting</span>
          <select className="pd-in" style={{ width: 175 }} value={cfg.track}
            onChange={(e) => setCfg({ ...cfg, track: e.target.value })}>
            <option value="__top__">Highest group</option>
            {groupOpts.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
          </select>
        </label>
      )}
      {!!weeks.length && (
        <label style={{ display: "flex", alignItems: "center", gap: 8 }}>
          <span className="pd-eyebrow">Week</span>
          <select className="pd-in" style={{ width: 200 }} value={week}
            onChange={(e) => setWeek(e.target.value)}>
            <option value="__all__">Whole season</option>
            {weeks.map((w, i) => (
              <option key={w.id} value={w.start}>Week {i + 1} · {pretty(w.start)}</option>
            ))}
          </select>
        </label>
      )}
      <span style={{ marginLeft: "auto", display: "flex", gap: 10, alignItems: "center" }}>
        <span className="pd-seg2">
          {[["practices", "Practices"], ["tests", "Tests"]].map(([k, l]) => (
            <button key={k} data-on={mode === k ? "1" : "0"} onClick={() => setMode(k)}>{l}</button>
          ))}
        </span>
        {season && <span style={{ fontSize: 12, color: "var(--faint)" }}>{season.name}</span>}
        <button className="pd-btn" data-key="1" onClick={onNew}>New practice</button>
      </span>
    </div>
  );

  if (!shown.length && mode === "practices") return (
    <div style={{ display: "grid", gap: 14 }}>
      {(!!groupOpts.length || !!weeks.length) && picker}
      <div className="pd-card pd-empty">
        {!scoped.length
          ? (season
              ? <>Nothing in <b>{season.name}</b> yet.</>
              : <>No practices yet.</>)
          : <>Nothing in that week.</>}
        <div style={{ marginTop: 16 }}>
          <button className="pd-btn" data-key="1" onClick={onNew}>New practice</button>
        </div>
      </div>
    </div>
  );

  return (
    <div style={{ display: "grid", gap: 14 }}>
      {picker}

      {mode === "tests" && (() => {
        const withResults = tests.filter((t) =>
          scoped.some((p) => p.results && p.results[t.id] && Object.keys(p.results[t.id]).length));
        const chosen = withResults.filter((t) => t.id === testId)[0] || withResults[0] || null;
        const roster = (season && season.roster) || [];
        const nameOf = (id) => (roster.filter((s) => s.id === id)[0] || {}).name || "Swimmer";
        const inGroup = (id) => {
          if (cfg.track === "__top__" || !tracked) return true;
          const sw = roster.filter((s) => s.id === id)[0];
          return !sw || sw.groupId === tracked.group.id;
        };

        if (!chosen) return (
          <div className="pd-card pd-empty">
            No test times recorded in this season yet. Mark a column as a test while writing and the
            entry table appears underneath the practice.
          </div>
        );

        const checkpointRuns = scoped
          .filter((p) => p.results && p.results[chosen.id] && Object.keys(p.results[chosen.id]).length)
          .sort((a, b) => (a.date < b.date ? -1 : 1));
        const ids = [...new Set(checkpointRuns.flatMap((p) => Object.keys(p.results[chosen.id])))]
          .filter(inGroup);
        // A progressive set changes shape, so each point says what it was.
        const shapes = checkpointRuns.map((p) => testShape(p, chosen.id, cfg));
        const anyShape = shapes.some(Boolean);
        const series = checkpointRuns.map((p, si) => {
          const sh = shapes[si];
          const wi = weeks.findIndex((w) => w.start === mondayOf(p.date));
          const pt = { name: (wi >= 0 ? `Wk ${wi + 1}` : pretty(p.date)) + (sh ? ` · ${sh.label}` : "") };
          ids.forEach((id) => {
            let m = meanOf(p.results[chosen.id][id]);
            if (m == null) return;
            if (chosen.mode === "total" && sh) m = m / Math.max(1, sh.reps);
            if (per100 && sh && sh.dist) m = (m / sh.dist) * 100;
            pt[nameOf(id)] = +m.toFixed(2);
          });
          return pt;
        });
        const line = (i) => GROUP_COLORS[i % GROUP_COLORS.length];

        return (
          <div style={{ display: "grid", gap: 14 }}>
            <div className="pd-card" style={{ padding: 14, display: "flex", gap: 12,
              alignItems: "center", flexWrap: "wrap" }}>
              <select className="pd-in" style={{ width: 250 }} value={chosen.id}
                onChange={(e) => setTestId(e.target.value)}>
                {withResults.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
              </select>
              {anyShape && (
                <span className="pd-seg2">
                  {[["swum", "As swum"], ["p100", "Per 100"]].map(([k, l]) => (
                    <button key={k} data-on={(per100 ? "p100" : "swum") === k ? "1" : "0"}
                      onClick={() => setPer100(k === "p100")}>{l}</button>
                  ))}
                </span>
              )}
              <span style={{ fontSize: 12.5, color: "var(--muted)" }}>
                {checkpointRuns.length} {checkpointRuns.length === 1 ? "checkpoint" : "checkpoints"} ·{" "}
                {ids.length} {ids.length === 1 ? "swimmer" : "swimmers"}
                {shapes.some((x) => x && !x.planned) ? " · some unplanned" : ""}
              </span>
            </div>

            {!ids.length ? (
              <div className="pd-card pd-empty">No times for that group.</div>
            ) : (
              <>
                <div className="pd-card" style={{ padding: 16 }}>
                  <div className="pd-eyebrow" style={{ marginBottom: 10 }}>
                    {chosen.name} · higher is faster{per100 ? ", per 100" : ""}
                  </div>
                  <div style={{ height: 260 }}>
                    <ResponsiveContainer>
                      <LineChart data={series} margin={{ top: 4, right: 10, left: 4, bottom: 0 }}>
                        <CartesianGrid stroke={ch.grid} vertical={false} />
                        <XAxis dataKey="name" tick={{ fill: ch.tick, fontSize: 11 }}
                          axisLine={{ stroke: ch.axis }} tickLine={false} />
                        <YAxis reversed tick={{ fill: ch.tick, fontSize: 11 }} axisLine={false}
                          tickLine={false} width={52} domain={["auto", "auto"]}
                          tickFormatter={(v) => secToPace(v)} />
                        <Tooltip contentStyle={{ background: ch.card, border: `1px solid ${ch.edge}`,
                          borderRadius: 8, fontSize: 12 }}
                          formatter={(v) => secToPace(v, true)} />
                        <Legend wrapperStyle={{ fontSize: 11 }} />
                        {ids.map((id, i) => (
                          <Line key={id} type="monotone" dataKey={nameOf(id)} stroke={line(i)}
                            strokeWidth={2} dot={{ r: 3, fill: line(i) }} connectNulls />
                        ))}
                      </LineChart>
                    </ResponsiveContainer>
                  </div>
                </div>

                <div className="pd-card" style={{ overflowX: "auto" }}>
                  <div className="pd-row pd-head"
                    style={{ gridTemplateColumns: `minmax(140px,1.4fr) repeat(${checkpointRuns.length},minmax(80px,1fr)) 92px`,
                      minWidth: 300 + checkpointRuns.length * 88 }}>
                    <div>Swimmer</div>
                    {checkpointRuns.map((p, si) => (
                      <div key={p.id}>
                        {pretty(p.date)}
                        {shapes[si] && <span style={{ display: "block", fontSize: 10,
                          color: "var(--faint)", letterSpacing: 0 }}>{shapes[si].label}</span>}
                      </div>
                    ))}
                    <div>Change</div>
                  </div>
                  {ids.map((id) => {
                    const vals = checkpointRuns.map((p, si) => {
                      let v = meanOf(p.results[chosen.id][id]);
                      if (v == null) return null;
                      const sh = shapes[si];
                      if (chosen.mode === "total" && sh) v = v / Math.max(1, sh.reps);
                      if (per100 && sh && sh.dist) v = (v / sh.dist) * 100;
                      return v;
                    });
                    const real = vals.filter((v) => v != null);
                    const drop = real.length > 1 ? real[real.length - 1] - real[0] : null;
                    return (
                      <div className="pd-row" key={id}
                        style={{ gridTemplateColumns: `minmax(140px,1.4fr) repeat(${checkpointRuns.length},minmax(80px,1fr)) 92px`,
                          minWidth: 300 + checkpointRuns.length * 88 }}>
                        <div style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                          {nameOf(id)}
                        </div>
                        {vals.map((v, i) => (
                          <div className="pd-num" key={i} style={{ color: v == null ? "var(--ghost)" : "var(--ink)" }}>
                            {v == null ? "—" : secToPace(v, true)}
                          </div>
                        ))}
                        <div className="pd-num" style={{
                          color: drop == null ? "var(--muted)" : drop < 0 ? "var(--aqua)" : "var(--clock)" }}>
                          {drop == null ? "—" : `${drop < 0 ? "−" : "+"}${secToPace(Math.abs(drop), true)}`}
                        </div>
                      </div>
                    );
                  })}
                </div>
              </>
            )}
          </div>
        );
      })()}

      {mode === "practices" && (<>
      <div style={{ display: "grid", gridTemplateColumns: "minmax(0,2fr) minmax(0,1fr)", gap: 14 }}>
        <div className="pd-card" style={{ padding: 16 }}>
          <div className="pd-eyebrow" style={{ marginBottom: 10 }}>
            {week === "__all__"
              ? (weeks.length ? "Distance by week" : "Weekly distance · last 14 weeks")
              : "Distance by practice"}
          </div>
          <div style={{ height: 210 }}>
            <ResponsiveContainer>
              {week === "__all__" ? (
                <LineChart data={series} margin={{ top: 4, right: 8, left: -18, bottom: 0 }}>
                  <CartesianGrid stroke={ch.grid} vertical={false} />
                  <XAxis dataKey="name" tick={{ fill: ch.tick, fontSize: 11 }} axisLine={{ stroke: ch.axis }} tickLine={false} />
                  <YAxis tick={{ fill: ch.tick, fontSize: 11 }} axisLine={false} tickLine={false} />
                  <Tooltip contentStyle={{ background: ch.card, border: `1px solid ${ch.edge}`, borderRadius: 8, fontSize: 12 }}
                    formatter={(v) => comma(v)} />
                  <Line type="monotone" dataKey="Distance" stroke={ch.line} strokeWidth={2} dot={{ r: 3, fill: ch.line }} />
                </LineChart>
              ) : (
                <BarChart data={series} margin={{ top: 4, right: 8, left: -18, bottom: 0 }}>
                  <CartesianGrid stroke={ch.grid} vertical={false} />
                  <XAxis dataKey="name" tick={{ fill: ch.tick, fontSize: 11 }} axisLine={{ stroke: ch.axis }} tickLine={false} />
                  <YAxis tick={{ fill: ch.tick, fontSize: 11 }} axisLine={false} tickLine={false} />
                  <Tooltip contentStyle={{ background: ch.card, border: `1px solid ${ch.edge}`, borderRadius: 8, fontSize: 12 }}
                    formatter={(v) => comma(v)} />
                  <Bar dataKey="Distance"
                    fill={cfg.track === "__top__" ? ch.act : colorOf()} radius={[3, 3, 0, 0]} />
                </BarChart>
              )}
            </ResponsiveContainer>
          </div>
        </div>

        <div className="pd-card" style={{ padding: 16 }}>
          <div className="pd-eyebrow">
            {week === "__all__" ? "Season to date" : `Week of ${pretty(week)}`}
          </div>
          <div className="pd-big" style={{ fontSize: 40, margin: "2px 0 4px" }}>{comma(rollup.yards)}</div>
          <div className="pd-num" style={{ fontSize: 12, color: "var(--muted)", marginBottom: 10 }}>
            {shown.length} {shown.length === 1 ? "practice" : "practices"} · {fmt(rollup.seconds)}
          </div>
          {activeWeek && activeWeek.focus && (
            <div style={{ fontSize: 12.5, color: "var(--muted)", lineHeight: 1.55, marginBottom: 10 }}>
              {activeWeek.focus}
            </div>
          )}
          <Rope data={rollup.stroke} />
          <div style={{ marginTop: 12 }}>
            <Breakdown data={rollup.stroke} labels={STROKE_LABEL} total={rollup.yards || 1} />
          </div>
        </div>
      </div>

      <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit,minmax(260px,1fr))", gap: 14 }}>
        <div className="pd-card" style={{ padding: 16 }}>
          <div className="pd-eyebrow" style={{ marginBottom: 8 }}>Effort mix</div>
          <Breakdown data={rollup.zone} labels={zoneLabels(cfg.zoneVocab)} colorKey="z_"
            order={ZONE_ORDER} total={rollup.yards || 1} />
        </div>
        <div className="pd-card" style={{ padding: 16 }}>
          <div className="pd-eyebrow" style={{ marginBottom: 8 }}>Swim / kick / pull / drill</div>
          <Breakdown data={rollup.mode} labels={MODE_LABEL} colorKey="m_" total={rollup.yards || 1} />
        </div>
      </div>

      <PracticeList rows={sorted} seasons={seasons} track={cfg.track}
        trackGroup={tracked && tracked.group} onOpen={onOpen} onDelete={onDelete} />
      </>)}
    </div>
  );
}

/* ============================================================
   9h. THE PRACTICE LIST
   Shared by the dashboard and the log; the dashboard shows every
   season at once, the log shows the one it is filtered to.
   ============================================================ */

function PracticeList({ rows, seasons, showSeason, track, trackGroup, onOpen, onDelete }) {
  const cols = showSeason
    ? "100px minmax(0,1fr) 150px 110px 90px 96px"
    : "100px minmax(0,1fr) 110px 90px 160px";
  const seasonName = (id) => {
    const s = seasons.filter((x) => x.id === id)[0];
    return s ? s.name : null;
  };
  return (
    <div className="pd-card">
      <div className="pd-row pd-head" style={{ gridTemplateColumns: cols }}>
        <div>Date</div><div>Practice</div>
        {showSeason && <div>Season</div>}
        <div>Distance</div><div>Time</div><div />
      </div>
      {rows.map((p) => {
        const g = pickGroup(p, track || "__top__", trackGroup);
        return (
          <div className="pd-row" key={p.id} data-click="1" role="button" tabIndex={0}
            style={{ gridTemplateColumns: cols }}
            onClick={() => onOpen(p)}
            onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onOpen(p); } }}>
            <div style={{ fontSize: 12, color: "var(--muted)" }}>{pretty(p.date)}</div>
            <div style={{ minWidth: 0 }}>
              <div style={{ fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis",
                whiteSpace: "nowrap" }}>{p.title || "Untitled practice"}</div>
              {(p.byGroup || []).length > 1 && (
                <div style={{ fontSize: 11, color: "var(--faint)", marginTop: 2, overflow: "hidden",
                  textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                  {p.byGroup.map((x) => `${x.name} ${comma(x.yards)}`).join("  ·  ")}
                </div>
              )}
            </div>
            {showSeason && (
              <div style={{ fontSize: 12, color: seasonName(p.seasonId) ? "var(--muted)" : "var(--ghost)" }}>
                {seasonName(p.seasonId) || "No season"}
              </div>
            )}
            <div className="pd-num">
              {p.yards
                ? <>{comma(g.yards)} <span style={{ color: "var(--faint)", fontSize: 10.5 }}>{courseUnit(p.course)}</span></>
                : <span className="pd-chip" style={{ background: "var(--track)", color: "var(--muted)" }}>Planned</span>}
            </div>
            <div className="pd-num" style={{ color: "var(--muted)" }}>{p.yards ? fmt(g.seconds) : "—"}</div>
            <div style={{ display: "flex", gap: 6, justifyContent: "flex-end" }}>
              <button className="pd-btn" data-ghost="1"
                onClick={(e) => { e.stopPropagation(); onDelete(p.id); }}>Delete</button>
            </div>
          </div>
        );
      })}
    </div>
  );
}

function Dashboard({ log, seasons, onOpen, onNew, onDelete }) {
  const rows = useMemo(
    () => [...log].sort((a, b) => (a.date === b.date ? 0 : a.date < b.date ? 1 : -1)),
    [log]
  );
  const written = rows.filter((p) => p.yards > 0).length;

  return (
    <div style={{ display: "grid", gap: 14 }}>
      <div style={{ display: "flex", alignItems: "flex-end", gap: 14, flexWrap: "wrap" }}>
        <div>
          <div className="pd-big" style={{ fontSize: 30 }}>Practices</div>
          <div style={{ color: "var(--muted)", fontSize: 13, marginTop: 2 }}>
            {rows.length
              ? <>{rows.length} in all{written < rows.length && <> · {rows.length - written} still to write</>}</>
              : "Nothing here yet"}
          </div>
        </div>
        <button className="pd-btn" data-key="1" style={{ marginLeft: "auto", padding: "9px 16px" }}
          onClick={onNew}>New practice</button>
      </div>

      {rows.length ? (
        <PracticeList rows={rows} seasons={seasons} showSeason onOpen={onOpen} onDelete={onDelete} />
      ) : (
        <div className="pd-card pd-empty" style={{ padding: "56px 24px" }}>
          Start a practice and it lands here straight away: named, dated, and saving itself as you
          write. Open one to pick up where you left off.
          <div style={{ marginTop: 18 }}>
            <button className="pd-btn" data-key="1" onClick={onNew}>New practice</button>
          </div>
        </div>
      )}
    </div>
  );
}

/* ============================================================
   9b. PAPER — practice sheets and the season report
   ============================================================ */

const PAGE = { portrait: [816, 1056], landscape: [1056, 816] };

// A page that quietly shrinks its own type until the contents clear the bottom.
function FitPage({ w, h, base, resetKey, pad, children }) {
  const top = base || 11;
  const [fs, setFs] = useState(top);
  const span = useRef({ lo: 5.5, hi: top });
  const inner = useRef(null);
  useEffect(() => { span.current = { lo: 5.5, hi: top }; setFs(top); }, [top, w, h, resetKey]);
  // Binary search rather than a slow walk down — five layout passes, not fourteen.
  useLayoutEffect(() => {
    const el = inner.current;
    if (!el) return;
    const b = span.current;
    const fits = el.scrollHeight <= h;
    if (fits) {
      if (fs >= b.hi - 0.01) return;
      b.lo = fs;
    } else b.hi = fs;
    if (b.hi - b.lo <= 0.3) {
      if (!fits && fs > b.lo) setFs(b.lo);
      return;
    }
    const next = +(((b.lo + b.hi) / 2).toFixed(2));
    if (Math.abs(next - fs) > 0.01) setFs(next);
  }, [fs, h]);
  return (
    <div className="pg" style={{ width: w, height: h, fontSize: fs, marginBottom: 18 }}>
      <div className="pg-in" ref={inner} style={pad ? { padding: pad } : undefined}>{children}</div>
    </div>
  );
}

function PracticeTable({ doc, per, unit }) {
  const blocks = doc.blocks.filter((b) => b.cells.some((c) => c.text.trim()));
  const multi = doc.groups.length > 1;
  const nameOf = (id) => (doc.groups.filter((g) => g.id === id)[0] || {}).name;
  return (
    <table>
      <colgroup>
        {doc.groups.map((g) => <col key={g.id} style={{ width: `${100 / doc.groups.length}%` }} />)}
      </colgroup>
      {multi && (
        <thead><tr>{doc.groups.map((g) => (
          <th key={g.id}><span className="pg-sw" style={{ background: g.color }} />{g.name}</th>
        ))}</tr></thead>
      )}
      <tbody>
        {blocks.map((b) => (
          <tr key={b.id}>
            {b.cells.map((c) => {
              const span = c.groups.length > 1 && multi;
              const all = c.groups.length === doc.groups.length;
              return (
                <td key={c.id} colSpan={c.groups.length}
                  className={(span ? "pg-span" : "") + (c.testId ? " pg-test" : "")}>
                  {c.testId && <span className="pg-testtag">Test set</span>}
                  {span && (
                    <span className="pg-tag">
                      {all ? "All groups" : c.groups.map(nameOf).join("  +  ")}
                    </span>
                  )}
                  <span className="pg-blk">{c.text.replace(/\s+$/, "")}</span>
                </td>
              );
            })}
          </tr>
        ))}
        <tr className="pg-tot">
          {doc.groups.map((g) => (
            <td key={g.id}>{comma(per[g.id].yards)} {unit} · {fmt(per[g.id].seconds)}</td>
          ))}
        </tr>
      </tbody>
    </table>
  );
}

function longDate(d) {
  return new Date((d || today()) + "T00:00:00")
    .toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
}

function PrintSheet({ draft, cfg, seasons, onClose }) {
  const doc = draft.doc;
  const [orient, setOrient] = useState(doc.groups.length > 2 ? "landscape" : "portrait");
  const [w, h] = PAGE[orient];
  const per = useMemo(() => docTotals(doc, primed(cfg, draft)), [doc, cfg, draft]);
  const unit = courseUnit(draft.course);
  const season = (seasons || []).filter((x) => x.id === draft.seasonId)[0] || null;
  const sameCourse = !!season && toCourse(draft.course) === toCourse(season.course);
  const plan = useMemo(() => (sameCourse ? pacePlan(doc, season, cfg) : []),
    [doc, season, cfg, sameCourse]);

  /* Paces get their own pages so the practice itself stays full size, and
     they carry on over as many sheets as the roster needs. */
  const pacePages = useMemo(() => {
    const out = []; let cur = [], room = 24;
    plan.forEach((p) => {
      let rest = p.swimmers;
      while (rest.length) {
        const take = Math.min(rest.length, Math.max(4, room));
        cur.push({ ...p, swimmers: rest.slice(0, take) });
        rest = rest.slice(take);
        room -= take + 3;
        if (room <= 4) { out.push(cur); cur = []; room = 24; }
      }
    });
    if (cur.length) out.push(cur);
    return out;
  }, [plan]);

  return (
    <div className="pd-printwrap" style={{ position: "fixed", inset: 0, background: "var(--scrim)",
      overflow: "auto", padding: 20, zIndex: 80 }}>
      <style>{`@page{size:letter ${orient};margin:.4in;}`}</style>
      <div className="pd-noprint" style={{ display: "flex", gap: 8, alignItems: "center",
        maxWidth: w, margin: "0 auto 14px", flexWrap: "wrap" }}>
        <div className="pd-eyebrow">Practice sheet</div>
        <div className="pd-seg2" style={{ marginLeft: 8 }}>
          {["portrait", "landscape"].map((o) => (
            <button key={o} data-on={orient === o ? "1" : "0"} style={{ textTransform: "capitalize" }}
              onClick={() => setOrient(o)}>{o}</button>
          ))}
        </div>
        <div style={{ marginLeft: "auto", display: "flex", gap: 8 }}>
          <button className="pd-btn" data-key="1" onClick={() => window.print()}>Print</button>
          <button className="pd-btn" data-ghost="1" onClick={onClose}>Close</button>
        </div>
      </div>

      <FitPage w={w} h={h} resetKey={orient + doc.blocks.length}>
        <div className="pg-hd">
          <h1>{draft.title || "Practice"}</h1>
          <span>{longDate(draft.date)}</span>
          <span style={{ marginLeft: "auto" }}>{courseName(draft.course)}</span>
        </div>
        <PracticeTable doc={doc} per={per} unit={unit} />
      </FitPage>

      {pacePages.map((chunk, i) => (
        <FitPage w={w} h={h} base={10} key={"pace" + i} resetKey={"pace" + i + orient}>
          <div className="pg-hd">
            <h1>Goal times</h1>
            <span>{draft.title || "Practice"}</span>
            <span style={{ marginLeft: "auto" }}>
              {longDate(draft.date)}{pacePages.length > 1 ? ` · ${i + 1} of ${pacePages.length}` : ""}
            </span>
          </div>
          <PaceTables plan={chunk} vocab={cfg.zoneVocab} />
          <div style={{ fontSize: ".85em", color: "#5A6A6E", fontFamily: "var(--body)", marginTop: 8 }}>
            Aerobic goals come from each swimmer’s T-pace. Percentage goals come from their best 50 free.
          </div>
        </FitPage>
      ))}

      <div className="pd-noprint" style={{ maxWidth: w, margin: "10px auto 0", fontSize: 11.5,
        color: "var(--muted)", lineHeight: 1.6 }}>
        The practice keeps a page to itself; goal times follow on their own sheets.
        {season && !sameCourse && (
          <> This practice is {courseName(draft.course)} but {season.name} is
          {" "}{courseName(season.course)}, so paces are left out. A T-pace in one course does
          not carry to the other.</>
        )}
        {!season && <> Attach the practice to a season to print goal times with it.</>}
        {sameCourse && !plan.length && <> Nothing here can be paced: aerobic goals need a color and
          a distance between 50 and 500, percentage goals need a 25 or 50.</>}
      </div>
    </div>
  );
}

/* ---------------------------------------------------------- pace charts */

// Swimmers belong to groups by name, the same join used everywhere else.
function rosterForGroupName(season, name) {
  const st = primaryStructure(season);
  const g = st ? st.groups.filter((x) => x.name === name)[0] : null;
  if (!g) return [];
  return ((season && season.roster) || [])
    .filter((s) => s.groupId === g.id && s.name && s.name.trim());
}

/* Every rep in a practice that can be given a goal time, one table per group.
   Aerobic reps come off the T-pace; a percentage on a 25 or 50 comes off the
   best 50. Everything else is left alone. */
function pacePlan(doc, season, cfg) {
  if (!season) return [];
  return doc.groups.map((g) => {
    const cols = [], seen = {};
    doc.blocks.forEach((b) => b.cells.forEach((c) => {
      if (c.groups.indexOf(g.id) < 0) return;
      parsePractice(c.text, { ...cfg, basePace: g.base || DEFAULT_BASE }).rows.forEach((r) => {
        if (r.kind !== "set") return;
        const aero = r.zone && PACE_MULT[r.zone] && aerobicGoal(100, r.zone, r.dist) != null;
        const sprint = r.pct && (r.dist === 25 || r.dist === 50);
        if (!aero && !sprint) return;
        const key = aero ? `a|${r.zone}|${r.dist}` : `s|${r.pct}|${r.dist}`;
        if (seen[key]) return;
        seen[key] = 1;
        cols.push({ key, kind: aero ? "a" : "s", zone: r.zone, dist: r.dist, pct: r.pct });
      });
    }));
    const swimmers = rosterForGroupName(season, g.name)
      .filter((s) => s.tPace || s.free50);
    return { group: g, cols, swimmers };
  }).filter((x) => x.cols.length && x.swimmers.length);
}

const goalFor = (sw, col) => col.kind === "a"
  ? aerobicGoal(sw.tPace, col.zone, col.dist)
  : sprintGoal(sw.free50, col.pct, col.dist);

function PaceTables({ plan, vocab }) {
  return (
    <>
      {plan.map((p) => (
        <div key={p.group.id} style={{ marginBottom: 14 }}>
          <div className="pg-sec">
            <span className="pg-sw" style={{ background: p.group.color }} />{p.group.name}
          </div>
          <table className="pg-tbl">
            <thead>
              <tr>
                <th style={{ width: "26%" }}>Swimmer</th>
                <th className="pg-num" style={{ width: 62 }}>T pace</th>
                {p.cols.map((c) => (
                  <th key={c.key} className="pg-num">
                    {c.dist} {c.kind === "a" ? zoneLabel(c.zone, vocab) : `@ ${c.pct}%`}
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              {p.swimmers.map((s) => (
                <tr key={s.id}>
                  <td>{s.name}</td>
                  <td className="pg-num">{s.tPace ? secToPace(s.tPace) : "—"}</td>
                  {p.cols.map((c) => {
                    const g = goalFor(s, c);
                    return <td key={c.key} className="pg-num">{g ? secToPace(g, c.dist <= 50) : "—"}</td>;
                  })}
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      ))}
    </>
  );
}

/* One swimmer's full chart — zones down, distances across. Two to a page. */
function SwimmerChart({ sw, group, course }) {
  return (
    <div className="pg-half">
      <div className="pg-half-hd">
        <b>{sw.name}</b>
        <span>{group ? group.name : ""}</span>
        <span style={{ marginLeft: "auto" }}>
          {sw.tTest || "T-30"} · {sw.tPace ? secToPace(sw.tPace, true) : "—"} per 100 · {courseName(course)}
        </span>
      </div>
      <table className="pg-tbl">
        <thead>
          <tr>
            <th style={{ width: 78 }}>Zone</th>
            {PACE_DISTANCES.map((d) => <th key={d} className="pg-num">{d}</th>)}
          </tr>
        </thead>
        <tbody>
          {PACE_ZONES.slice().reverse().map((z) => (
            <tr key={z}>
              <td><span className="pg-sw" style={{ background: ZONE_BY_KEY[z].swatch }} />{ZONE_BY_KEY[z].color}</td>
              {PACE_DISTANCES.map((d) => (
                <td key={d} className="pg-num">{secToPace(aerobicGoal(sw.tPace, z, d))}</td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
      {sw.free50 ? (
        <div style={{ fontFamily: "var(--mono)", fontSize: ".92em", marginTop: 5 }}>
          Best 50 free {secToPace(sw.free50, true)}:
          {[95, 90, 85].map((p) => ` ${p}% ${secToPace(sprintGoal(sw.free50, p, 50), true)}`).join(" ·")}
        </div>
      ) : null}
    </div>
  );
}

function PaceChartSheet({ season, cfg, onClose }) {
  const [orient] = useState("portrait");
  const [w, h] = PAGE[orient];
  const st = primaryStructure(season);
  const groups = st ? st.groups : [];
  const people = ((season.roster) || []).filter((s) => s.name && s.name.trim() && s.tPace);
  const pages = [];
  for (let i = 0; i < people.length; i += 2) pages.push(people.slice(i, i + 2));

  return (
    <div className="pd-printwrap" style={{ position: "fixed", inset: 0, background: "var(--scrim)",
      overflow: "auto", padding: 20, zIndex: 80 }}>
      <style>{`@page{size:letter ${orient};margin:.4in;}`}</style>
      <div className="pd-noprint" style={{ display: "flex", gap: 10, alignItems: "center",
        maxWidth: w, margin: "0 auto 14px", flexWrap: "wrap" }}>
        <div className="pd-eyebrow">Pace charts · {season.name}</div>
        <span style={{ fontSize: 12, color: "var(--muted)" }}>
          {people.length} {people.length === 1 ? "swimmer" : "swimmers"} · {pages.length} pages
        </span>
        <div style={{ marginLeft: "auto", display: "flex", gap: 8 }}>
          <button className="pd-btn" data-key="1" onClick={() => window.print()}>Print</button>
          <button className="pd-btn" data-ghost="1" onClick={onClose}>Close</button>
        </div>
      </div>

      {!people.length && (
        <FitPage w={w} h={h} resetKey="none">
          <div className="pg-hd"><h1>No T-paces yet</h1></div>
          <div style={{ fontFamily: "var(--body)", marginTop: 10 }}>
            Add a T-pace to a swimmer under Plan → Roster and their chart appears here.
          </div>
        </FitPage>
      )}

      {pages.map((pair, i) => (
        <FitPage w={w} h={h} base={9.5} key={i} resetKey={"pc" + i}>
          <div className="pg-hd">
            <h1>Pace charts</h1>
            <span>{season.name}</span>
            <span style={{ marginLeft: "auto" }}>{i + 1} of {pages.length}</span>
          </div>
          {pair.map((sw) => (
            <SwimmerChart key={sw.id} sw={sw} course={season.course}
              group={groups.filter((g) => g.id === sw.groupId)[0]} />
          ))}
        </FitPage>
      ))}
    </div>
  );
}

/* ---------------------------------------------------------- season report */

function SeasonReport({ season, log, cfg, tests, onClose }) {
  const [orient, setOrient] = useState("portrait");
  const [w, h] = PAGE[orient];
  const unit = courseUnit(season.course);
  const weeks = season.weeks || [];
  const struct = primaryStructure(season);
  const pGroups = struct ? struct.groups : [];
  const from = weeks.length ? weeks[0].start : season.startDate;
  const to = weeks.length ? addDays(weeks[weeks.length - 1].start, 6) : season.startDate;

  const practices = useMemo(
    () => log.filter((p) => p.date >= from && p.date <= to && p.yards > 0)
            .sort((a, b) => (a.date < b.date ? -1 : 1)),
    [log, from, to]
  );
  const actuals = useMemo(() => weekActuals(practices), [practices]);

  // season totals, per group name
  const byGroup = useMemo(() => {
    const m = {};
    practices.forEach((p) => (p.byGroup || []).forEach((g) => {
      if (!m[g.name]) m[g.name] = { yards: 0, seconds: 0, zone: {}, stroke: {} };
      m[g.name].yards += g.yards; m[g.name].seconds += g.seconds;
      ["zone", "stroke"].forEach((k) => Object.entries(g[k] || {}).forEach(([kk, v]) => {
        const key = k === "zone" ? (LEGACY_ZONE[kk] || kk) : kk;
        m[g.name][k][key] = (m[g.name][k][key] || 0) + v;
      }));
    }));
    return m;
  }, [practices]);

  const cols = pGroups.length ? pGroups.map((g) => g.name)
    : Object.keys(byGroup).sort();
  const plannedFor = (name) => {
    const g = pGroups.filter((x) => x.name === name)[0];
    return g ? weeks.reduce((a, wk) => a + (Number(wk.targets[g.id]) || 0), 0) : 0;
  };
  const deckTime = practices.reduce((a, p) => a + (p.seconds || 0), 0);

  const zoneRows = ZONE_ORDER.filter((z) => cols.some((c) => (byGroup[c] || { zone: {} }).zone[z]));
  const strokeRows = ["free", "back", "breast", "fly", "im", "choice", "untagged"]
    .filter((k) => cols.some((c) => (byGroup[c] || { stroke: {} }).stroke[k]));

  /* Every test with times in this season, swimmers down and checkpoints across. */
  const testTables = useMemo(() => {
    const out = [];
    (tests || []).forEach((test) => {
      const runs = practices.filter((p) => p.results && p.results[test.id] &&
        Object.keys(p.results[test.id]).length);
      if (!runs.length) return;
      const shapes = runs.map((p) => testShape(p, test.id, cfg));
      const cols = runs.map((p, i) => {
        const wi = weeks.findIndex((x) => x.start === mondayOf(p.date));
        return { date: p.date, week: wi >= 0 ? wi + 1 : null,
          label: shapes[i] ? shapes[i].label : "" };
      });
      const ids = [...new Set(runs.flatMap((p) => Object.keys(p.results[test.id])))];
      const rows = ids.map((id) => {
        const sw = ((season.roster) || []).filter((x) => x.id === id)[0];
        const vals = runs.map((p, i) => {
          let v = meanOf(p.results[test.id][id]);
          if (v == null) return null;
          if (test.mode === "total" && shapes[i]) v = v / Math.max(1, shapes[i].reps);
          return v;
        });
        const real = vals.filter((v) => v != null);
        return { id, name: sw ? sw.name : "Swimmer", vals,
          change: real.length > 1 ? real[real.length - 1] - real[0] : null };
      }).filter((r) => r.vals.some((v) => v != null));
      if (rows.length) out.push({ test, cols, rows });
    });
    return out;
  }, [tests, practices, weeks, season, cfg]);

  const testPages = useMemo(() => {
    const out = []; let cur = [], room = 22;
    testTables.forEach((t) => {
      cur.push(t); room -= t.rows.length + 4;
      if (room <= 4) { out.push(cur); cur = []; room = 22; }
    });
    if (cur.length) out.push(cur);
    return out;
  }, [testTables]);

  const WEEKS_PER_PAGE = orient === "portrait" ? 18 : 13;
  const weekPages = [];
  for (let i = 0; i < weeks.length; i += WEEKS_PER_PAGE) weekPages.push(weeks.slice(i, i + WEEKS_PER_PAGE));

  const pct = (v, t) => (t ? Math.round((v / t) * 100) : 0);

  return (
    <div className="pd-printwrap" style={{ position: "fixed", inset: 0, background: "var(--scrim)",
      overflow: "auto", padding: 20, zIndex: 80 }}>
      <style>{`@page{size:letter ${orient};margin:.4in;}`}</style>

      <div className="pd-noprint" style={{ display: "flex", gap: 8, alignItems: "center",
        maxWidth: w, margin: "0 auto 14px", flexWrap: "wrap" }}>
        <div className="pd-eyebrow">Season report · {season.name}</div>
        <div className="pd-seg2" style={{ marginLeft: 8 }}>
          {["portrait", "landscape"].map((o) => (
            <button key={o} data-on={orient === o ? "1" : "0"} style={{ textTransform: "capitalize" }}
              onClick={() => setOrient(o)}>{o}</button>
          ))}
        </div>
        <span style={{ fontSize: 12, color: "var(--muted)" }}>
          {1 + testPages.length + weekPages.length + Math.max(practices.length, 1)} pages
        </span>
        <div style={{ marginLeft: "auto", display: "flex", gap: 8 }}>
          <button className="pd-btn" data-key="1" onClick={() => window.print()}>Print / save as PDF</button>
          <button className="pd-btn" data-ghost="1" onClick={onClose}>Close</button>
        </div>
      </div>

      {/* ---------- 1. the season at a glance ---------- */}
      <FitPage w={w} h={h} resetKey={"cover" + orient}>
        <div className="pg-hd">
          <h1>{season.name}</h1>
          <span>Season report</span>
          <span style={{ marginLeft: "auto" }}>{pretty(from)} – {pretty(to)}</span>
        </div>

        <div className="pg-stat">
          <div><b>{practices.length}</b><span>Practices</span></div>
          <div><b>{weeks.length}</b><span>Weeks</span></div>
          <div><b>{fmt(deckTime)}</b><span>On the clock</span></div>
          <div><b>{(season.roster || []).filter((r) => r.name.trim()).length}</b><span>Swimmers</span></div>
        </div>

        <div className="pg-sec">Distance against plan</div>
        <table className="pg-tbl">
          <thead><tr>
            <th>Group</th><th className="pg-num">Planned</th><th className="pg-num">Logged</th>
            <th className="pg-num">Difference</th><th style={{ width: "26%" }}>Of plan</th>
          </tr></thead>
          <tbody>
            {cols.map((name) => {
              const planned = plannedFor(name);
              const logged = (byGroup[name] || { yards: 0 }).yards;
              const g = pGroups.filter((x) => x.name === name)[0];
              return (
                <tr key={name}>
                  <td>{g && <span className="pg-sw" style={{ background: g.color }} />}{name}</td>
                  <td className="pg-num">{planned ? comma(planned) : "—"}</td>
                  <td className="pg-num">{comma(logged)}</td>
                  <td className="pg-num">{planned ? (logged - planned >= 0 ? "+" : "") + comma(logged - planned) : "—"}</td>
                  <td>
                    <div className="pg-mini"><i style={{ width: `${Math.min(100, pct(logged, planned))}%` }} /></div>
                    <span style={{ fontSize: ".85em" }}>{planned ? pct(logged, planned) + "%" : ""}</span>
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>

        {!!sortedMeets(season).length && (
          <>
            <div className="pg-sec">Meets</div>
            <table className="pg-tbl">
              <tbody>
                {sortedMeets(season).map((m) => {
                  const wi = weeks.findIndex((w) => w.start === mondayOf(m.date));
                  return (
                    <tr key={m.id}>
                      <td style={{ width: 110 }}>{pretty(m.date)}</td>
                      <td style={{ width: 54, fontFamily: "var(--disp)" }}>{meetLevel(m)}</td>
                      <td>{m.name || "Meet"}</td>
                      <td className="pg-num" style={{ width: 80 }}>{wi >= 0 ? `Week ${wi + 1}` : ""}</td>
                    </tr>
                  );
                })}
              </tbody>
            </table>
          </>
        )}

        {!!zoneRows.length && (
          <>
            <div className="pg-sec">Effort distribution</div>
            <table className="pg-tbl">
              <thead><tr>
                <th>Zone</th>
                {cols.map((c) => <th key={c} className="pg-num">{c}</th>)}
              </tr></thead>
              <tbody>
                {zoneRows.map((z) => (
                  <tr key={z}>
                    <td>
                      <span className="pg-sw" style={{ background: COLOR["z_" + z] || "#456E79" }} />
                      {zoneLabel(z, cfg.zoneVocab)}
                    </td>
                    {cols.map((c) => {
                      const b = byGroup[c] || { zone: {}, yards: 0 };
                      const v = b.zone[z] || 0;
                      return <td key={c} className="pg-num">
                        {v ? `${comma(v)}  ${pct(v, b.yards)}%` : "—"}</td>;
                    })}
                  </tr>
                ))}
              </tbody>
            </table>
          </>
        )}

        {!!strokeRows.length && (
          <>
            <div className="pg-sec">Stroke distribution</div>
            <table className="pg-tbl">
              <thead><tr>
                <th>Stroke</th>
                {cols.map((c) => <th key={c} className="pg-num">{c}</th>)}
              </tr></thead>
              <tbody>
                {strokeRows.map((k) => (
                  <tr key={k}>
                    <td><span className="pg-sw" style={{ background: COLOR[k] || "#456E79" }} />
                      {STROKE_LABEL[k] || k}</td>
                    {cols.map((c) => {
                      const b = byGroup[c] || { stroke: {}, yards: 0 };
                      const v = b.stroke[k] || 0;
                      return <td key={c} className="pg-num">
                        {v ? `${comma(v)}  ${pct(v, b.yards)}%` : "—"}</td>;
                    })}
                  </tr>
                ))}
              </tbody>
            </table>
          </>
        )}
      </FitPage>

      {/* ---------- 1b. tests ---------- */}
      {testPages.map((chunk, pi) => (
        <FitPage w={w} h={h} base={10} key={"test" + pi} resetKey={"test" + pi + orient}>
          <div className="pg-hd">
            <h1>Test sets</h1>
            <span>{season.name}</span>
            <span style={{ marginLeft: "auto" }}>
              {testPages.length > 1 ? `Part ${pi + 1} of ${testPages.length}` : ""}
            </span>
          </div>
          {chunk.map((t) => (
            <div key={t.test.id} style={{ marginBottom: 16 }}>
              <div className="pg-sec">{t.test.name}</div>
              <table className="pg-tbl">
                <thead>
                  <tr>
                    <th style={{ width: "26%" }}>Swimmer</th>
                    {t.cols.map((c, i) => (
                      <th key={i} className="pg-num">
                        {c.week ? `Wk ${c.week}` : pretty(c.date)}
                        {c.label ? <span style={{ display: "block", fontWeight: 400 }}>{c.label}</span> : null}
                      </th>
                    ))}
                    <th className="pg-num" style={{ width: 74 }}>Change</th>
                  </tr>
                </thead>
                <tbody>
                  {t.rows.map((r) => (
                    <tr key={r.id}>
                      <td>{r.name}</td>
                      {r.vals.map((v, i) => (
                        <td key={i} className="pg-num">{v == null ? "—" : secToPace(v, true)}</td>
                      ))}
                      <td className="pg-num">
                        {r.change == null ? "—"
                          : `${r.change < 0 ? "−" : "+"}${secToPace(Math.abs(r.change), true)}`}
                      </td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
          ))}
          <div style={{ fontSize: ".85em", color: "#5A6A6E", fontFamily: "var(--body)", marginTop: 6 }}>
            Averages per repeat. A set that grew shows what it was above each column.
          </div>
        </FitPage>
      ))}

      {/* ---------- 2. week by week ---------- */}
      {weekPages.map((chunk, pi) => (
        <FitPage w={w} h={h} key={"wk" + pi} resetKey={"wk" + pi + orient}>
          <div className="pg-hd">
            <h1>Week by week</h1>
            <span>{season.name}</span>
            <span style={{ marginLeft: "auto" }}>
              {weekPages.length > 1 ? `Part ${pi + 1} of ${weekPages.length}` : ""}
            </span>
          </div>
          <table className="pg-tbl">
            <thead><tr>
              <th style={{ width: 34 }}>Wk</th><th style={{ width: 92 }}>Of</th>
              {cols.map((c) => <th key={c} className="pg-num">{c}</th>)}
              <th className="pg-num" style={{ width: 46 }}>Prac</th>
              <th>Focus</th>
            </tr></thead>
            <tbody>
              {chunk.map((wk) => {
                const idx = weeks.indexOf(wk) + 1;
                const n = practices.filter((p) => mondayOf(p.date) === wk.start).length;
                return (
                  <tr key={wk.id}>
                    <td>{idx}</td>
                    <td>{pretty(wk.start)}</td>
                    {cols.map((c) => {
                      const g = pGroups.filter((x) => x.name === c)[0];
                      const t = g ? Number(wk.targets[g.id]) || 0 : 0;
                      const a = weekFor(actuals, wk.start, pGroups.filter((x) => x.name === c)[0] || { name: c });
                      return <td key={c} className="pg-num">
                        {a ? comma(a) : "—"}{t ? ` / ${comma(t)}` : ""}</td>;
                    })}
                    <td className="pg-num">{n || "—"}</td>
                    <td style={{ fontFamily: "var(--body)" }}>{wk.focus}</td>
                  </tr>
                );
              })}
            </tbody>
          </table>
          <div style={{ fontSize: ".85em", color: "#5A6A6E", marginTop: 6, fontFamily: "var(--body)" }}>
            Logged / planned, in {courseName(season.course)}.
          </div>
        </FitPage>
      ))}

      {/* ---------- 3. every practice, one to a page ---------- */}
      {practices.map((p) => {
        const doc = toDoc(p.doc || p.text);
        const per = docTotals(doc, primed(cfg, p));
        return (
          <FitPage w={w} h={h} key={p.id} resetKey={p.id + orient}>
            <div className="pg-hd">
              <h1>{p.title || "Practice"}</h1>
              <span>{longDate(p.date)}</span>
              <span style={{ marginLeft: "auto" }}>{season.name}</span>
            </div>
            <PracticeTable doc={doc} per={per} unit={unit} />
          </FitPage>
        );
      })}

      {!practices.length && (
        <FitPage w={w} h={h} resetKey="none">
          <div className="pg-hd"><h1>No practices yet</h1></div>
          <div style={{ fontFamily: "var(--body)", marginTop: 10 }}>
            Nothing has been logged between {pretty(from)} and {pretty(to)}.
          </div>
        </FitPage>
      )}
    </div>
  );
}

/* ============================================================
   9c. SETTINGS — general, zones, season, sets
   ============================================================ */

const generalGroupNames = (seasons, log) => {
  const st = seasons.map(primaryStructure).filter(Boolean);
  if (st.length) {
    const seen = {};
    st.forEach((x) => x.groups.forEach((g) => (seen[g.id] = g.name)));
    return Object.entries(seen).map(([value, label]) => ({ value, label }));
  }
  return [...new Set(log.flatMap((p) => (p.byGroup || []).map((g) => g.name)))]
    .sort().map((n) => ({ value: n, label: n }));
};

async function probeOffline() {
  if (typeof navigator === "undefined" || !("serviceWorker" in navigator))
    return { state: "none", note: "This browser cannot keep the app for offline use." };
  try {
    const reg = await navigator.serviceWorker.getRegistration();
    const names = await caches.keys();
    const mine = names.filter((k) => k.indexOf("practice-desk") === 0)[0];
    if (!reg || !mine) return { state: "pending", note: "Not stored yet. Stay online for a few seconds." };
    const held = (await (await caches.open(mine)).keys()).length;
    return { state: held >= 8 ? "ready" : "pending", held,
      controlled: !!navigator.serviceWorker.controller };
  } catch (e) { return { state: "none", note: "Could not check." }; }
}

function OfflineCard() {
  const [s, setS] = useState(null);
  const check = useCallback(() => { probeOffline().then(setS); }, []);
  useEffect(() => { check(); const t = setInterval(check, 4000); return () => clearInterval(t); }, [check]);
  if (!s) return null;
  const ready = s.state === "ready";
  return (
    <div className="pd-card" style={{ padding: 18 }}>
      <div className="pd-eyebrow" style={{ marginBottom: 10 }}>Offline</div>
      <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
        <i style={{ width: 10, height: 10, borderRadius: "50%", flex: "none",
          background: ready ? "var(--aqua)" : s.state === "pending" ? "#F2B01E" : "var(--faint)" }} />
        <b style={{ fontWeight: 600, fontSize: 14 }}>
          {ready ? "Ready to use without internet" : s.state === "pending" ? "Still storing…" : "Not available here"}
        </b>
        {ready && <span className="pd-num" style={{ marginLeft: "auto", fontSize: 11, color: "var(--faint)" }}>
          {s.held} files kept
        </span>}
      </div>
      <div style={{ fontSize: 12, color: "var(--muted)", lineHeight: 1.6, marginTop: 8 }}>
        {ready
          ? "Everything needed to run is on this device. Practices were never anywhere else."
          : s.note || "Leave this page open on a connection for a moment and it will finish."}
      </div>
    </div>
  );
}

function GeneralPage({ cfg, setCfg, log, seasons }) {
  return (
    <div style={{ display: "grid", gap: 14, maxWidth: 620 }}>
      <OfflineCard />

      <div className="pd-card" style={{ padding: 18 }}>
        <div className="pd-eyebrow" style={{ marginBottom: 12 }}>Appearance</div>
        <div className="pd-seg2">
          {[["dark", "Dark"], ["light", "Light"]].map(([k, l]) => (
            <button key={k} data-on={(cfg.theme || "dark") === k ? "1" : "0"}
              onClick={() => setCfg({ ...cfg, theme: k })}>{l}</button>
          ))}
        </div>
      </div>

      <div className="pd-card" style={{ padding: 18 }}>
        <div className="pd-eyebrow" style={{ marginBottom: 12 }}>Reading practices</div>
        <Field label="Label effort zones as">
          <div className="pd-seg2">
            {VOCABS.map(([k, l]) => (
              <button key={k} data-on={cfg.zoneVocab === k ? "1" : "0"}
                onClick={() => setCfg({ ...cfg, zoneVocab: k })}>{l}</button>
            ))}
          </div>
        </Field>
        <label style={{ display: "flex", gap: 10, alignItems: "flex-start", marginTop: 4 }}>
          <input type="checkbox" checked={cfg.vagueByDistance !== false} style={{ marginTop: 3 }}
            onChange={(e) => setCfg({ ...cfg, vagueByDistance: e.target.checked })} />
          <span style={{ fontSize: 13, lineHeight: 1.55 }}>
            Read <b>fast</b>, <b>hard</b> and <b>quick</b> by distance
            <span style={{ display: "block", color: "var(--muted)", fontSize: 12 }}>
              25s land in Platinum, 50s in Gold, 100s in Purple, longer in Red.
            </span>
          </span>
        </label>
      </div>

      <div className="pd-card" style={{ padding: 18 }}>
        <div className="pd-eyebrow" style={{ marginBottom: 8 }}>About</div>
        <div className="pd-num" style={{ fontSize: 13 }}>{APP_VERSION}</div>
        <div style={{ fontSize: 12, color: "var(--muted)", lineHeight: 1.6, marginTop: 6 }}>
          Quote this version if you ever need to report something.
        </div>
      </div>

      <div className="pd-card" style={{ padding: 18 }}>
        <div className="pd-eyebrow" style={{ marginBottom: 12 }}>Counting volume</div>
        <div style={{ fontSize: 12.5, color: "var(--muted)", lineHeight: 1.6, marginBottom: 10 }}>
          When a practice has several groups, this decides whose yardage the log and season plan count.
        </div>
        <TrackPicker opts={generalGroupNames(seasons, log)} cfg={cfg} setCfg={setCfg} inline />
      </div>
    </div>
  );
}

/* ---------------------------------------------------------- zones */

function ZonesPage({ cfg, setCfg }) {
  const [drafts, setDrafts] = useState({});
  const triggers = cfg.zoneTriggers || DEFAULT_TRIGGERS;

  const write = (key, list) =>
    setCfg({ ...cfg, zoneTriggers: { ...triggers, [key]: list } });
  const add = (key, word) => {
    const w = String(word || "").trim();
    if (!w) return;
    const list = triggers[key] || [];
    if (list.some((x) => x.toLowerCase() === w.toLowerCase())) return;
    write(key, [...list, w]);
  };
  const drop = (key, word) => write(key, (triggers[key] || []).filter((x) => x !== word));

  const chartCols = [["gold", "Gold"], ["purple", "Purple"], ["blue", "Blue"], ["red", "Red"], ["white", "White / Pink"]];
  const dists = ["25", "50", "75", "100", "150", "200", "400"];

  return (
    <div style={{ display: "grid", gap: 14 }}>
      <div className="pd-card" style={{ padding: 18 }}>
        <div className="pd-big" style={{ fontSize: 26 }}>Trigger words</div>
        <div style={{ color: "var(--muted)", fontSize: 13, lineHeight: 1.65, maxWidth: 700, marginTop: 4 }}>
          Each zone starts out answering only to its own color. Add whatever else you actually
          write: codes, abbreviations, your own shorthand. Practices will pick them up.
          Suggestions below each row come from the workbook.
        </div>
      </div>

      <div className="pd-card">
        {COLOR_SYSTEM.slice().reverse().map((z) => {
          const list = triggers[z.key] || [];
          const ideas = (TRIGGER_IDEAS[z.key] || [])
            .filter((w) => !list.some((x) => x.toLowerCase() === w.toLowerCase()));
          return (
            <div key={z.key} style={{ padding: "12px 16px", borderBottom: "1px solid var(--soft)" }}>
              <div style={{ display: "flex", gap: 14, alignItems: "flex-start", flexWrap: "wrap" }}>
                <div style={{ width: 132, flex: "none", display: "flex", alignItems: "center", gap: 9 }}>
                  <i style={{ width: 14, height: 14, borderRadius: 3, background: z.swatch, flex: "none",
                    boxShadow: "0 0 0 1px var(--ring)" }} />
                  <div>
                    <div style={{ fontWeight: 500 }}>{z.color}</div>
                    <div className="pd-num" style={{ fontSize: 11, color: "var(--muted)" }}>{z.code}</div>
                  </div>
                </div>
                <div style={{ flex: "1 1 320px", minWidth: 0 }}>
                  <div style={{ display: "flex", gap: 6, flexWrap: "wrap", alignItems: "center" }}>
                    {list.map((w) => (
                      <span className="pd-trig" key={w}>{w}
                        <button onClick={() => drop(z.key, w)} title={`Remove ${w}`}>×</button>
                      </span>
                    ))}
                    <input className="pd-in" style={{ width: 130, padding: "4px 9px", fontSize: 12 }}
                      placeholder="add a word…" value={drafts[z.key] || ""}
                      onChange={(e) => setDrafts({ ...drafts, [z.key]: e.target.value })}
                      onKeyDown={(e) => {
                        if (e.key === "Enter") { add(z.key, drafts[z.key]); setDrafts({ ...drafts, [z.key]: "" }); }
                      }} />
                  </div>
                  {!!ideas.length && (
                    <div style={{ display: "flex", gap: 5, flexWrap: "wrap", marginTop: 7 }}>
                      {ideas.map((w) => (
                        <button className="pd-add" key={w} onClick={() => add(z.key, w)}>+ {w}</button>
                      ))}
                    </div>
                  )}
                </div>
              </div>
            </div>
          );
        })}
        <div style={{ padding: "12px 16px", display: "flex", justifyContent: "flex-end" }}>
          <button className="pd-btn" data-ghost="1"
            onClick={() => setCfg({ ...cfg, zoneTriggers: JSON.parse(JSON.stringify(DEFAULT_TRIGGERS)) })}>
            Reset to colors only
          </button>
        </div>
      </div>

      <div className="pd-card">
        <div style={{ padding: "14px 16px 8px" }}>
          <div className="pd-eyebrow">Building sets inside a color</div>
          <div style={{ color: "var(--muted)", fontSize: 12.5, marginTop: 4 }}>
            Repetitions that keep a set in its zone at 10 to 30 seconds rest. Practices are checked
            against this while you write.
          </div>
        </div>
        <div style={{ overflowX: "auto" }}>
          <div className="pd-row pd-head" style={{ gridTemplateColumns: `110px repeat(${chartCols.length},1fr)`, minWidth: 600 }}>
            <div>Distance</div>
            {chartCols.map(([k, l]) => (
              <div key={k} style={{ display: "flex", alignItems: "center", gap: 5 }}>
                <i style={{ width: 8, height: 8, borderRadius: 2, flex: "none",
                  background: ZONE_BY_KEY[k].swatch, boxShadow: "0 0 0 1px var(--ring)" }} />{l}
              </div>
            ))}
          </div>
          {dists.map((d) => (
            <div className="pd-row" key={d} style={{ gridTemplateColumns: `110px repeat(${chartCols.length},1fr)`, minWidth: 600 }}>
              <div className="pd-num">{d}</div>
              {chartCols.map(([k]) => {
                const r = REP_CHART[d] && REP_CHART[d][k];
                return <div className="pd-num" key={k} style={{ color: r ? "var(--ink)" : "var(--track)" }}>
                  {r ? (r[1] ? `${r[0]}–${r[1]}` : `${r[0]}+`) : "—"}</div>;
              })}
            </div>
          ))}
        </div>
      </div>

      <div className="pd-card">
        <div className="pd-row pd-head" style={{ gridTemplateColumns: "140px 70px 180px 200px 120px 100px 100px", minWidth: 930 }}>
          <div>Color</div><div>Code</div><div>What it trains</div><div>System</div>
          <div>Heart rate</div><div>Lactate</div><div>Rest</div>
        </div>
        {COLOR_SYSTEM.slice().reverse().map((z) => (
          <div className="pd-row" key={z.key}
            style={{ gridTemplateColumns: "140px 70px 180px 200px 120px 100px 100px", minWidth: 930 }}>
            <div style={{ display: "flex", alignItems: "center", gap: 9, fontWeight: 500 }}>
              <i style={{ width: 13, height: 13, borderRadius: 3, background: z.swatch, flex: "none",
                boxShadow: "0 0 0 1px var(--ring)" }} />{z.color}
            </div>
            <div className="pd-num" style={{ color: z.swatch }}>{z.code}</div>
            <div>{z.name}</div>
            <div style={{ color: "var(--muted)", fontSize: 12 }}>{z.zone}<br />{z.trains}</div>
            <div style={{ color: "var(--muted)", fontSize: 12 }}>{z.hr}</div>
            <div style={{ color: "var(--muted)", fontSize: 12 }}>{z.lac}</div>
            <div style={{ color: "var(--muted)", fontSize: 12 }}>{z.rest}</div>
          </div>
        ))}
        <div style={{ padding: "10px 16px 14px", fontSize: 11.5, color: "var(--faint)", lineHeight: 1.6 }}>
          From the Custom Urbanchek Color System Workbook by Coach Maximillian Thomas, drawing on
          Jon Urbanchek’s charts and Dr. G. Sokolovas’s “Energy Zones in Swimming.”
        </div>
      </div>
    </div>
  );
}


/* ---------------------------------------------------------- season */

const CP_COLS = "76px minmax(0,1fr) 92px 34px";
const ATT_COLS = "minmax(0,1.5fr) 96px 150px 108px minmax(150px,1.2fr)";
const ROSTER_COLS = "minmax(0,1.6fr) 150px 92px 84px 132px 100px 34px";

const PLAN_PAGES = [["plan", "Weekly plan"], ["effort", "Effort plan"], ["tests", "Tests"],
  ["groups", "Groups"], ["roster", "Roster"], ["meets", "Meets"], ["sheet", "Attendance"]];


function PlanWeeks({ active, patch, cfg, pGroups, weeks, actuals, lens, setLens }) {
  const ch = chartInk(cfg);
  const lensGroups = lens === "__all__" ? pGroups : pGroups.filter((g) => g.id === lens);
  const lensGroup = lens === "__all__" ? null : lensGroups[0];
  const sumT = (w) => lensGroups.reduce((b, g) => b + (Number(w.targets[g.id]) || 0), 0);
  const sumA = (w) => lensGroups.reduce((b, g) => b + weekFor(actuals, w.start, g), 0);
  const totalTarget = weeks.reduce((a, w) => a + sumT(w), 0);
  const totalActual = weeks.reduce((a, w) => a + sumA(w), 0);
  const chart = weeks.map((w, i) => ({ name: `W${i + 1}`, Target: sumT(w), Actual: sumA(w) }));
  const meetMarks = sortedMeets(active).map((m) => {
    const i = weeks.findIndex((w) => w.start === mondayOf(m.date));
    return i >= 0 ? { ...m, week: `W${i + 1}` } : null;
  }).filter(Boolean);

  return (

        <>
          <div className="pd-card" style={{ padding: 16 }}>
            {pGroups.length > 1 && (
              <div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 10 }}>
                <div className="pd-seg2">
                  <button data-on={lens === "__all__" ? "1" : "0"}
                    onClick={() => setLens("__all__")}>All groups</button>
                  {pGroups.map((g) => (
                    <button key={g.id} data-on={lens === g.id ? "1" : "0"} onClick={() => setLens(g.id)}>
                      <i style={{ display: "inline-block", width: 7, height: 7, borderRadius: 2,
                        background: g.color, marginRight: 6 }} />{g.name}
                    </button>
                  ))}
                </div>
              </div>
            )}
            <div style={{ display: "flex", gap: 30, marginBottom: 14, flexWrap: "wrap" }}>
              <div><div className="pd-eyebrow">Planned</div>
                <div className="pd-big" style={{ fontSize: 32 }}>{comma(totalTarget)}</div></div>
              <div><div className="pd-eyebrow">Logged</div>
                <div className="pd-big" style={{ fontSize: 32, color: "var(--aqua)" }}>{comma(totalActual)}</div></div>
              <div><div className="pd-eyebrow">Difference</div>
                <div className="pd-big" style={{ fontSize: 32,
                  color: totalActual >= totalTarget ? "var(--aqua)" : "var(--clock)" }}>
                  {totalActual - totalTarget >= 0 ? "+" : ""}{comma(totalActual - totalTarget)}</div></div>
            </div>
            <div style={{ height: 230 }}>
              <ResponsiveContainer>
                <BarChart data={chart} margin={{ top: 4, right: 4, left: -18, bottom: 0 }}>
                  <CartesianGrid stroke={ch.grid} vertical={false} />
                  <XAxis dataKey="name" tick={{ fill: ch.tick, fontSize: 11 }} axisLine={{ stroke: ch.axis }} tickLine={false} />
                  <YAxis tick={{ fill: ch.tick, fontSize: 11 }} axisLine={false} tickLine={false} />
                  <Tooltip contentStyle={{ background: ch.card, border: `1px solid ${ch.edge}`, borderRadius: 8, fontSize: 12 }}
                    formatter={(v) => comma(v)} />
                  <Legend wrapperStyle={{ fontSize: 12 }} />
                  <Bar dataKey="Target" fill={ch.plan} radius={[3, 3, 0, 0]} />
                  <Bar dataKey="Actual" fill={lensGroup ? lensGroup.color : ch.act} radius={[3, 3, 0, 0]} />
                  {meetMarks.map((m) => (
                    <ReferenceLine key={m.id} x={m.week} stroke={MEET_INK[meetLevel(m)]}
                      strokeDasharray="4 3"
                      label={{ value: meetLevel(m), position: "top", fontSize: 10,
                        fill: MEET_INK[meetLevel(m)] }} />
                  ))}
                </BarChart>
              </ResponsiveContainer>
            </div>
          </div>

          <div className="pd-card" style={{ overflowX: "auto" }}>
            <div className="pd-row pd-head"
              style={{ gridTemplateColumns: `52px 92px repeat(${pGroups.length},minmax(120px,1fr)) minmax(180px,1.4fr)`,
                minWidth: 420 + pGroups.length * 130 }}>
              <div>Wk</div><div>Of</div>
              {pGroups.map((g) => <div key={g.id} style={{ color: g.color }}>{g.name}</div>)}
              <div>Focus</div>
            </div>
            {weeks.map((w, i) => (
              <div className="pd-row" key={w.id}
                style={{ gridTemplateColumns: `52px 92px repeat(${pGroups.length},minmax(120px,1fr)) minmax(180px,1.4fr)`,
                  minWidth: 420 + pGroups.length * 130 }}>
                <div className="pd-num" style={{ color: "var(--muted)" }}>{i + 1}</div>
                <div style={{ fontSize: 12 }}>
                  {pretty(w.start)}
                  {meetsInWeek(active, w.start).map((m) => (
                    <div key={m.id} style={{ fontSize: 10.5, marginTop: 2, lineHeight: 1.3,
                      color: MEET_INK[meetLevel(m)], fontWeight: 600, overflow: "hidden",
                      textOverflow: "ellipsis", whiteSpace: "nowrap" }}
                      title={`${m.name || "Meet"} · ${pretty(m.date)}`}>
                      {meetLevel(m)} · {m.name || "Meet"}
                    </div>
                  ))}
                </div>
                {pGroups.map((g) => {
                  const act = weekFor(actuals, w.start, g);
                  const tgt = Number(w.targets[g.id]) || 0;
                  const pct = tgt ? Math.min(100, (act / tgt) * 100) : 0;
                  return (
                    <div key={g.id}>
                      <input className="pd-in pd-num" type="number" min="0" step="500" placeholder="0"
                        value={w.targets[g.id] || ""}
                        onChange={(e) => patch({ ...active, weeks: weeks.map((x) => x.id !== w.id ? x
                          : { ...x, targets: { ...x.targets, [g.id]: +e.target.value || 0 } }) })} />
                      <div className="pd-bar" style={{ marginTop: 3 }}>
                        <i style={{ width: `${pct}%`, background: pct >= 100 ? "var(--aqua)" : "var(--clock)" }} />
                      </div>
                      <div className="pd-num" style={{ fontSize: 10.5, color: "var(--muted)", marginTop: 2 }}>
                        {comma(act)} logged
                      </div>
                    </div>
                  );
                })}
                <div>
                  <input className="pd-in" value={w.focus} placeholder="Aerobic base, test set Friday…"
                    onChange={(e) => patch({ ...active, weeks: weeks.map((x) =>
                      x.id === w.id ? { ...x, focus: e.target.value } : x) })} />
                </div>
              </div>
            ))}
          </div>
        </>
      
  );
}

function PlanMeets({ active, patch, weeks }) {

        const meets = sortedMeets(active);
        const setMeets = (next) => patch({ ...active, meets: next });
        const inRange = (d) => weeks.length &&
          d >= weeks[0].start && d <= addDays(weeks[weeks.length - 1].start, 6);
        return (
          <div style={{ display: "grid", gap: 12 }}>
            <div style={{ fontSize: 13, color: "var(--muted)", lineHeight: 1.65, maxWidth: 700 }}>
              Meets shape everything else. They mark up the weekly plan and the season chart, add a
              countdown while you are writing, and appear as their own columns on the attendance sheet.
              Level A is what you taper for.
            </div>

            <div className="pd-card">
              <div className="pd-row pd-head" style={{ gridTemplateColumns: "140px minmax(0,1fr) 170px 90px 90px" }}>
                <div>Date</div><div>Meet</div><div>Level</div><div>Week</div><div />
              </div>
              {!meets.length && <div className="pd-empty">No meets yet.</div>}
              {meets.map((m) => {
                const wi = weeks.findIndex((w) => w.start === mondayOf(m.date));
                return (
                  <div className="pd-row" key={m.id}
                    style={{ gridTemplateColumns: "140px minmax(0,1fr) 170px 90px 90px" }}>
                    <div>
                      <input className="pd-in" type="date" value={m.date}
                        onChange={(e) => setMeets(meets.map((x) => x.id === m.id ? { ...x, date: e.target.value } : x))} />
                    </div>
                    <div>
                      <input className="pd-in" value={m.name} placeholder="Meet name"
                        onChange={(e) => setMeets(meets.map((x) => x.id === m.id ? { ...x, name: e.target.value } : x))} />
                    </div>
                    <div>
                      <select className="pd-in" value={meetLevel(m)}
                        style={{ color: MEET_INK[meetLevel(m)] }}
                        onChange={(e) => setMeets(meets.map((x) => x.id === m.id ? { ...x, level: e.target.value } : x))}>
                        {MEET_LEVELS.map(([k, l]) => <option key={k} value={k}>{l}</option>)}
                      </select>
                    </div>
                    <div style={{ fontSize: 12, color: wi >= 0 ? "var(--muted)" : "var(--clock)" }}>
                      {wi >= 0 ? `Week ${wi + 1}` : inRange(m.date) ? "—" : "outside"}
                    </div>
                    <div style={{ textAlign: "right" }}>
                      <button className="pd-btn" data-ghost="1"
                        onClick={() => setMeets(meets.filter((x) => x.id !== m.id))}>Remove</button>
                    </div>
                  </div>
                );
              })}
              <div style={{ padding: 12 }}>
                <button className="pd-btn" data-key="1" onClick={() => setMeets([...meets,
                  { id: uid(), date: weeks.length ? weeks[weeks.length - 1].start : today(),
                    name: "", level: "C" }])}>Add meet</button>
              </div>
            </div>
          </div>
        );
      
}

function PlanEffort({ active, patch, cfg, weeks, effortSt, setEffortSt }) {

        const wkCount = weeks.length || 16;
        const st = (active.structures || []).filter((x) => x.id === effortSt)[0]
          || primaryStructure(active) || (active.structures || [])[0];
        if (!st) return <div className="pd-card pd-empty">Add a group structure first.</div>;
        const setPeriod = (gid, period) => patch({ ...active,
          structures: active.structures.map((x) => x.id !== st.id ? x
            : { ...x, groups: x.groups.map((y) => (y.id === gid ? { ...y, period } : y)) }) });
        const wide = cfg.zoneVocab === "name";
        const cell = wide ? 92 : 70;
        const grid = `62px 76px 104px repeat(7,minmax(${cell}px,1fr)) 60px 34px`;
        const minW = 336 + cell * 7;

        return (
          <div style={{ display: "grid", gap: 14 }}>
            <div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
              <div style={{ fontSize: 13, color: "var(--muted)", lineHeight: 1.65, flex: "1 1 380px" }}>
                Each group gets its own block plan. A row covers a run of weeks and says what share of
                that group’s distance each zone should take. Rows are filled in from the group’s name
                (sprint, middle distance, or distance), and every row has to add to 100. Columns are
                labeled in {cfg.zoneVocab === "color" ? "colors" : cfg.zoneVocab === "code" ? "EN / SP codes" : "what each zone trains"};
                change that under Settings → Zones. A column covers every color inside it, so
                {" "}{codeLabel("EN1", "code")} is {codeLabel("EN1", "color")} together.
              </div>
              {(active.structures || []).length > 1 && (
                <select className="pd-in" style={{ width: 200 }} value={st.id}
                  onChange={(e) => setEffortSt(e.target.value)}>
                  {active.structures.map((x) => <option key={x.id} value={x.id}>{x.name}</option>)}
                </select>
              )}
            </div>

            {st.groups.map((g) => {
              const period = periodFor(g, wkCount);
              const covered = period.reduce((a, b) => a + Math.max(1, b.span || 1), 0);
              const write = (next) => setPeriod(g.id, next);
              const edit = (id, patchRow) =>
                write(period.map((b) => (b.id === id ? { ...b, ...patchRow } : b)));
              let acc = 0;

              return (
                <div className="pd-card" key={g.id}>
                  <div style={{ display: "flex", alignItems: "center", gap: 10, padding: "12px 14px",
                    borderBottom: "1px solid var(--line)", flexWrap: "wrap" }}>
                    <i style={{ width: 14, height: 14, borderRadius: 3, background: g.color,
                      boxShadow: "0 0 0 1px var(--ring)" }} />
                    <b style={{ fontSize: 15, fontWeight: 600 }}>{g.name}</b>
                    <span className="pd-eyebrow">{archetypeFor(g.name)} shape</span>
                    <span style={{ marginLeft: "auto", fontSize: 12,
                      color: covered === wkCount ? "var(--faint)" : "var(--clock)" }}>
                      {covered} of {wkCount} weeks covered
                    </span>
                  </div>

                  <div style={{ overflowX: "auto" }}>
                    <div className="pd-row pd-head" style={{ gridTemplateColumns: grid, minWidth: minW }}>
                      <div>Weeks</div><div>Range</div><div>Phase</div>
                      {CODES.map((c) => (
                        <div key={c} style={{ display: "flex", alignItems: "center", gap: 5,
                          whiteSpace: "normal", lineHeight: 1.25 }}>
                          <i style={{ width: 8, height: 8, borderRadius: 2, flex: "none",
                            background: CODE_COLOR[c], boxShadow: "0 0 0 1px var(--ring)" }} />
                          {codeLabel(c, cfg.zoneVocab)}
                        </div>
                      ))}
                      <div>Total</div><div />
                    </div>
                    {period.map((b) => {
                      const span = Math.max(1, b.span || 1);
                      const from = acc + 1; acc += span;
                      const to = Math.min(acc, wkCount);
                      const sum = mixTotal(b.mix);
                      const ok = sum === 100;
                      return (
                        <div className="pd-row" key={b.id} style={{ gridTemplateColumns: grid, minWidth: minW }}>
                          <div>
                            <input className="pd-in pd-num pd-cell" type="number" min="1" max={wkCount}
                              value={span} aria-label="Weeks in this block"
                              onChange={(e) => edit(b.id, { span: Math.max(1, Math.min(wkCount, +e.target.value || 1)) })} />
                          </div>
                          <div style={{ fontSize: 12, color: "var(--muted)" }}>
                            {from > wkCount ? "—" : from === to ? `Wk ${from}` : `Wk ${from}–${to}`}
                          </div>
                          <div>
                            <input className="pd-in pd-cell" value={b.name || ""} aria-label="Phase name"
                              onChange={(e) => edit(b.id, { name: e.target.value })} />
                          </div>
                          {CODES.map((c) => (
                            <div key={c}>
                              <input className="pd-in pd-num pd-cell" type="number" min="0" max="100" step="1"
                                aria-label={`${c} percent`} value={b.mix[c] == null ? "" : b.mix[c]}
                                onChange={(e) => edit(b.id, { mix: { ...b.mix,
                                  [c]: Math.max(0, Math.min(100, Math.round(+e.target.value || 0))) } })} />
                            </div>
                          ))}
                          <div>
                            {ok ? (
                              <span className="pd-num" style={{ color: "var(--aqua)", fontSize: 12 }}>100</span>
                            ) : (
                              <button className="pd-mini" title="Even it out to 100"
                                style={{ color: "var(--clock)", borderColor: "var(--clock)" }}
                                onClick={() => edit(b.id, { mix: balanceMix(b.mix) })}>
                                {sum} →100
                              </button>
                            )}
                          </div>
                          <div style={{ textAlign: "right" }}>
                            <button className="pd-x" disabled={period.length < 2}
                              style={{ opacity: period.length < 2 ? .3 : 1 }}
                              onClick={() => write(period.filter((x) => x.id !== b.id))}>×</button>
                          </div>
                        </div>
                      );
                    })}
                  </div>

                  <div style={{ display: "flex", gap: 8, padding: "10px 14px", alignItems: "center", flexWrap: "wrap" }}>
                    <button className="pd-btn" onClick={() => write([...period,
                      { id: uid(), name: "Block", span: 2, mix: { ...period[period.length - 1].mix } }])}>
                      Add block
                    </button>
                    <button className="pd-btn" data-ghost="1"
                      onClick={() => write(seedPeriod(wkCount, archetypeFor(g.name)))}>
                      Start over from the {archetypeFor(g.name)} shape
                    </button>
                    {period.some((b) => mixTotal(b.mix) !== 100) && (
                      <span style={{ fontSize: 12, color: "var(--clock)", marginLeft: "auto" }}>
                        Rows that don’t add to 100 are ignored while you write.
                      </span>
                    )}
                  </div>
                </div>
              );
            })}
          </div>
        );
      
}

function PlanTests({ active, patch, tests, weeks, log }) {
  const plans = active.testPlans || [];
  const st = primaryStructure(active);
  const groups = st ? st.groups : [];
  const setPlans = (next) => patch({ ...active, testPlans: next });
  const editPlan = (id, o) => setPlans(plans.map((p) => (p.id === id ? { ...p, ...o } : p)));

  // A checkpoint counts as done once someone has a time against it.
  const doneOn = useMemo(() => {
    const m = {};
    log.forEach((p) => Object.entries(p.results || {}).forEach(([tid, byId]) => {
      const has = Object.keys(byId || {}).length;
      const wk = weeks.findIndex((w) => w.start === mondayOf(p.date));
      if (wk < 0) return;
      const key = `${tid}|${wk + 1}`;
      if (!m[key] || has) m[key] = { date: p.date, has: !!has };
    }));
    return m;
  }, [log, weeks]);

  if (!tests.length) return (
    <div className="pd-card pd-empty">
      No tests to plan yet. Build them under Settings → Sets → Test library, then schedule them here.
    </div>
  );

  return (
    <div style={{ display: "grid", gap: 14 }}>
      <div style={{ fontSize: 13, color: "var(--muted)", lineHeight: 1.65, maxWidth: 720 }}>
        A testing plan says which tests a group works through and when. Generating fills in the weeks
        below, and every row stays editable, so a week that breaks the pattern is just a row you
        change. A progressive set starts over at the beginning of each plan.
      </div>

      {!plans.length && (
        <div className="pd-card pd-empty">Nothing planned yet.</div>
      )}

      {plans.map((plan) => (
        <div className="pd-card" key={plan.id} style={{ padding: 14 }}>
          <div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
            <input className="pd-in" style={{ width: 230, fontWeight: 500 }} value={plan.name}
              onChange={(e) => editPlan(plan.id, { name: e.target.value })} />
            <div style={{ display: "flex", gap: 5, flexWrap: "wrap" }}>
              <button className="pd-mini" data-on={!plan.groupIds.length ? "1" : "0"}
                style={!plan.groupIds.length ? { borderColor: "var(--aqua)", color: "var(--ink)" } : undefined}
                onClick={() => editPlan(plan.id, { groupIds: [] })}>Everyone</button>
              {groups.map((g) => {
                const on = plan.groupIds.indexOf(g.id) >= 0;
                return (
                  <button className="pd-mini" key={g.id}
                    style={on ? { borderColor: g.color, color: "var(--ink)" } : undefined}
                    onClick={() => editPlan(plan.id, { groupIds: on
                      ? plan.groupIds.filter((x) => x !== g.id)
                      : [...plan.groupIds, g.id] })}>
                    <i style={{ display: "inline-block", width: 7, height: 7, borderRadius: 2,
                      background: g.color, marginRight: 6 }} />{g.name}
                  </button>
                );
              })}
            </div>
            <button className="pd-x" style={{ marginLeft: "auto" }}
              onClick={() => setPlans(plans.filter((x) => x.id !== plan.id))}>×</button>
          </div>

          {(plan.entries || []).map((entry) => {
            const test = tests.filter((t) => t.id === entry.testId)[0];
            const editEntry = (o) => editPlan(plan.id, {
              entries: plan.entries.map((x) => (x.id === entry.id ? { ...x, ...o } : x)) });
            const cps = entry.checkpoints || [];
            return (
              <div key={entry.id} style={{ marginTop: 12, paddingTop: 12, borderTop: "1px solid var(--soft)" }}>
                <div style={{ display: "flex", gap: 8, alignItems: "flex-end", flexWrap: "wrap" }}>
                  <div style={{ flex: "1 1 190px" }}>
                    <Field label="Test">
                      <select className="pd-in" value={entry.testId}
                        onChange={(e) => editEntry({ testId: e.target.value })}>
                        {tests.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
                      </select>
                    </Field>
                  </div>
                  <div style={{ flex: "0 1 86px" }}>
                    <Field label="From week">
                      <input className="pd-in pd-num" type="number" min="1" max={weeks.length || 60}
                        value={entry.start}
                        onChange={(e) => editEntry({ start: Math.max(1, +e.target.value || 1) })} />
                    </Field>
                  </div>
                  <div style={{ flex: "0 1 92px" }}>
                    <Field label="Every">
                      <select className="pd-in" value={entry.every}
                        onChange={(e) => editEntry({ every: +e.target.value })}>
                        {[1, 2, 3, 4].map((n) => (
                          <option key={n} value={n}>{n === 1 ? "week" : `${n} weeks`}</option>
                        ))}
                      </select>
                    </Field>
                  </div>
                  <div style={{ flex: "0 1 80px" }}>
                    <Field label="Times">
                      <input className="pd-in pd-num" type="number" min="1" max="40" value={entry.count}
                        onChange={(e) => editEntry({ count: Math.max(1, +e.target.value || 1) })} />
                    </Field>
                  </div>
                  <div style={{ flex: "1 1 150px" }}>
                    <Field label="Or name the weeks">
                      <input className="pd-in pd-num" placeholder="2, 9, 15"
                        defaultValue={(entry.weeks || []).join(", ")}
                        onBlur={(e) => {
                          const list = e.target.value.split(/[,\s]+/).map((n) => parseInt(n, 10))
                            .filter((n) => n > 0);
                          editEntry({ weeks: list.length ? list : null });
                        }} />
                    </Field>
                  </div>
                  <button className="pd-btn" data-key="1" style={{ marginBottom: 12 }}
                    onClick={() => editEntry({ checkpoints: buildCheckpoints(test, entry) })}>
                    {cps.length ? "Rebuild" : "Generate"}
                  </button>
                  <button className="pd-x" style={{ marginBottom: 12 }}
                    onClick={() => editPlan(plan.id, {
                      entries: plan.entries.filter((x) => x.id !== entry.id) })}>×</button>
                </div>

                {!!cps.length && (
                  <div style={{ marginTop: 4 }}>
                    <div className="pd-row pd-head" style={{ gridTemplateColumns: CP_COLS }}>
                      <div>Week</div><div>Set</div><div>Done</div><div />
                    </div>
                    {cps.map((cp) => {
                      const hit = doneOn[`${entry.testId}|${cp.week}`];
                      return (
                        <div className="pd-row" key={cp.id} style={{ gridTemplateColumns: CP_COLS }}>
                          <div>
                            <input className="pd-in pd-num pd-cell" type="number" min="1"
                              max={weeks.length || 60} value={cp.week}
                              onChange={(e) => editEntry({ checkpoints: cps.map((x) =>
                                x.id === cp.id ? { ...x, week: Math.max(1, +e.target.value || 1) } : x) })} />
                          </div>
                          <div>
                            <input className="pd-in pd-cell pd-num" value={cp.text}
                              onChange={(e) => editEntry({ checkpoints: cps.map((x) =>
                                x.id === cp.id ? { ...x, text: e.target.value } : x) })} />
                          </div>
                          <div style={{ fontSize: 11.5,
                            color: hit && hit.has ? "var(--aqua)" : "var(--faint)" }}>
                            {hit ? (hit.has ? pretty(hit.date) : "written") : "—"}
                          </div>
                          <div style={{ textAlign: "right" }}>
                            <button className="pd-x" onClick={() => editEntry({
                              checkpoints: cps.filter((x) => x.id !== cp.id) })}>×</button>
                          </div>
                        </div>
                      );
                    })}
                    <div style={{ padding: "8px 0 0" }}>
                      <button className="pd-mini" onClick={() => {
                        const last = cps[cps.length - 1];
                        const next = test && test.progressive
                          ? stepText(last.text, test.step) : last.text;
                        editEntry({ checkpoints: [...cps,
                          { id: uid(), week: last.week + Math.max(1, +entry.every || 1), text: next }] });
                      }}>Add next checkpoint</button>
                    </div>
                  </div>
                )}
              </div>
            );
          })}

          <div style={{ marginTop: 12 }}>
            <button className="pd-btn" onClick={() => editPlan(plan.id, {
              entries: [...(plan.entries || []), newPlanEntry(tests[0].id)] })}>Add a test</button>
          </div>
        </div>
      ))}

      <div>
        <button className="pd-btn" data-key="1"
          onClick={() => setPlans([...plans, newTestPlan(`Testing plan ${plans.length + 1}`)])}>
          New testing plan
        </button>
      </div>
    </div>
  );
}

function PlanGroups({ active, patch, sBase, setSBase }) {
  return (

        <div style={{ display: "grid", gap: 12 }}>
          <div style={{ fontSize: 13, color: "var(--muted)", lineHeight: 1.65, maxWidth: 700 }}>
            A structure is one way of splitting the team. Keep as many as you use: a level split for
            most days, an event split for others. Pick one when you start a practice. The
            structure marked primary is what the roster and the weekly plan are organized by. The
            small figure beside each name is that group’s base pace per 100, which is what the
            practice writer works its intervals out from.
          </div>
          {(active.structures || []).map((st) => (
            <div className="pd-card" key={st.id} style={{ padding: 14 }}>
              <div style={{ display: "flex", gap: 10, alignItems: "center", marginBottom: 10, flexWrap: "wrap" }}>
                <input className="pd-in" style={{ width: 220, fontWeight: 500 }} value={st.name}
                  onChange={(e) => patch({ ...active, structures: active.structures.map((x) =>
                    x.id === st.id ? { ...x, name: e.target.value } : x) })} />
                <label style={{ display: "flex", gap: 6, alignItems: "center", fontSize: 12.5, color: "var(--muted)" }}>
                  <input type="checkbox" checked={active.primary === st.id}
                    onChange={() => patch({ ...active, primary: st.id })} />
                  Primary
                </label>
                {active.structures.length > 1 && (
                  <button className="pd-btn" data-ghost="1" style={{ marginLeft: "auto" }}
                    onClick={() => {
                      const rest = active.structures.filter((x) => x.id !== st.id);
                      patch({ ...active, structures: rest,
                        primary: active.primary === st.id ? rest[0].id : active.primary });
                    }}>Delete</button>
                )}
              </div>
              <div className="pd-groupbar" style={{ padding: 0 }}>
                {st.groups.map((g) => (
                  <div className="pd-gtag" key={g.id}>
                    <i style={{ background: g.color }} />
                    <input value={g.name} aria-label="Group name"
                      onChange={(e) => patch({ ...active, structures: active.structures.map((x) => x.id !== st.id ? x
                        : { ...x, groups: x.groups.map((y) => y.id === g.id ? { ...y, name: e.target.value } : y) }) })} />
                    <input className="pd-base" aria-label="Base pace per 100" title="Base pace per 100"
                      value={sBase[g.id] !== undefined ? sBase[g.id] : clockText(g.base || DEFAULT_BASE)}
                      onChange={(e) => setSBase({ ...sBase, [g.id]: e.target.value })}
                      onKeyDown={(e) => { if (e.key === "Enter") e.target.blur(); }}
                      onBlur={() => {
                        const sec = toSec(sBase[g.id]);
                        if (sec >= 30 && sec <= 400)
                          patch({ ...active, structures: active.structures.map((x) => x.id !== st.id ? x
                            : { ...x, groups: x.groups.map((y) => y.id === g.id ? { ...y, base: sec } : y) }) });
                        setSBase((d) => { const n = { ...d }; delete n[g.id]; return n; });
                      }} />
                    {st.groups.length > 1 && (
                      <button className="pd-x" onClick={() => patch({ ...active, structures: active.structures.map((x) =>
                        x.id !== st.id ? x : { ...x, groups: x.groups.filter((y) => y.id !== g.id) }) })}>×</button>
                    )}
                  </div>
                ))}
                <button className="pd-btn" onClick={() => patch({ ...active, structures: active.structures.map((x) =>
                  x.id !== st.id ? x : { ...x, groups: [...x.groups,
                    newGroup(`Group ${x.groups.length + 1}`, x.groups.length,
                      (x.groups[x.groups.length - 1] || {}).base + 20 || DEFAULT_BASE)] }) })}>Add group</button>
              </div>
            </div>
          ))}
          <div style={{ display: "flex", gap: 8 }}>
            <button className="pd-btn" data-key="1" onClick={() => patch({ ...active,
              structures: [...active.structures, makeStructure(`Structure ${active.structures.length + 1}`, ["Group 1", "Group 2"])] })}>
              Add structure
            </button>
            <button className="pd-btn" onClick={() => patch({ ...active,
              structures: [...active.structures, makeStructure("Event split", ["Distance", "IM / Mid", "Sprint"])] })}>
              Add event split
            </button>
          </div>
        </div>
      
  );
}

function PlanRoster({ active, patch, pGroups, bulk, setBulk, onPaces }) {
  return (

        <div style={{ display: "grid", gap: 12 }}>
          <div className="pd-card" style={{ overflowX: "auto" }}>
            <div className="pd-row pd-head" style={{ gridTemplateColumns: ROSTER_COLS, minWidth: 880 }}>
              <div>Swimmer</div><div>Primary group</div><div>T pace</div><div>Test</div>
              <div>Taken</div><div>Best 50 free</div><div />
            </div>
            {!(active.roster || []).length && (
              <div className="pd-empty">No swimmers yet. Add them one at a time, or paste a list below.</div>
            )}
            {(active.roster || []).map((sw) => {
              const setSw = (patchSw) => patch({ ...active, roster: active.roster.map((x) =>
                x.id === sw.id ? { ...x, ...patchSw } : x) });
              return (
                <div className="pd-row" key={sw.id} style={{ gridTemplateColumns: ROSTER_COLS, minWidth: 880 }}>
                  <div>
                    <input className="pd-in" value={sw.name}
                      onChange={(e) => setSw({ name: e.target.value })} />
                  </div>
                  <div>
                    <select className="pd-in" value={sw.groupId || ""}
                      onChange={(e) => setSw({ groupId: e.target.value })}>
                      <option value="">Unassigned</option>
                      {pGroups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
                    </select>
                  </div>
                  <div>
                    <input className="pd-in pd-num" placeholder="1:12.5" aria-label="T pace"
                      defaultValue={sw.tPace ? secToPace(sw.tPace, true) : ""}
                      onBlur={(e) => setSw({ tPace: paceToSec(e.target.value) })} />
                  </div>
                  <div>
                    <select className="pd-in" value={sw.tTest || "T-30"}
                      onChange={(e) => setSw({ tTest: e.target.value })}>
                      {T_TESTS.map((t) => <option key={t} value={t}>{t}</option>)}
                    </select>
                  </div>
                  <div>
                    <input className="pd-in" type="date" aria-label="Date tested"
                      value={sw.tDate || ""} onChange={(e) => setSw({ tDate: e.target.value })} />
                  </div>
                  <div>
                    <input className="pd-in pd-num" placeholder="24.80" aria-label="Best 50 free"
                      defaultValue={sw.free50 ? secToPace(sw.free50, true) : ""}
                      onBlur={(e) => setSw({ free50: paceToSec(e.target.value) })} />
                  </div>
                  <div style={{ textAlign: "right" }}>
                    <button className="pd-x" title={`Remove ${sw.name}`}
                      onClick={() => patch({ ...active, roster: active.roster.filter((x) => x.id !== sw.id) })}>×</button>
                  </div>
                </div>
              );
            })}
            <div style={{ padding: 12, display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
              <button className="pd-btn" onClick={() => patch({ ...active,
                roster: [...(active.roster || []), { id: uid(), name: "", groupId: pGroups[0] ? pGroups[0].id : "" }] })}>
                Add swimmer
              </button>
              <button className="pd-btn" data-key="1" onClick={() => onPaces(active)}
                disabled={!(active.roster || []).some((s) => s.tPace)}>
                Print pace charts
              </button>
              <span style={{ fontSize: 12, color: "var(--muted)" }}>
                A T-pace is their average per 100, written 1:12.5.
              </span>
            </div>
          </div>

          <div className="pd-card" style={{ padding: 16 }}>
            <div className="pd-eyebrow" style={{ marginBottom: 6 }}>Paste a list</div>
            <textarea className="pd-in" rows={4} value={bulk} placeholder={"One name per line"}
              style={{ resize: "vertical" }} onChange={(e) => setBulk(e.target.value)} />
            <div style={{ display: "flex", gap: 8, marginTop: 10, alignItems: "center" }}>
              <button className="pd-btn" data-key="1" disabled={!bulk.trim()} onClick={() => {
                const names = bulk.split("\n").map((n) => n.trim()).filter(Boolean);
                patch({ ...active, roster: [...(active.roster || []),
                  ...names.map((n) => ({ id: uid(), name: n, groupId: pGroups[0] ? pGroups[0].id : "" }))] });
                setBulk("");
              }}>Add {bulk.split("\n").filter((n) => n.trim()).length || ""} swimmers</button>
              <span style={{ fontSize: 12, color: "var(--muted)" }}>
                They all land in {pGroups[0] ? pGroups[0].name : "the first group"}. Reassign from the table.
              </span>
            </div>
          </div>
        </div>
  );
}

function PlanAttendance({ active, patch, log, weeks, onAttendance }) {

        const rule = attRule(active);
        const setRule = (patchR) => patch({ ...active, attendance: { ...rule, ...patchR } });
        const att = seasonAttendance(active, log);
        return (
        <div style={{ display: "grid", gap: 12 }}>
          <div className="pd-card" style={{ padding: 16 }}>
            <div className="pd-eyebrow" style={{ marginBottom: 12 }}>Attendance policy</div>
            <div style={{ display: "flex", gap: 14, flexWrap: "wrap", alignItems: "flex-end" }}>
              <div style={{ flex: "0 1 130px" }}>
                <Field label="Practices a week">
                  <input className="pd-in pd-num" type="number" min="0" max="21" value={rule.perWeek}
                    onChange={(e) => setRule({ perWeek: Math.max(0, +e.target.value || 0) })} />
                </Field>
              </div>
              <div style={{ flex: "0 1 110px" }}>
                <Field label="Over weeks">
                  <input className="pd-in pd-num" type="number" min="1" max="60" value={rule.weeks}
                    onChange={(e) => setRule({ weeks: Math.max(1, +e.target.value || 1) })} />
                </Field>
              </div>
              <div style={{ flex: "0 1 110px" }}>
                <Field label="Expect">
                  <input className="pd-in pd-num" type="number" min="0" max="150" value={rule.goal}
                    onChange={(e) => setRule({ goal: Math.max(0, +e.target.value || 0) })} />
                </Field>
              </div>
              <div style={{ flex: "1 1 240px" }}>
                <Field label="An excused absence">
                  <select className="pd-in" value={rule.excused}
                    onChange={(e) => setRule({ excused: e.target.value })}>
                    {EXCUSED_RULES.map(([k, l]) => <option key={k} value={k}>{l}</option>)}
                  </select>
                </Field>
              </div>
            </div>
            <label style={{ display: "flex", gap: 10, alignItems: "flex-start", marginTop: 2 }}>
              <input type="checkbox" checked={!!rule.extraCredit} style={{ marginTop: 3 }}
                onChange={(e) => setRule({ extraCredit: e.target.checked })} />
              <span style={{ fontSize: 13, lineHeight: 1.55 }}>
                Optional practices count as extra credit
                <span style={{ display: "block", color: "var(--muted)", fontSize: 12 }}>
                  Each optional practice attended is added to the number attended without adding to
                  the number expected, so a swimmer can finish above 100 percent.
                </span>
              </span>
            </label>
            <div style={{ fontSize: 12, color: "var(--muted)", marginTop: 12, lineHeight: 1.6 }}>
              {att.target} mandatory practices expected at {rule.goal} percent, so{" "}
              {Math.ceil((att.target * rule.goal) / 100)} of them. {att.mandatoryHeld} written so far.
            </div>
          </div>

          {!att.rows.length ? (
            <div className="pd-card pd-empty">Add swimmers to the roster and their attendance appears here.</div>
          ) : (
            <div className="pd-card" style={{ overflowX: "auto" }}>
              <div className="pd-row pd-head" style={{ gridTemplateColumns: ATT_COLS, minWidth: 760 }}>
                <div>Swimmer</div><div>Attended</div><div>Mandatory</div><div>Optional</div><div>Standing</div>
              </div>
              {att.rows.map((r) => {
                const tight = r.slack != null && r.slack <= 0;
                return (
                  <div className="pd-row" key={r.sw.id}
                    style={{ gridTemplateColumns: ATT_COLS, minWidth: 760 }}>
                    <div style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                      {r.sw.name}
                      {r.gname && <span style={{ color: "var(--faint)", fontSize: 11 }}> · {r.gname}</span>}
                    </div>
                    <div className="pd-num" style={{ fontWeight: 600,
                      color: r.pct == null ? "var(--muted)"
                        : r.pct >= rule.goal ? "var(--aqua)" : "var(--clock)" }}>
                      {r.pct == null ? "—" : `${Math.round(r.pct)}%`}
                    </div>
                    <div className="pd-num" style={{ fontSize: 12 }}>
                      {r.mP} of {r.mandatory}
                      {(r.mE || r.mU) ? (
                        <span style={{ color: "var(--faint)" }}>
                          {r.mE ? `  ${r.mE}E` : ""}{r.mU ? `  ${r.mU}U` : ""}
                        </span>
                      ) : null}
                    </div>
                    <div className="pd-num" style={{ fontSize: 12, color: "var(--muted)" }}>
                      {r.optional ? `${r.oP} of ${r.optional}` : "—"}
                    </div>
                    <div style={{ fontSize: 12, color: tight ? "var(--clock)" : "var(--muted)" }}>
                      {r.slack == null ? "—"
                        : r.slack < 0
                          ? (rule.extraCredit
                              ? `${-r.slack} extra practices needed`
                              : `Cannot reach the goal, short ${-r.slack}`)
                          : r.slack === 0 ? "Cannot miss another"
                          : `${r.slack} to spare`}
                      {r.blank ? <span style={{ color: "var(--ghost)" }}> · {r.blank} unmarked</span> : null}
                    </div>
                  </div>
                );
              })}
            </div>
          )}

          <div className="pd-card" style={{ padding: 16 }}>
            <div className="pd-eyebrow" style={{ marginBottom: 8 }}>Training days</div>
            <div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
              {DAY_NAMES.map((d, i) => {
                const on = (active.trainingDays || []).indexOf(i) >= 0;
                return (
                  <button key={d} className="pd-btn" data-key={on ? "1" : undefined}
                    onClick={() => {
                      const cur = active.trainingDays || [];
                      patch({ ...active, trainingDays: on ? cur.filter((x) => x !== i) : [...cur, i].sort() });
                    }}>{d}</button>
                );
              })}
            </div>
            <div style={{ color: "var(--muted)", fontSize: 12.5, marginTop: 12, lineHeight: 1.6 }}>
              {seasonDates(active).length} practice days across {weeks.length} weeks
              {" · "}{(active.roster || []).length} swimmers on the roster.
              The sheet prints swimmers as rows and dates as columns, grouped, with room to mark by hand.
            </div>
            <div style={{ marginTop: 14 }}>
              <button className="pd-btn" data-key="1" disabled={!(active.roster || []).length}
                onClick={() => onAttendance(active)}>Open attendance sheet</button>
              {!(active.roster || []).length &&
                <span style={{ marginLeft: 10, fontSize: 12, color: "var(--muted)" }}>Add a roster first.</span>}
            </div>
          </div>
        </div>
      );
}

function SeasonPage({ page, setPage, seasons, setSeasons, cfg, setCfg, log, setLog, tests,
  onAttendance, onReport, onPaces }) {
  const view = page || "plan";
  const setView = setPage;
  const [bulk, setBulk] = useState("");
  const [lens, setLens] = useState("__all__");
  const [effortSt, setEffortSt] = useState("");
  const [files, setFiles] = useState(false);
  const [sBase, setSBase] = useState({});
  const active = seasons.filter((s) => s.id === cfg.activeSeason)[0] || seasons[0] || null;
  const patch = (next) => setSeasons(seasons.map((s) => (s.id === next.id ? next : s)));
  const actuals = useMemo(() => weekActuals(log), [log]);

  const newSeason = () => {
    const s = makeSeason(`Season ${seasons.length + 1}`, today(), 16);
    setSeasons([...seasons, s]);
    setCfg({ ...cfg, activeSeason: s.id });
  };

  if (!seasons.length) return (
    <div className="pd-card pd-empty">
      No seasons yet. A season holds the weekly plan, the group structures you write practices
      against, and the roster.
      <div style={{ marginTop: 14, display: "flex", gap: 8, justifyContent: "center" }}>
        <button className="pd-btn" data-key="1" onClick={newSeason}>Create a season</button>
        <button className="pd-btn" onClick={() => setFiles(true)}>Open a season file</button>
      </div>
      {files && <SeasonFiles season={makeSeason("Empty", today(), 0)} seasons={seasons}
        setSeasons={setSeasons} log={log} setLog={setLog} cfg={cfg} setCfg={setCfg}
        onClose={() => setFiles(false)} />}
    </div>
  );

  const struct = primaryStructure(active);
  const pGroups = struct ? struct.groups : [];
  const weeks = active.weeks || [];
  const setWeeks = (n) => {
    const start = mondayOf(active.startDate || today());
    const keep = weeks.slice(0, n);
    const grown = Array.from({ length: n }, (_, i) => keep[i] ||
      ({ id: uid(), start: addDays(start, i * 7), targets: {}, focus: "" }));
    patch({ ...active, weeks: grown.map((w, i) => ({ ...w, start: addDays(start, i * 7) })) });
  };

  return (
    <div>
      <div className="pd-sub">
        {PLAN_PAGES.map(([k, l]) => (
          <button key={k} className="pd-subtab" data-on={view === k ? "1" : "0"}
            onClick={() => setView(k)}>{l}</button>
        ))}
      </div>

    <div style={{ display: "grid", gap: 14 }}>
      <div className="pd-card pd-groupbar" style={{ padding: "10px 12px" }}>
        <select className="pd-in" style={{ width: 210 }} value={active.id}
          onChange={(e) => setCfg({ ...cfg, activeSeason: e.target.value })}>
          {seasons.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
        </select>
        <button className="pd-btn" onClick={newSeason}>New season</button>
        {seasons.length > 1 && (
          <button className="pd-btn" data-ghost="1" onClick={() => {
            const rest = seasons.filter((s) => s.id !== active.id);
            setSeasons(rest); setCfg({ ...cfg, activeSeason: rest[0].id });
          }}>Delete</button>
        )}
        <button className="pd-btn" onClick={() => onReport(active)}>Season report</button>
        <button className="pd-btn" onClick={() => setFiles(true)}>Season file</button>
      </div>

      <div className="pd-card" style={{ padding: 16, display: "flex", gap: 16, alignItems: "flex-end", flexWrap: "wrap" }}>
        <div style={{ flex: "2 1 200px" }}>
          <Field label="Season name">
            <input className="pd-in" value={active.name}
              onChange={(e) => patch({ ...active, name: e.target.value })} />
          </Field>
        </div>
        <div style={{ flex: "1 1 150px" }}>
          <Field label="Starts (week of)">
            <input className="pd-in" type="date" value={active.startDate}
              onChange={(e) => {
                const start = mondayOf(e.target.value);
                patch({ ...active, startDate: start,
                  weeks: weeks.map((w, i) => ({ ...w, start: addDays(start, i * 7) })) });
              }} />
          </Field>
        </div>
        <div style={{ flex: "0 1 100px" }}>
          <Field label="Course">
            <select className="pd-in" value={toCourse(active.course)}
              onChange={(e) => patch({ ...active, course: e.target.value })}>
              {COURSES.map(([k, l]) => <option key={k} value={k}>{l}</option>)}
            </select>
          </Field>
        </div>
        <div style={{ flex: "1 1 380px" }}>
          <div className="pd-eyebrow" style={{ marginBottom: 5 }}>New practices start at</div>
          <PrimaryPicker value={primaryOf(active)} vocab={cfg.zoneVocab}
            onChange={(v) => patch({ ...active, primary: v })} />
        </div>
        <div style={{ flex: "0 1 110px" }}>
          <Field label="Weeks">
            <input className="pd-in" type="number" min="1" max="60" value={weeks.length}
              onChange={(e) => setWeeks(Math.max(1, Math.min(60, +e.target.value || 1)))} />
          </Field>
        </div>
      </div>

      {view === "plan" && <PlanWeeks active={active} patch={patch} cfg={cfg} pGroups={pGroups} weeks={weeks}
        actuals={actuals} lens={lens} setLens={setLens} />}
      {view === "meets" && <PlanMeets active={active} patch={patch} weeks={weeks} />}
      {view === "effort" && <PlanEffort active={active} patch={patch} cfg={cfg} weeks={weeks}
        effortSt={effortSt} setEffortSt={setEffortSt} />}
      {view === "tests" && <PlanTests active={active} patch={patch} tests={tests}
        weeks={weeks} log={practicesInSeason(log, active)} />}
      {view === "groups" && <PlanGroups active={active} patch={patch} sBase={sBase} setSBase={setSBase} />}
      {view === "roster" && <PlanRoster active={active} patch={patch} pGroups={pGroups} bulk={bulk} setBulk={setBulk}
        onPaces={onPaces} />}
      {files && <SeasonFiles season={active} seasons={seasons} setSeasons={setSeasons}
        log={log} setLog={setLog} cfg={cfg} setCfg={setCfg} onClose={() => setFiles(false)} />}

      {view === "sheet" && <PlanAttendance active={active} patch={patch} log={log} weeks={weeks} onAttendance={onAttendance} />}
    </div>
    </div>
  );
}


/* ---------------------------------------------------------- season files */

function SeasonFiles({ season, seasons, setSeasons, log, setLog, cfg, setCfg, onClose }) {
  const [mode, setMode] = useState("export");
  const [incoming, setIncoming] = useState(null);
  const [err, setErr] = useState("");
  const [copied, setCopied] = useState("");
  const file = useRef(null);

  const bundle = useMemo(() => buildBundle(season, log, cfg), [season, log, cfg]);
  const text = useMemo(() => JSON.stringify(bundle, null, 2), [bundle]);

  const take = (f) => {
    setErr(""); setIncoming(null);
    if (!f) return;
    const r = new FileReader();
    r.onload = () => {
      try { setIncoming(readBundle(String(r.result))); }
      catch (e) { setErr(e.message || "Could not read that file."); }
    };
    r.onerror = () => setErr("Could not read that file.");
    r.readAsText(f);
  };

  const commit = () => {
    const out = mergeBundle(incoming, seasons, log);
    setSeasons(out.seasons); setLog(out.log);
    setCfg({ ...cfg, activeSeason: out.seasons[out.seasons.length - 1].id });
    onClose();
  };

  return (
    <div className="pd-modal" onClick={(e) => e.target === e.currentTarget && onClose()}>
      <div className="pd-sheet" style={{ padding: 20, maxWidth: 660 }}>
        <div className="pd-big" style={{ fontSize: 26, marginBottom: 12 }}>Season files</div>
        <div className="pd-seg2" style={{ marginBottom: 16 }}>
          {[["export", "Save a copy"], ["import", "Open a file"]].map(([k, l]) => (
            <button key={k} data-on={mode === k ? "1" : "0"} onClick={() => setMode(k)}>{l}</button>
          ))}
        </div>

        {mode === "export" && (
          <>
            <div style={{ color: "var(--muted)", fontSize: 13, lineHeight: 1.65, marginBottom: 14 }}>
              Everything in <b style={{ color: "var(--ink)", fontWeight: 500 }}>{season.name}</b>:
              the weekly plan, every group structure, the roster, and {bundle.practices.length}{" "}
              {bundle.practices.length === 1 ? "practice" : "practices"}, in one plain text file.
              Keep it somewhere safe, or hand it to another coach.
            </div>
            <div style={{ display: "flex", gap: 8, marginBottom: 14, flexWrap: "wrap" }}>
              <button className="pd-btn" data-key="1"
                onClick={() => setCopied(downloadJSON(`${slug(season.name)}.json`, bundle)
                  ? "Saved to your downloads." : "Download was blocked. Copy the text below instead.")}>
                Download .json
              </button>
              <button className="pd-btn" onClick={() => {
                try {
                  navigator.clipboard.writeText(text);
                  setCopied("Copied. Paste it into a text file and save it as .json");
                } catch (e) { setCopied("Copy was blocked. Select the text below by hand."); }
              }}>Copy the text</button>
              {copied && <span style={{ fontSize: 12, color: "var(--aqua)", alignSelf: "center" }}>{copied}</span>}
            </div>
            <div className="pd-eyebrow" style={{ marginBottom: 5 }}>The file</div>
            <textarea className="pd-in" readOnly rows={9} value={text}
              onFocus={(e) => e.target.select()}
              style={{ fontFamily: "var(--mono)", fontSize: 11, lineHeight: 1.5, resize: "vertical" }} />
          </>
        )}

        {mode === "import" && (
          <>
            <div style={{ color: "var(--muted)", fontSize: 13, lineHeight: 1.65, marginBottom: 14 }}>
              Open a season file saved from Practice Desk. It is added alongside what you already
              have. Nothing is overwritten or replaced.
            </div>
            <input ref={file} type="file" accept="application/json,.json" style={{ display: "none" }}
              onChange={(e) => take(e.target.files && e.target.files[0])} />
            <button className="pd-btn" onClick={() => file.current && file.current.click()}>
              Choose a file…
            </button>
            {err && <div style={{ color: "var(--clock)", fontSize: 12.5, marginTop: 12 }}>{err}</div>}
            {incoming && (
              <div className="pd-card" style={{ padding: 14, marginTop: 14, background: "var(--field)" }}>
                <div style={{ fontWeight: 500, fontSize: 15 }}>{incoming.season.name}</div>
                <div style={{ color: "var(--muted)", fontSize: 12.5, marginTop: 6, lineHeight: 1.7 }}>
                  {(incoming.season.weeks || []).length} weeks ·{" "}
                  {(incoming.season.structures || []).length} group{(incoming.season.structures || []).length === 1 ? "" : "s"} of columns ·{" "}
                  {(incoming.season.roster || []).length} swimmers ·{" "}
                  {(incoming.practices || []).length} practices
                  {incoming.exportedAt && <><br />Saved {new Date(incoming.exportedAt).toLocaleDateString()}</>}
                </div>
                <button className="pd-btn" data-key="1" style={{ marginTop: 12 }} onClick={commit}>
                  Add this season
                </button>
              </div>
            )}
          </>
        )}

        <div style={{ display: "flex", justifyContent: "flex-end", marginTop: 16 }}>
          <button className="pd-btn" data-ghost="1" onClick={onClose}>Close</button>
        </div>
      </div>
    </div>
  );
}

/* ---------------------------------------------------------- sets */

const DEFAULT_CATS = ["Warm up", "Main set", "Skill set", "Cool down", "Break"];

function SetsPage({ lib, setLib, cfg, setCfg, tests, setTests }) {
  const cats = useMemo(() => {
    const saved = cfg.setCategories && cfg.setCategories.length ? cfg.setCategories : DEFAULT_CATS;
    const used = [...new Set(lib.map((s) => s.group || "Other"))];
    return [...saved, ...used.filter((u) => saved.indexOf(u) < 0)];
  }, [cfg.setCategories, lib]);

  const [cat, setCat] = useState(cats[0]);
  const [edit, setEdit] = useState(null);
  const [newCat, setNewCat] = useState("");
  const [editT, setEditT] = useState(null);
  useEffect(() => { if (cat !== "__tests__" && cats.indexOf(cat) < 0) setCat(cats[0]); }, [cats, cat]);

  const shown = lib.filter((s) => (s.group || "Other") === cat);
  const preview = edit ? parsePractice(edit.text, cfg).totals : null;
  const saveCats = (list) => setCfg({ ...cfg, setCategories: list });

  const commit = () => {
    if (!edit.name.trim()) return;
    setLib(edit.id ? lib.map((s) => (s.id === edit.id ? edit : s)) : [...lib, { ...edit, id: uid() }]);
    setEdit(null);
  };
  const renameCat = (from, to) => {
    const t = to.trim();
    if (!t || cats.indexOf(t) >= 0) return;
    saveCats(cats.map((c) => (c === from ? t : c)));
    setLib(lib.map((s) => (s.group === from ? { ...s, group: t } : s)));
    setCat(t);
  };

  return (
    <div style={{ display: "grid", gridTemplateColumns: "230px minmax(0,1fr)", gap: 14, alignItems: "start" }}>
      <div className="pd-card" style={{ padding: 10 }}>
        <div className="pd-eyebrow" style={{ padding: "2px 6px 8px" }}>Categories</div>
        {cats.map((c) => {
          const n = lib.filter((s) => (s.group || "Other") === c).length;
          return (
            <button key={c} onClick={() => setCat(c)}
              style={{ display: "flex", width: "100%", gap: 8, alignItems: "center", padding: "7px 8px",
                background: c === cat ? "var(--sel)" : "transparent", border: 0, borderRadius: 6,
                fontSize: 13, textAlign: "left", marginBottom: 2 }}>
              <span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{c}</span>
              <span className="pd-num" style={{ color: "var(--muted)", fontSize: 11 }}>{n}</span>
            </button>
          );
        })}
        <button onClick={() => setCat("__tests__")}
          style={{ display: "flex", width: "100%", gap: 8, alignItems: "center", padding: "7px 8px",
            marginTop: 8, background: cat === "__tests__" ? "#0F4351" : "transparent", border: 0,
            borderTop: "1px solid var(--line)", borderRadius: 6, fontSize: 13, textAlign: "left" }}>
          <span style={{ flex: 1, color: cat === "__tests__" ? "var(--ink)" : "var(--clock)" }}>Test library</span>
          <span className="pd-num" style={{ color: "var(--muted)", fontSize: 11 }}>{tests.length}</span>
        </button>
        <div style={{ display: "flex", gap: 5, marginTop: 10, paddingTop: 10, borderTop: "1px solid var(--line)" }}>
          <input className="pd-in" style={{ fontSize: 12, padding: "6px 8px" }} placeholder="New category"
            value={newCat} onChange={(e) => setNewCat(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === "Enter" && newCat.trim() && cats.indexOf(newCat.trim()) < 0) {
                saveCats([...cats, newCat.trim()]); setCat(newCat.trim()); setNewCat("");
              }
            }} />
          <button className="pd-btn" disabled={!newCat.trim() || cats.indexOf(newCat.trim()) >= 0}
            onClick={() => { saveCats([...cats, newCat.trim()]); setCat(newCat.trim()); setNewCat(""); }}>Add</button>
        </div>
      </div>

      {cat === "__tests__" ? (
        <div style={{ display: "grid", gap: 12 }}>
          <div className="pd-card" style={{ padding: 14, display: "flex", gap: 10,
            alignItems: "center", flexWrap: "wrap" }}>
            <span style={{ fontFamily: "var(--disp)", fontSize: 19, letterSpacing: ".06em",
              textTransform: "uppercase", fontWeight: 600 }}>Test library</span>
            <span style={{ fontSize: 12.5, color: "var(--muted)" }}>
              {tests.length} {tests.length === 1 ? "test" : "tests"}
            </span>
            <button className="pd-btn" data-key="1" style={{ marginLeft: "auto" }}
              onClick={() => setEditT(newTest("", ""))}>New test</button>
          </div>

          {!tests.length && (
            <div className="pd-card pd-empty">
              Nothing yet. A named test is what lets the same set be compared through a season.
              Add a standard you repeat: a T-30, a 10x100 best average, a 500 for time.
            </div>
          )}

          {tests.map((t) => {
            const tot = parsePractice(t.text || "", cfg).totals;
            const lines = (t.text || "").split("\n").map((l) => l.trim()).filter(Boolean);
            const mode = (TEST_MODES.filter(([k]) => k === testMode(t))[0] || [])[1] || "";
            return (
              <div className="pd-card" key={t.id} style={{ padding: "12px 14px", borderColor: "#A8453B" }}>
                <div style={{ display: "flex", gap: 12, alignItems: "flex-start" }}>
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div style={{ fontWeight: 500, fontSize: 14.5, display: "flex",
                      alignItems: "center", gap: 8, flexWrap: "wrap" }}>
                      <span className="pd-testtag">●</span>{t.name}
                      {t.progressive && (
                        <span className="pd-chip" style={{ background: "var(--track)", color: "var(--muted)" }}>
                          Grows
                        </span>
                      )}
                    </div>
                    <div className="pd-num" style={{ fontSize: 11.5, color: "var(--muted)", marginTop: 3 }}>
                      {tot.yards ? comma(tot.yards) : "no distance"} · {mode.toLowerCase()}
                      {t.progressive ? ` · ${(STEP_KINDS.filter(([k]) => k === (t.step || {}).kind)[0] || [])[1].toLowerCase()}` : ""}
                    </div>
                  </div>
                  <div style={{ display: "flex", gap: 6 }}>
                    <button className="pd-btn" onClick={() => setEditT({ ...t })}>Edit</button>
                    <button className="pd-btn" data-ghost="1"
                      onClick={() => setTests(tests.filter((x) => x.id !== t.id))}>Delete</button>
                  </div>
                </div>
                {!!lines.length && (
                  <div style={{ marginTop: 9, paddingTop: 9, borderTop: "1px solid var(--soft)",
                    fontFamily: "var(--mono)", fontSize: 12, color: "var(--faint)", lineHeight: 1.7,
                    whiteSpace: "pre-wrap" }}>
                    {lines.join("\n")}
                  </div>
                )}
                {t.note && (
                  <div style={{ marginTop: 7, fontSize: 12, color: "var(--muted)", lineHeight: 1.5 }}>
                    {t.note}
                  </div>
                )}
              </div>
            );
          })}
        </div>
      ) : (
      <div style={{ display: "grid", gap: 12 }}>
        <div className="pd-card" style={{ padding: 14, display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
          <input className="pd-in" style={{ width: 220, fontFamily: "var(--disp)", fontSize: 19,
            letterSpacing: ".06em", textTransform: "uppercase", fontWeight: 600 }}
            value={cat} onChange={(e) => renameCat(cat, e.target.value)} aria-label="Category name" />
          <span style={{ fontSize: 12.5, color: "var(--muted)" }}>
            {shown.length} {shown.length === 1 ? "set" : "sets"}
          </span>
          <div style={{ marginLeft: "auto", display: "flex", gap: 8 }}>
            {!shown.length && cats.length > 1 && DEFAULT_CATS.indexOf(cat) < 0 && (
              <button className="pd-btn" data-ghost="1"
                onClick={() => { saveCats(cats.filter((c) => c !== cat)); setCat(cats[0]); }}>Delete category</button>
            )}
            <button className="pd-btn" data-key="1"
              onClick={() => setEdit({ id: null, name: "", group: cat, text: "" })}>New set</button>
          </div>
        </div>

        {!shown.length && (
          <div className="pd-card pd-empty">
            Nothing in {cat} yet. Sets saved here show up in the strip above the practice writer,
            one click from dropping into whatever you are writing.
          </div>
        )}

        {shown.map((s) => {
          const t = parsePractice(s.text, cfg).totals;
          const lines = s.text.split("\n").map((l) => l.trim()).filter(Boolean);
          return (
            <div className="pd-card" key={s.id} style={{ padding: "12px 14px" }}>
              <div style={{ display: "flex", gap: 12, alignItems: "flex-start" }}>
                <div style={{ flex: 1, minWidth: 0 }}>
                  <div style={{ fontWeight: 500, fontSize: 14.5 }}>{s.name}</div>
                  <div className="pd-num" style={{ fontSize: 11.5, color: "var(--muted)", marginTop: 3 }}>
                    {t.yards ? comma(t.yards) : "no distance"} · {fmt(t.seconds)}{t.estimated ? " est" : ""}
                  </div>
                </div>
                <div style={{ display: "flex", gap: 6 }}>
                  <select className="pd-in" style={{ width: 130, fontSize: 12 }} value={s.group}
                    onChange={(e) => setLib(lib.map((x) => x.id === s.id ? { ...x, group: e.target.value } : x))}>
                    {cats.map((c) => <option key={c} value={c}>{c}</option>)}
                  </select>
                  <button className="pd-btn" onClick={() => setEdit({ ...s })}>Edit</button>
                  <button className="pd-btn" data-ghost="1"
                    onClick={() => setLib(lib.filter((x) => x.id !== s.id))}>Delete</button>
                </div>
              </div>
              <div style={{ marginTop: 9, paddingTop: 9, borderTop: "1px solid var(--soft)",
                fontFamily: "var(--mono)", fontSize: 12, color: "var(--faint)", lineHeight: 1.7,
                whiteSpace: "pre-wrap" }}>
                {lines.join("\n")}
              </div>
            </div>
          );
        })}
      </div>

      )}


      {editT && (() => {
        const et = editT;
        const set = (o) => setEditT({ ...et, ...o });
        const tot = parsePractice(et.text || "", cfg).totals;
        const st = et.step || { kind: "reps", by: 1, count: 6 };
        const setStep = (o) => set({ step: { ...st, ...o } });
        let prev = (et.text || "").trim();
        const preview = et.progressive
          ? Array.from({ length: Math.min(4, Math.max(1, +st.count || 1)) }, () => {
              const now = prev; prev = stepText(prev, st); return now;
            })
          : [];
        const inferred = (TEST_MODES.filter(([k]) => k === testMode(et))[0] || [])[1] || "";

        return (
          <div className="pd-modal" onClick={(e) => e.target === e.currentTarget && setEditT(null)}>
            <div className="pd-sheet" style={{ padding: 20 }}>
              <div className="pd-big" style={{ fontSize: 26, marginBottom: 4,
                display: "flex", alignItems: "center", gap: 9 }}>
                <span className="pd-testtag">●</span>
                {tests.some((x) => x.id === et.id) ? "Edit test" : "New test"}
              </div>
              <div style={{ color: "var(--muted)", fontSize: 12.5, lineHeight: 1.6, marginBottom: 16 }}>
                A named test is what lets the same set be compared through a season. Schedule it for a
                group under Plan, or reach for it any time while writing.
              </div>

              <Field label="Name">
                <input className="pd-in" autoFocus value={et.name} placeholder="10x100 best average"
                  onChange={(e) => set({ name: e.target.value })} />
              </Field>

              <Field label="The set">
                <textarea className="pd-in" rows={4} wrap="off" spellCheck={false}
                  style={{ fontFamily: "var(--mono)", lineHeight: 1.7, resize: "vertical" }}
                  value={et.text || ""} placeholder={"6x200 @ r:30 best average"}
                  onChange={(e) => set({ text: e.target.value })} />
              </Field>

              <div style={{ display: "flex", gap: 12, alignItems: "flex-end" }}>
                <div style={{ flex: "1 1 220px" }}>
                  <Field label="Times are taken">
                    <select className="pd-in" value={et.mode || "auto"}
                      onChange={(e) => set({ mode: e.target.value })}>
                      <option value="auto">Read it from the set</option>
                      {TEST_MODES.map(([k, l]) => <option key={k} value={k}>{l}</option>)}
                    </select>
                  </Field>
                </div>
                <div className="pd-num" style={{ fontSize: 11.5, color: "var(--faint)",
                  paddingBottom: 18 }}>
                  {tot.yards ? `${comma(tot.yards)} · ` : ""}
                  {(et.mode || "auto") === "auto" ? `reads as ${inferred.toLowerCase()}` : ""}
                </div>
              </div>

              <label style={{ display: "flex", gap: 9, alignItems: "center", marginBottom: 12 }}>
                <input type="checkbox" checked={!!et.progressive}
                  onChange={(e) => set({ progressive: e.target.checked })} />
                <span style={{ fontSize: 13 }}>This set grows each time it comes round</span>
              </label>

              {et.progressive && (
                <div style={{ marginBottom: 14, padding: 12, background: "var(--field)",
                  border: "1px solid var(--line)", borderRadius: 8 }}>
                  <div style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "flex-end" }}>
                    <div style={{ flex: "1 1 190px" }}>
                      <Field label="Each time">
                        <select className="pd-in" value={st.kind}
                          onChange={(e) => setStep({ kind: e.target.value })}>
                          {STEP_KINDS.map(([k, l]) => <option key={k} value={k}>{l}</option>)}
                        </select>
                      </Field>
                    </div>
                    {st.kind !== "none" && (
                      <div style={{ flex: "0 1 96px" }}>
                        <Field label={st.kind === "reps" ? "Repeats" : st.kind === "dist" ? "Distance" : "Seconds"}>
                          <input className="pd-in pd-num" type="number" min="1" value={st.by}
                            onChange={(e) => setStep({ by: Math.max(1, +e.target.value || 1) })} />
                        </Field>
                      </div>
                    )}
                    <div style={{ flex: "0 1 110px" }}>
                      <Field label="Steps, then holds">
                        <input className="pd-in pd-num" type="number" min="1" max="30" value={st.count}
                          onChange={(e) => setStep({ count: Math.max(1, +e.target.value || 1) })} />
                      </Field>
                    </div>
                  </div>
                  <div className="pd-num" style={{ fontSize: 11.5, color: "var(--faint)",
                    lineHeight: 1.7, marginTop: -4 }}>
                    {preview.map((x, i) => <div key={i}>{i + 1}. {x.split("\n")[0]}</div>)}
                    {(+st.count || 1) > 4 && <div>…</div>}
                  </div>
                </div>
              )}

              <Field label="Note">
                <input className="pd-in" value={et.note || ""}
                  placeholder="How it is run: interval, rest, what counts"
                  onChange={(e) => set({ note: e.target.value })} />
              </Field>

              <div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
                <button className="pd-btn" data-ghost="1" onClick={() => setEditT(null)}>Cancel</button>
                <button className="pd-btn" data-key="1" disabled={!et.name.trim()}
                  onClick={() => {
                    setTests(tests.some((x) => x.id === et.id)
                      ? tests.map((x) => (x.id === et.id ? et : x)) : [...tests, et]);
                    setEditT(null);
                  }}>
                  {tests.some((x) => x.id === et.id) ? "Save changes" : "Add test"}
                </button>
              </div>
            </div>
          </div>
        );
      })()}

      {edit && (
        <div className="pd-modal" onClick={(e) => e.target === e.currentTarget && setEdit(null)}>
          <div className="pd-sheet" style={{ padding: 20 }}>
            <div className="pd-big" style={{ fontSize: 26, marginBottom: 14 }}>
              {edit.id ? "Edit set" : "New set"}
            </div>
            <div style={{ display: "flex", gap: 12 }}>
              <div style={{ flex: 2 }}>
                <Field label="Name">
                  <input className="pd-in" value={edit.name} autoFocus placeholder="Standard warm up"
                    onChange={(e) => setEdit({ ...edit, name: e.target.value })} />
                </Field>
              </div>
              <div style={{ flex: 1 }}>
                <Field label="Category">
                  <select className="pd-in" value={edit.group}
                    onChange={(e) => setEdit({ ...edit, group: e.target.value })}>
                    {cats.map((c) => <option key={c} value={c}>{c}</option>)}
                  </select>
                </Field>
              </div>
            </div>
            <Field label="The set">
              <textarea className="pd-in" rows={9} wrap="off" spellCheck={false}
                style={{ fontFamily: "var(--mono)", lineHeight: 1.7, resize: "vertical" }}
                value={edit.text} onChange={(e) => setEdit({ ...edit, text: e.target.value })}
                placeholder={"Main set:\n  10x100 @ 1:30 free red"} />
            </Field>
            <div style={{ display: "flex", alignItems: "center", gap: 14 }}>
              <div className="pd-num" style={{ color: "var(--muted)", fontSize: 12 }}>
                {preview.yards ? `${comma(preview.yards)} · ` : ""}{fmt(preview.seconds)}{preview.estimated ? " est" : ""}
              </div>
              <div style={{ marginLeft: "auto", display: "flex", gap: 8 }}>
                <button className="pd-btn" data-ghost="1" onClick={() => setEdit(null)}>Cancel</button>
                <button className="pd-btn" data-key="1" onClick={commit} disabled={!edit.name.trim()}>
                  {edit.id ? "Save changes" : "Add set"}
                </button>
              </div>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

/* ---------------------------------------------------------- shell */

const SETTINGS_PAGES = [["general", "General"], ["zones", "Zones"], ["sets", "Sets"]];

function SettingsTab({ page, setPage, cfg, setCfg, lib, setLib, tests, setTests, log, seasons }) {
  return (
    <div>
      <div className="pd-sub">
        {SETTINGS_PAGES.map(([k, l]) => (
          <button key={k} className="pd-subtab" data-on={page === k ? "1" : "0"}
            onClick={() => setPage(k)}>{l}</button>
        ))}
      </div>
      {page === "general" && <GeneralPage cfg={cfg} setCfg={setCfg} log={log} seasons={seasons} />}
      {page === "zones" && <ZonesPage cfg={cfg} setCfg={setCfg} />}
      {page === "sets" && <SetsPage lib={lib} setLib={setLib} cfg={cfg} setCfg={setCfg}
        tests={tests} setTests={setTests} />}
    </div>
  );
}

/* ---------------------------------------------------------- attendance */

function AttendanceSheet({ season, onClose }) {
  const [orient, setOrient] = useState("landscape");
  const [perPage, setPerPage] = useState(4);
  const [w, h] = PAGE[orient];
  const dates = useMemo(() => {
    const train = seasonDates(season);
    const meetDays = sortedMeets(season).map((m) => m.date);
    return [...new Set([...train, ...meetDays])].sort();
  }, [season]);
  const meetOn = useMemo(() => {
    const m = {};
    sortedMeets(season).forEach((x) => (m[x.date] = x));
    return m;
  }, [season]);
  const struct = primaryStructure(season);
  const groups = struct ? struct.groups : [];
  const days = (season.trainingDays || [1, 2, 3, 4, 5]).length || 5;
  const perSheet = Math.max(1, perPage * days);
  const pages = [];
  for (let i = 0; i < dates.length; i += perSheet) pages.push(dates.slice(i, i + perSheet));

  const rostered = (gid) => (season.roster || []).filter((s) => s.groupId === gid && s.name.trim());
  const loose = (season.roster || []).filter((s) => s.name.trim() && !groups.some((g) => g.id === s.groupId));

  return (
    <div className="pd-printwrap" style={{ position: "fixed", inset: 0, background: "var(--scrim)",
      overflow: "auto", padding: 20, zIndex: 80 }}>
      <style>{`@page{size:letter ${orient};margin:.35in;}`}</style>

      <div className="pd-noprint" style={{ display: "flex", gap: 10, alignItems: "center",
        maxWidth: w, margin: "0 auto 14px", flexWrap: "wrap" }}>
        <div className="pd-eyebrow">Attendance · {season.name}</div>
        <div className="pd-seg2">
          {["portrait", "landscape"].map((o) => (
            <button key={o} data-on={orient === o ? "1" : "0"} style={{ textTransform: "capitalize" }}
              onClick={() => setOrient(o)}>{o}</button>
          ))}
        </div>
        <label style={{ display: "flex", gap: 7, alignItems: "center", fontSize: 12, color: "var(--muted)" }}>
          Weeks per page
          <input className="pd-in" type="number" min="1" max="8" style={{ width: 62 }} value={perPage}
            onChange={(e) => setPerPage(Math.max(1, Math.min(8, +e.target.value || 1)))} />
        </label>
        <span style={{ fontSize: 12, color: "var(--muted)" }}>{pages.length} pages</span>
        <div style={{ marginLeft: "auto", display: "flex", gap: 8 }}>
          <button className="pd-btn" data-key="1" onClick={() => window.print()}>Print</button>
          <button className="pd-btn" data-ghost="1" onClick={onClose}>Close</button>
        </div>
      </div>

      {pages.map((chunk, pi) => (
        <FitPage w={w} h={h} base={10} pad={26} key={pi} resetKey={orient + perPage + pi}>
            <div className="pg-hd">
              <h1>{season.name}</h1>
              <span>Attendance</span>
              <span style={{ marginLeft: "auto" }}>
                {pretty(chunk[0])} – {pretty(chunk[chunk.length - 1])} · sheet {pi + 1} of {pages.length}
              </span>
            </div>
            <table className="pg-att">
              <colgroup>
                <col style={{ width: orient === "landscape" ? 168 : 148 }} />
                {chunk.map((d) => <col key={d} />)}
              </colgroup>
              <thead>
                <tr>
                  <th>Swimmer</th>
                  {chunk.map((d) => {
                    const dt = new Date(d + "T00:00:00");
                    const mt = meetOn[d];
                    return <th key={d} className={"pg-day" + (mt ? " pg-meet" : "")}
                      title={mt ? mt.name : undefined}>
                      {mt ? meetLevel(mt) : DAY_NAMES[dt.getDay()][0]}<br />{dt.getMonth() + 1}/{dt.getDate()}
                    </th>;
                  })}
                </tr>
              </thead>
              <tbody>
                {groups.map((g) => {
                  const swimmers = rostered(g.id);
                  if (!swimmers.length) return null;
                  return (
                    <React.Fragment key={g.id}>
                      <tr className="pg-grp">
                        <td colSpan={chunk.length + 1}>
                          <span className="pg-sw" style={{ background: g.color }} />{g.name}
                        </td>
                      </tr>
                      {swimmers.map((s) => (
                        <tr key={s.id}>
                          <td className="pg-name">{s.name}</td>
                          {chunk.map((d) => <td key={d} className="pg-box" />)}
                        </tr>
                      ))}
                    </React.Fragment>
                  );
                })}
                {!!loose.length && (
                  <React.Fragment>
                    <tr className="pg-grp"><td colSpan={chunk.length + 1}>Unassigned</td></tr>
                    {loose.map((s) => (
                      <tr key={s.id}>
                        <td className="pg-name">{s.name}</td>
                        {chunk.map((d) => <td key={d} className="pg-box" />)}
                      </tr>
                    ))}
                  </React.Fragment>
                )}
              </tbody>
            </table>
        </FitPage>
      ))}
    </div>
  );
}

/* ============================================================
   9d. FIRST RUN
   ============================================================ */

function Welcome({ onStart }) {
  const [theme, setTheme] = useState("dark");
  const sample = (t) => t === "light"
    ? { bg: "#F2F6F7", panel: "#FFFFFF", line: "#D2DEE1", ink: "#0D2B32", muted: "#5C777F", aqua: "#0C8878", clock: "#CE3327" }
    : { bg: "#061F26", panel: "#0B2F39", line: "#1B5A69", ink: "#E8F4F2", muted: "#87ABB3", aqua: "#4FD3C4", clock: "#FF4438" };

  return (
    <div style={{ maxWidth: 660, margin: "6vh auto 0", padding: "0 20px" }}>
      <div className="pd-mark" style={{ fontSize: 34, paddingBottom: 0 }}>Practice<em>·</em>Desk</div>
      <div style={{ color: "var(--muted)", fontSize: 14.5, lineHeight: 1.7, margin: "14px 0 26px" }}>
        Write practices the way you already write them. Distance, time, stroke and effort are read
        as you type; nothing has to be entered twice. Everything you save stays on this computer.
      </div>

      <div className="pd-eyebrow" style={{ marginBottom: 10 }}>Pick a look</div>
      <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
        {["dark", "light"].map((t) => {
          const c = sample(t);
          return (
            <button key={t} onClick={() => setTheme(t)}
              style={{ background: c.bg, border: `2px solid ${theme === t ? c.aqua : c.line}`,
                borderRadius: 10, padding: 14, textAlign: "left", display: "block" }}>
              <div style={{ background: c.panel, border: `1px solid ${c.line}`, borderRadius: 7, padding: 11 }}>
                <div style={{ fontFamily: "var(--disp)", fontSize: 11, letterSpacing: ".18em",
                  textTransform: "uppercase", color: c.muted }}>Total distance</div>
                <div style={{ fontFamily: "var(--disp)", fontWeight: 700, fontSize: 32, color: c.ink,
                  lineHeight: 1, margin: "3px 0 7px" }}>4,200</div>
                <div style={{ display: "flex", height: 8, borderRadius: 5, overflow: "hidden", gap: 1 }}>
                  <div style={{ flex: 3, background: c.aqua }} />
                  <div style={{ flex: 1, background: c.clock }} />
                  <div style={{ flex: 1, background: c.muted }} />
                </div>
              </div>
              <div style={{ color: c.ink, fontSize: 13, fontWeight: 500, marginTop: 10,
                textTransform: "capitalize" }}>{t}</div>
            </button>
          );
        })}
      </div>

      <div style={{ color: "var(--faint)", fontSize: 12.5, lineHeight: 1.7, margin: "20px 0 22px" }}>
        You are starting empty on purpose. No sample sets, no season. Build the warm ups you actually
        use under <b style={{ color: "var(--muted)", fontWeight: 500 }}>Settings → Sets</b>, and set up
        a season when you are ready. Timed breaks are already there.
      </div>

      <button className="pd-btn" data-key="1" style={{ padding: "10px 20px", fontSize: 14 }}
        onClick={() => onStart(theme)}>Start writing</button>
    </div>
  );
}

/* ============================================================
   9e. STARTING A PRACTICE
   ============================================================ */

function NewPractice({ seasons, cfg, onCreate, onClose }) {
  const [title, setTitle] = useState("");
  const [date, setDate] = useState(today());
  const [seasonId, setSeasonId] = useState(
    cfg.activeSeason || (seasons[0] && seasons[0].id) || "");
  const season = seasons.filter((s) => s.id === seasonId)[0] || null;
  const [structureId, setStructureId] = useState("");
  const [course, setCourse] = useState(toCourse(season && season.course));
  const [attendance, setAttendance] = useState("mandatory");
  const [primary, setPrimary] = useState(primaryOf(season));

  useEffect(() => {
    const st = season ? (primaryStructure(season) || (season.structures || [])[0]) : null;
    setStructureId(st ? st.id : "");
    if (season && season.course) setCourse(season.course);
    if (season) setPrimary(primaryOf(season));
  }, [seasonId]); // eslint-disable-line

  const structure = season
    ? (season.structures || []).filter((x) => x.id === structureId)[0] : null;
  const columns = structure ? structure.groups : [{ id: "solo", name: "Everyone", color: GROUP_COLORS[0] }];

  return (
    <div className="pd-modal" onClick={(e) => e.target === e.currentTarget && onClose()}>
      <div className="pd-sheet" style={{ padding: 22, maxWidth: 520 }}>
        <div className="pd-big" style={{ fontSize: 27, marginBottom: 4 }}>New practice</div>
        <div style={{ color: "var(--muted)", fontSize: 12.5, lineHeight: 1.65, marginBottom: 18 }}>
          It goes into the log straight away and saves itself as you write, so you can set up a
          few now and fill them in later.
        </div>

        <div style={{ display: "flex", gap: 12 }}>
          <div style={{ flex: 2 }}>
            <Field label="Title">
              <input className="pd-in" autoFocus value={title} placeholder="Tuesday AM"
                onChange={(e) => setTitle(e.target.value)}
                onKeyDown={(e) => { if (e.key === "Enter") onCreate({ title, date, seasonId, structureId, course, attendance, primary }); }} />
            </Field>
          </div>
          <div style={{ flex: 1 }}>
            <Field label="Date">
              <input className="pd-in" type="date" value={date} onChange={(e) => setDate(e.target.value)} />
            </Field>
          </div>
          <div style={{ flex: "0 1 96px" }}>
            <Field label="Course">
              <select className="pd-in" value={course} onChange={(e) => setCourse(e.target.value)}>
                {COURSES.map(([k, l]) => <option key={k} value={k}>{l}</option>)}
              </select>
            </Field>
          </div>
          <div style={{ flex: "0 1 132px" }}>
            <Field label="Attendance">
              <select className="pd-in" value={attendance} onChange={(e) => setAttendance(e.target.value)}>
                {ATT_KINDS.map(([k, l]) => <option key={k} value={k}>{l}</option>)}
              </select>
            </Field>
          </div>
        </div>

        {!!seasons.length && (
          <div style={{ display: "flex", gap: 12 }}>
            <div style={{ flex: 1 }}>
              <Field label="Season">
                <select className="pd-in" value={seasonId} onChange={(e) => setSeasonId(e.target.value)}>
                  {seasons.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
                  <option value="">None</option>
                </select>
              </Field>
            </div>
            <div style={{ flex: 1 }}>
              <Field label="Groups">
                <select className="pd-in" value={structureId} disabled={!season}
                  onChange={(e) => setStructureId(e.target.value)}>
                  {season && (season.structures || []).map((st) => (
                    <option key={st.id} value={st.id}>{st.name}</option>
                  ))}
                  <option value="">One group</option>
                </select>
              </Field>
            </div>
          </div>
        )}

        <div className="pd-eyebrow" style={{ marginBottom: 6 }}>Unless a line says otherwise</div>
        <PrimaryPicker value={primary} onChange={setPrimary} vocab={cfg.zoneVocab} />
        <div style={{ fontSize: 11.5, color: "var(--faint)", lineHeight: 1.6, margin: "-4px 0 16px" }}>
          Anything a set does not name falls to these, so an aerobic freestyle practice needs no
          words at all on most of its lines.
        </div>

        <div className="pd-eyebrow" style={{ marginBottom: 6 }}>Columns you will get</div>
        <div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 18 }}>
          {columns.map((g) => (
            <span className="pd-trig" key={g.id} style={{ fontFamily: "var(--body)" }}>
              <i style={{ width: 9, height: 9, borderRadius: 2, background: g.color, display: "inline-block" }} />
              {g.name}
              {g.base ? <em style={{ fontStyle: "normal", color: "var(--faint)",
                fontFamily: "var(--mono)", fontSize: 10.5 }}>{clockText(g.base)}</em> : null}
            </span>
          ))}
        </div>

        <div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
          <button className="pd-btn" data-ghost="1" onClick={onClose}>Cancel</button>
          <button className="pd-btn" data-key="1"
            onClick={() => onCreate({ title, date, seasonId, structureId, course, attendance, primary })}>Start writing</button>
        </div>
      </div>
    </div>
  );
}

/* ============================================================
   9f. PRACTICE DETAILS — everything that is not the writing
   ============================================================ */

function PracticeInfo({ draft, seasons, cfg, onSave, onDelete, onClose }) {
  const [d, setD] = useState({ ...draft });
  const [bd, setBd] = useState({});
  const season = seasons.filter((s) => s.id === d.seasonId)[0] || null;
  const setDoc = (doc) => setD({ ...d, doc });

  const commitBase = (g) => {
    const raw = bd[g.id];
    if (raw !== undefined) {
      const sec = toSec(raw);
      if (sec >= 30 && sec <= 400)
        setDoc({ ...d.doc, groups: d.doc.groups.map((x) => (x.id === g.id ? { ...x, base: sec } : x)) });
    }
    setBd((o) => { const n = { ...o }; delete n[g.id]; return n; });
  };

  return (
    <div className="pd-modal" onClick={(e) => e.target === e.currentTarget && onClose()}>
      <div className="pd-sheet" style={{ padding: 22, maxWidth: 580 }}>
        <div className="pd-big" style={{ fontSize: 27, marginBottom: 16 }}>Practice details</div>

        <div style={{ display: "flex", gap: 12 }}>
          <div style={{ flex: 2 }}>
            <Field label="Title">
              <input className="pd-in" autoFocus value={d.title} placeholder="Tuesday AM"
                onChange={(e) => setD({ ...d, title: e.target.value })} />
            </Field>
          </div>
          <div style={{ flex: 1 }}>
            <Field label="Date">
              <input className="pd-in" type="date" value={d.date}
                onChange={(e) => setD({ ...d, date: e.target.value })} />
            </Field>
          </div>
        </div>

        <div style={{ display: "flex", gap: 12 }}>
          <div style={{ flex: 2 }}>
            <Field label="Season">
              <select className="pd-in" value={d.seasonId || ""}
                onChange={(e) => {
                  const id = e.target.value || null;
                  const sn = seasons.filter((x) => x.id === id)[0];
                  setD({ ...d, seasonId: id, course: sn && sn.course ? sn.course : d.course });
                }}>
                <option value="">No season</option>
                {seasons.map((sn) => <option key={sn.id} value={sn.id}>{sn.name}</option>)}
              </select>
            </Field>
          </div>
          <div style={{ flex: 1 }}>
            <Field label="Course">
              <select className="pd-in" value={toCourse(d.course)}
                onChange={(e) => setD({ ...d, course: e.target.value })}>
                {COURSES.map(([k, l]) => <option key={k} value={k}>{l}</option>)}
              </select>
            </Field>
          </div>
          <div style={{ flex: 1 }}>
            <Field label="Attendance">
              <select className="pd-in" value={attKind(d)}
                onChange={(e) => setD({ ...d, attendance: e.target.value })}>
                {ATT_KINDS.map(([k, l]) => <option key={k} value={k}>{l}</option>)}
              </select>
            </Field>
          </div>
        </div>

        <div className="pd-eyebrow" style={{ marginBottom: 6 }}>Unless a line says otherwise</div>
        <PrimaryPicker value={primaryOf(d)} onChange={(v) => setD({ ...d, primary: v })}
          vocab={cfg.zoneVocab} />

        <div className="pd-eyebrow" style={{ marginBottom: 6 }}>Groups and base paces</div>
        <div style={{ display: "grid", gap: 6, marginBottom: 10 }}>
          {d.doc.groups.map((g) => (
            <div key={g.id} style={{ display: "flex", gap: 8, alignItems: "center" }}>
              <i style={{ width: 13, height: 13, borderRadius: 3, background: g.color, flex: "none",
                boxShadow: "0 0 0 1px var(--ring)" }} />
              <input className="pd-in" style={{ flex: 1 }} value={g.name} aria-label="Group name"
                onChange={(e) => setDoc({ ...d.doc,
                  groups: d.doc.groups.map((x) => (x.id === g.id ? { ...x, name: e.target.value } : x)) })} />
              <input className="pd-in pd-num" style={{ width: 78, textAlign: "right" }}
                aria-label={`${g.name} base pace per 100`} title="Base pace per 100"
                value={bd[g.id] !== undefined ? bd[g.id] : clockText(g.base || DEFAULT_BASE)}
                onChange={(e) => setBd({ ...bd, [g.id]: e.target.value })}
                onBlur={() => commitBase(g)}
                onKeyDown={(e) => { if (e.key === "Enter") e.target.blur(); }} />
              <button className="pd-x" title={`Remove ${g.name}`}
                disabled={d.doc.groups.length < 2} style={{ opacity: d.doc.groups.length < 2 ? .3 : 1 }}
                onClick={() => setDoc(removeGroupFromDoc(d.doc, g.id))}>×</button>
            </div>
          ))}
        </div>

        <div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 18 }}>
          <button className="pd-btn" onClick={() => {
            const last = d.doc.groups[d.doc.groups.length - 1];
            setDoc(addGroupToDoc(d.doc, `Group ${d.doc.groups.length + 1}`, last.id,
              (last.base || DEFAULT_BASE) + 20));
          }}>Add group</button>
          {season && !!(season.structures || []).length && (
            <select className="pd-in" style={{ width: 190, fontSize: 12 }} value=""
              aria-label="Replace the groups with a season structure"
              onChange={(e) => {
                const st = season.structures.filter((x) => x.id === e.target.value)[0];
                if (st) setDoc(applyStructure(d.doc, st));
              }}>
              <option value="">Use a season structure…</option>
              {season.structures.map((st) => (
                <option key={st.id} value={st.id}>{st.name} ({st.groups.length})</option>
              ))}
            </select>
          )}
        </div>
        <div style={{ fontSize: 11.5, color: "var(--faint)", lineHeight: 1.6, marginBottom: 16 }}>
          Base pace is what <b style={{ color: "var(--muted)", fontWeight: 500 }}>Adapt from…</b> works
          intervals and repetitions out from, and what times a set written on rest rather than a clock.
        </div>

        <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
          <button className="pd-btn" data-ghost="1" style={{ color: "var(--clock)" }}
            onClick={onDelete}>Delete practice</button>
          <span style={{ marginLeft: "auto", display: "flex", gap: 8 }}>
            <button className="pd-btn" data-ghost="1" onClick={onClose}>Cancel</button>
            <button className="pd-btn" data-key="1" onClick={() => onSave(d)}>Save details</button>
          </span>
        </div>
      </div>
    </div>
  );
}

/* ============================================================
   9g. SLIDES — one block at a time, for the wall
   ============================================================ */

function FitText({ max, min, resetKey, className, style, children }) {
  const hi0 = max || 40, lo0 = min || 11;
  const [fs, setFs] = useState(hi0);
  const span = useRef({ lo: lo0, hi: hi0 });
  const box = useRef(null);
  useEffect(() => { span.current = { lo: lo0, hi: hi0 }; setFs(hi0); }, [resetKey, hi0, lo0]);
  useLayoutEffect(() => {
    const el = box.current;
    if (!el) return;
    const b = span.current;
    const fits = el.scrollHeight <= el.clientHeight + 1 && el.scrollWidth <= el.clientWidth + 1;
    if (fits) { if (fs >= b.hi - 0.05) return; b.lo = fs; } else b.hi = fs;
    if (b.hi - b.lo <= 0.8) { if (!fits && fs > b.lo) setFs(b.lo); return; }
    const next = +(((b.lo + b.hi) / 2).toFixed(1));
    if (Math.abs(next - fs) > 0.05) setFs(next);
  }, [fs]);
  return (
    <div ref={box} className={className} style={{ ...style, fontSize: fs, overflow: "hidden" }}>
      {children}
    </div>
  );
}

function SlideShow({ draft, cfg, onClose }) {
  const doc = draft.doc;
  const blocks = useMemo(
    () => doc.blocks.filter((b) => b.cells.some((c) => c.text.trim())), [doc]);
  const [i, setI] = useState(0);
  const shell = useRef(null);
  const n = blocks.length;
  const at = Math.min(i, Math.max(0, n - 1));
  const block = blocks[at];

  const go = useCallback((d) => setI((x) => Math.max(0, Math.min(n - 1, x + d))), [n]);
  useEffect(() => {
    const key = (e) => {
      if (e.key === "Escape") onClose();
      else if (e.key === "ArrowRight" || e.key === " " || e.key === "PageDown") { e.preventDefault(); go(1); }
      else if (e.key === "ArrowLeft" || e.key === "PageUp") { e.preventDefault(); go(-1); }
      else if (e.key === "Home") setI(0);
      else if (e.key === "End") setI(n - 1);
    };
    window.addEventListener("keydown", key);
    return () => window.removeEventListener("keydown", key);
  }, [go, n, onClose]);

  const nameOf = (id) => (doc.groups.filter((g) => g.id === id)[0] || {}).name;
  const colorOf = (id) => (doc.groups.filter((g) => g.id === id)[0] || {}).color;

  if (!n) return (
    <div className="pd-slides" onClick={onClose}>
      <div style={{ margin: "auto", color: "var(--muted)", fontSize: 16 }}>
        Nothing written yet. Press Escape to go back.
      </div>
    </div>
  );

  return (
    <div className="pd-slides" ref={shell}>
      <div className="pd-slide-top">
        <span className="pd-slide-title">{draft.title || "Practice"}</span>
        <span className="pd-slide-sub">{pretty(draft.date)} · {courseName(draft.course)}</span>
        <span style={{ marginLeft: "auto", display: "flex", gap: 8, alignItems: "center" }}>
          <span className="pd-slide-sub">{at + 1} / {n}</span>
          <button className="pd-btn" onClick={() => {
            try {
              if (document.fullscreenElement) document.exitFullscreen();
              else if (shell.current && shell.current.requestFullscreen) shell.current.requestFullscreen();
            } catch (e) { /* not permitted here */ }
          }}>Full screen</button>
          <button className="pd-btn" data-ghost="1" onClick={onClose}>Close</button>
        </span>
      </div>

      <div className="pd-slide-body">
        <div className="pd-slide-cells">
          {block.cells.map((c) => {
            const all = c.groups.length === doc.groups.length && doc.groups.length > 1;
            const many = doc.groups.length > 1;
            return (
              <div className="pd-slide-cell" key={c.id} style={{ flex: c.groups.length }}>
                {many && (
                  <div className="pd-slide-tag">
                    {all ? "All groups" : c.groups.map((id) => (
                      <span key={id} style={{ marginRight: 14 }}>
                        <i style={{ display: "inline-block", width: 10, height: 10, borderRadius: 2,
                          background: colorOf(id), marginRight: 7 }} />{nameOf(id)}
                      </span>
                    ))}
                  </div>
                )}
                <FitText className="pd-slide-text" max={block.cells.length > 2 ? 30 : 42} min={11}
                  resetKey={block.id + c.id + at}>
                  {c.text.replace(/\s+$/, "")}
                </FitText>
              </div>
            );
          })}
        </div>
      </div>

      <button className="pd-slide-hit" style={{ left: 0, width: "26%" }} aria-label="Previous block"
        onClick={() => go(-1)} disabled={at === 0}>
        <span className="pd-slide-arrow" style={{ opacity: at === 0 ? 0 : 1 }}>‹</span>
      </button>
      <button className="pd-slide-hit" style={{ right: 0, width: "26%", justifyContent: "flex-end" }}
        aria-label="Next block" onClick={() => go(1)} disabled={at === n - 1}>
        <span className="pd-slide-arrow" style={{ opacity: at === n - 1 ? 0 : 1 }}>›</span>
      </button>

      <div className="pd-slide-dots">
        {blocks.map((b, k) => (
          <button key={b.id} onClick={() => setI(k)} aria-label={`Block ${k + 1}`}
            data-on={k === at ? "1" : "0"} />
        ))}
      </div>
    </div>
  );
}

/* ============================================================
   9i. THE SAFETY NET
   Nothing here should ever be seen. If it is, the practices are
   still on disk, and the coach needs to know that before
   anything else.
   ============================================================ */

class Boundary extends React.Component {
  constructor(props) { super(props); this.state = { err: null }; }
  static getDerivedStateFromError(err) { return { err }; }
  componentDidCatch(err, info) { console.error("Practice Desk stopped:", err, info); }
  render() {
    if (!this.state.err) return this.props.children;
    const msg = String((this.state.err && this.state.err.message) || this.state.err);
    return (
      <div className="pd" data-theme={this.props.theme || "dark"}>
        <style>{CSS}</style>
        <div style={{ maxWidth: 620, margin: "10vh auto 0", padding: "0 20px" }}>
          <div className="pd-mark" style={{ fontSize: 28, paddingBottom: 0 }}>
            Practice<em>·</em>Desk
          </div>
          <div className="pd-card" style={{ padding: 22, marginTop: 20 }}>
            <div className="pd-big" style={{ fontSize: 26 }}>Something went wrong</div>
            <div style={{ color: "var(--muted)", fontSize: 13.5, lineHeight: 1.7, marginTop: 8 }}>
              <b style={{ color: "var(--aqua)", fontWeight: 600 }}>Your practices are safe.</b>{" "}
              Everything is stored on this computer and none of it was touched by this. Reloading
              the page will usually put things right.
            </div>
            <div style={{ display: "flex", gap: 8, marginTop: 18, flexWrap: "wrap" }}>
              <button className="pd-btn" data-key="1" onClick={() => window.location.reload()}>
                Reload
              </button>
              <button className="pd-btn" onClick={() => this.setState({ err: null })}>
                Try again without reloading
              </button>
            </div>
            <div style={{ marginTop: 20, paddingTop: 14, borderTop: "1px solid var(--line)" }}>
              <div className="pd-eyebrow" style={{ marginBottom: 6 }}>If it keeps happening</div>
              <div style={{ fontSize: 12.5, color: "var(--muted)", lineHeight: 1.65 }}>
                Reload, open Plan and save a season file, then send it along with the message below.
              </div>
              <pre style={{ fontFamily: "var(--mono)", fontSize: 11, color: "var(--faint)",
                background: "var(--field)", border: "1px solid var(--line)", borderRadius: 6,
                padding: 10, marginTop: 10, whiteSpace: "pre-wrap", overflowX: "auto" }}>
                {APP_VERSION}{"\n"}{msg}
              </pre>
            </div>
          </div>
        </div>
      </div>
    );
  }
}

/* ============================================================
   10. APP
   ============================================================ */

function PracticeDeskApp() {
  const [theme, setTheme] = useState("dark");
  useEffect(() => {
    store.get(K.cfg, {}).then((c) => setTheme(c && c.theme === "light" ? "light" : "dark"));
  }, []);
  return <Boundary theme={theme}><PracticeDesk onTheme={setTheme} /></Boundary>;
}

function PracticeDesk({ onTheme }) {
  const [ready, setReady] = useState(false);
  const [tab, setTab] = useState("write");
  const [lib, setLib] = useState([]);
  const [log, setLog] = useState([]);
  const [seasons, setSeasons] = useState([]);
  const [tests, setTests] = useState([]);
  const [settingsPage, setSettingsPage] = useState("general");
  const [planPage, setPlanPage] = useState("plan");
  const [attendance, setAttendance] = useState(null);
  const [report, setReport] = useState(null);
  const [paces, setPaces] = useState(null);
  const [cfg, setCfg] = useState({ basePace: 90, course: "yards", track: "__top__", zoneVocab: "color" });
  const [draft, setDraft] = useState(emptyDraft);
  const [saveState, setSaveState] = useState("");
  const [creating, setCreating] = useState(false);
  const [editing, setEditing] = useState(false);
  const [slides, setSlides] = useState(false);
  const [flash, setFlash] = useState("");
  const [printing, setPrinting] = useState(false);

  // load once
  useEffect(() => {
    (async () => {
      const [l, p, legacy, sn, ts, c] = await Promise.all([
        store.get(K.lib, null), loadPractices(),
        store.get(K.season, null), store.get(K.seasons, []), store.get(K.tests, []),
        store.get(K.cfg, {}),
      ]);
      const list = upgradeSeasons(sn, legacy);
      const returning = !!((p && p.length) || (l && l.length) || list.length);
      setLib(withBreaks(l || []));
      const wasCourse = toCourse(c && c.course);
      setLog(p.map((x) => (x.course ? x : { ...x, course: wasCourse })));
      setSeasons(list);
      setTests(ts && ts.length ? ts : (returning ? [] : seedTests()));
      const last = c && c.openId ? p.filter((x) => x.id === c.openId)[0] : null;
      if (last) setDraft({ id: last.id, title: last.title, date: last.date,
        doc: toDoc(last.doc || last.text), seasonId: last.seasonId || null,
        course: toCourse(last.course || (c && c.course)), results: last.results || {},
        attendance: attKind(last), roll: last.roll || {}, primary: primaryOf(last) });
      setCfg({ basePace: 90, course: "yards", track: "__top__", zoneVocab: "color",
        theme: "dark", onboarded: returning,
        vagueByDistance: true, setCategories: DEFAULT_CATS,
        zoneTriggers: JSON.parse(JSON.stringify(DEFAULT_TRIGGERS)),
        activeSeason: list.length ? list[0].id : null, ...c });
      setReady(true);
    })();
  }, []);

  const persist = useCallback(async (key, value) => {
    const ok = await store.set(key, value);
    if (!ok) setFlash("Could not save. Browser storage is full, so save a season file and remove old practices.");
    return ok;
  }, []);
  useEffect(() => { if (ready) persist(K.lib, lib); }, [lib, ready, persist]);
  useEffect(() => { if (ready) persist(K.seasons, seasons); }, [seasons, ready, persist]);
  useEffect(() => { if (ready) persist(K.tests, tests); }, [tests, ready, persist]);
  useEffect(() => { if (ready) persist(K.cfg, { ...cfg, openId: draft.id }); }, [cfg, draft.id, ready, persist]);

  /* ---- the open practice writes itself to its own key, a beat after typing stops ---- */
  useEffect(() => {
    if (!ready || !draft.id) return;
    setSaveState("saving");
    const t = setTimeout(async () => {
      const rec = recordFrom(draft, cfg);
      setLog((l) => l.map((x) => (x.id === rec.id ? rec : x)));
      const ok = await persist(K.practice(rec.id), rec);
      setSaveState(ok ? "saved" : "");
    }, 700);
    return () => clearTimeout(t);
  }, [draft, cfg, ready, persist]);

  const createPractice = ({ title, date, seasonId, structureId, course, attendance, primary }) => {
    const season = seasons.filter((x) => x.id === seasonId)[0];
    const st = season ? (season.structures || []).filter((x) => x.id === structureId)[0] : null;
    const d = { id: uid(), title: title || "", date: date || today(),
      doc: st ? applyStructure(blankDoc(), st) : blankDoc(), seasonId: seasonId || null,
      course: toCourse(course || (season && season.course)),
      attendance: attendance || "mandatory", roll: {},
      primary: primary || (season ? primaryOf(season) : { ...DEFAULT_PRIMARY }) };
    const rec = recordFrom(d, cfg);
    const next = [...log, rec];
    setLog(next);
    setDraft(d);
    if (seasonId) setCfg((c) => ({ ...c, activeSeason: seasonId }));
    setCreating(false);
    setTab("write");
    persist(K.practice(rec.id), rec);
    persist(K.index, next.map((x) => x.id));
  };

  /* The record is already safe from autosave; flush it once more and hand the
     screen back immediately rather than waiting on storage to answer. */
  const closePractice = () => {
    if (draft.id) {
      const rec = recordFrom(draft, cfg);
      setLog((l) => l.map((x) => (x.id === rec.id ? rec : x)));
      persist(K.practice(rec.id), rec);
    }
    setDraft(emptyDraft());
    setSaveState("");
  };

  const removePractice = (id) => {
    const next = log.filter((x) => x.id !== id);
    setLog(next);
    if (draft.id === id) { setDraft(emptyDraft()); setSaveState(""); }
    store.remove(K.practice(id));
    persist(K.index, next.map((x) => x.id));
  };

  const weekStat = useMemo(() => {
    const season = seasons.filter((x) => x.id === draft.seasonId)[0]
      || seasons.filter((x) => x.id === cfg.activeSeason)[0] || seasons[0];
    if (!season || !draft.id) return null;
    const start = mondayOf(draft.date || today());
    const allWeeks = season.weeks || [];
    const idx = allWeeks.findIndex((w) => w.start === start);
    const wk = allWeeks[idx];
    if (!wk) return null;

    const struct = primaryStructure(season);
    const groups = struct ? struct.groups : [];
    const weekOnes = log.filter((p) => mondayOf(p.date) === start);
    const per = docTotals(draft.doc, primed(cfg, draft));
    // practices still to come this week, this one included
    const left = Math.max(1, weekOnes.filter((p) => p.date >= draft.date).length);

    const rows = groups.map((g) => {
      const target = Number(wk.targets[g.id]) || 0;
      const blk = blockForWeek(periodFor(g, allWeeks.length), idx < 0 ? 0 : idx);
      const mix = blk ? blk.mix : null;
      const mixOk = !!(mix && mixTotal(mix) === 100);

      const done = {}; CODES.forEach((c) => (done[c] = 0));
      let untagged = 0, covered = 0, actual = 0;
      weekOnes.forEach((p) => {
        if (p.id === draft.id) return;
        [groupEntry(p, g)].filter(Boolean).forEach((x) => {
          const r = toCodes(x.zone);
          CODES.forEach((c) => (done[c] += r.codes[c]));
          untagged += r.untagged; covered += x.yards; actual += x.yards;
        });
      });

      const dg = draft.doc.groups.filter((x) => x.id === g.id)[0]
        || draft.doc.groups.filter((x) => x.name === g.name)[0];
      const mine = {}; CODES.forEach((c) => (mine[c] = 0));
      let myYards = 0;
      if (dg) {
        const r = toCodes(per[dg.id].zone);
        CODES.forEach((c) => (mine[c] = r.codes[c]));
        untagged += r.untagged; covered += per[dg.id].yards; myYards = per[dg.id].yards;
      }

      const zones = CODES.map((c) => {
        const weekTarget = mixOk ? Math.round((target * mix[c]) / 100) : 0;
        const share = Math.max(0, Math.round((weekTarget - done[c]) / left));
        return { code: c, weekTarget, weekDone: done[c] + mine[c], share, mine: mine[c] };
      });

      return { name: g.name, color: g.color, target, actual, draft: myYards,
        mixOk, phase: blk ? blk.name : null, zones, left,
        tagged: covered > 0 ? 1 - untagged / covered : 1 };
    });

    return { start, focus: wk.focus, seasonName: season.name, attached: !!draft.seasonId, rows,
      thisWeek: meetsInWeek(season, start), next: nextMeetFrom(season, draft.date || today()) };
  }, [seasons, log, draft, cfg]);

  useEffect(() => { if (!flash) return; const t = setTimeout(() => setFlash(""), 2600); return () => clearTimeout(t); }, [flash]);

  const openPractice = (p) => {
    setDraft({ id: p.id, title: p.title, date: p.date, doc: toDoc(p.doc || p.text),
      seasonId: p.seasonId || null, course: toCourse(p.course || cfg.course),
      results: p.results || {}, attendance: attKind(p), roll: p.roll || {},
      primary: primaryOf(p) });
    setSaveState("");
    setTab("write");
  };

  const TABS = [["write", "Write"], ["log", "Log"], ["plan", "Plan"], ["settings", "Settings"]];

  const theme = cfg.theme === "light" ? "light" : "dark";
  useEffect(() => { if (onTheme) onTheme(theme); }, [theme, onTheme]);
  const activeSeason = seasons.filter((x) => x.id === cfg.activeSeason)[0] || seasons[0] || null;

  if (ready && !cfg.onboarded) return (
    <div className="pd" data-theme={theme}>
      <style>{CSS}</style>
      <Welcome onStart={(t) => setCfg({ ...cfg, theme: t, onboarded: true })} />
    </div>
  );

  return (
    <div className="pd" data-theme={theme}>
      <style>{CSS}</style>

      <div className="pd-top pd-noprint">
        <div className="pd-mark">Practice<em>·</em>Desk</div>
        {!!activeSeason && <div className="pd-topseason">{activeSeason.name}</div>}
        <div className="pd-tabs">
          {TABS.map(([k, l]) => (
            <button key={k} className="pd-tab" data-on={tab === k ? "1" : "0"} onClick={() => setTab(k)}>{l}</button>
          ))}
        </div>
      </div>

      {flash && (
        <div className="pd-noprint" style={{ background: "var(--flash)", borderBottom: "1px solid var(--line)",
          padding: "8px 20px", fontSize: 12.5, color: "var(--aqua)" }}>{flash}</div>
      )}

      <div className="pd-wrap pd-noprint">
        {!ready ? <div className="pd-card pd-empty">Opening your practices…</div> : (
          <>
            {tab === "write" && (draft.id
              ? <WriteTab lib={lib} cfg={cfg} tests={tests} setTests={setTests}
                  season={seasons.filter((x) => x.id === draft.seasonId)[0] || null}
                  seasonLog={log}
                  draft={draft} setDraft={setDraft} saveState={saveState}
                  onPrint={() => setPrinting(true)} onSlides={() => setSlides(true)}
                  onEdit={() => setEditing(true)} onClose={closePractice} weekStat={weekStat} />
              : <Dashboard log={log} seasons={seasons} onOpen={openPractice}
                  onNew={() => setCreating(true)} onDelete={removePractice} />)}
            {tab === "log" && <LogTab log={log} cfg={cfg} setCfg={setCfg} seasons={seasons}
              tests={tests} onOpen={openPractice} onNew={() => setCreating(true)}
              onDelete={removePractice} />}
            {tab === "plan" && <SeasonPage page={planPage} setPage={setPlanPage}
              seasons={seasons} setSeasons={setSeasons} cfg={cfg} setCfg={setCfg}
              log={log} setLog={setLog} tests={tests} onAttendance={(sn) => setAttendance(sn)}
              onReport={(sn) => setReport(sn)} onPaces={(sn) => setPaces(sn)} />}
            {tab === "settings" && <SettingsTab page={settingsPage} setPage={setSettingsPage}
              cfg={cfg} setCfg={setCfg} lib={lib} setLib={setLib} tests={tests} setTests={setTests}
              log={log} seasons={seasons} />}
          </>
        )}
      </div>

      {creating && <NewPractice seasons={seasons} cfg={cfg}
        onCreate={createPractice} onClose={() => setCreating(false)} />}
      {editing && draft.id && <PracticeInfo draft={draft} seasons={seasons} cfg={cfg}
        onSave={(d) => { setDraft(d); setEditing(false); }}
        onDelete={() => { const id = draft.id; setEditing(false); removePractice(id); }}
        onClose={() => setEditing(false)} />}
      {slides && draft.id && <SlideShow draft={draft} cfg={cfg} onClose={() => setSlides(false)} />}
      {printing && <PrintSheet draft={draft} cfg={cfg} seasons={seasons} onClose={() => setPrinting(false)} />}
      {paces && <PaceChartSheet season={paces} cfg={cfg} onClose={() => setPaces(null)} />}
      {attendance && <AttendanceSheet season={attendance} onClose={() => setAttendance(null)} />}
      {report && <SeasonReport season={report} log={log} cfg={cfg} tests={tests}
        onClose={() => setReport(null)} />}

    </div>
  );
}


/* ============================================================
   BOOT
   The service worker is registered from index.html, not here:
   Babel runs this file as a blob module, and a relative path
   cannot be resolved against a blob URL.
   ============================================================ */

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

// Ask the browser not to evict the log when disk gets tight.
if (navigator.storage && navigator.storage.persist) navigator.storage.persist();
