// shared.jsx — CLA Practice Planner shared model + utilities
// Categories, drill library, practice state helpers, and small UI atoms.

// ─────────────────────────────────────────────────────────────────────
// Category palette — drill type taxonomy. Colors tuned to live together
// on warm paper (#f4ede2). Each entry has:
//   ink:  full-saturation chip color (used for fills, badges)
//   wash: 12% wash for soft backgrounds
//   line: 28% line for borders
//   label: human label
// ─────────────────────────────────────────────────────────────────────
// v8 "Calm Cockpit" palette (design_handoff_v8_redesign/README.md tokens):
// OFF #e8842c · DEF #d64545 · SCR #7b61ff · CON #2fa25c · PD #3b82d9 · SHO #c9a227.
// Film/Other keep their v6 inks (not in the v8 spec). This stays the ONE home
// for category color — bar, legend, calendar and print all derive from here.
const CATEGORIES = {
  offense:      { label: 'Offense',      ink: '#e8842c', wash: 'rgba(232,132,44,0.12)',  line: 'rgba(232,132,44,0.32)' },
  defense:      { label: 'Defense',      ink: '#d64545', wash: 'rgba(214,69,69,0.12)',   line: 'rgba(214,69,69,0.32)' },
  scrimmage:    { label: 'Scrimmage',    ink: '#7b61ff', wash: 'rgba(123,97,255,0.12)',  line: 'rgba(123,97,255,0.32)' },
  shooting:     { label: 'Shooting',     ink: '#c9a227', wash: 'rgba(201,162,39,0.14)',  line: 'rgba(201,162,39,0.36)' },
  playerdev:    { label: 'Player Dev',   ink: '#3b82d9', wash: 'rgba(59,130,217,0.12)',  line: 'rgba(59,130,217,0.32)' },
  conditioning: { label: 'Conditioning', ink: '#2fa25c', wash: 'rgba(47,162,92,0.14)',   line: 'rgba(47,162,92,0.36)' },
  film:         { label: 'Film',         ink: '#1c2840', wash: 'rgba(28,40,64,0.10)',    line: 'rgba(28,40,64,0.30)' },
  // CLA flag 80a598da (Tim) — catch-all for talks / intro / film sessions / etc.
  other:        { label: 'Other',        ink: '#8a7f72', wash: 'rgba(138,127,114,0.12)', line: 'rgba(138,127,114,0.32)' },
};

const CAT_ORDER = ['offense','defense','scrimmage','shooting','playerdev','conditioning','film','other'];

// v9 S4 (v8.54) — B/W print survivability: the 2–4-letter category CODES, defined
// ONCE beside CATEGORIES. The print sheet's per-row code chips and category-bar
// segment labels read these so category encoding survives a grayscale printer;
// screen surfaces keep dots/inks. (PCV4_CAT_CODES in planner-v4.jsx is the old
// v4 spelling — history's PCCat still uses it; new code uses THIS map.)
const V6_CAT_CODES = { offense:'OFF', defense:'DEF', scrimmage:'SCR', shooting:'SHT',
  playerdev:'PD', conditioning:'CND', film:'FLM', other:'OTH' };

// CLA flag (Tim — "the cue keeps clearing") — a contentEditable that does NOT clobber
// in-progress typing. When a field's text is React-controlled children ({value}), React
// reconciling a re-render resets the DOM node back to `value`; so any background re-render
// while you were typing (a usage scan finishing, a debounced save, a sibling edit) wiped
// what you'd typed — most visibly the vocab CALL cue, but also the practice focus and a
// drill block's name/notes. This writes `value` into the node only when it is NOT focused,
// so typing is never clobbered; the edit commits on blur. Used app-wide.
function EditableText({ value, onCommit, style, tag = 'span', editable = true, placeholder, onPointerDown, onClick }){
  const ref = React.useRef(null);
  React.useLayoutEffect(()=>{
    const el = ref.current;
    if (el && document.activeElement !== el && el.textContent !== (value || '')) el.textContent = value || '';
  });
  const Tag = tag;
  const props = { ref, contentEditable: editable, suppressContentEditableWarning: true, style,
    onBlur: (e)=>{ if (editable && onCommit) onCommit(e.currentTarget.textContent); } };
  if (placeholder != null) props['data-placeholder'] = placeholder;
  if (onPointerDown) props.onPointerDown = onPointerDown;
  if (onClick) props.onClick = onClick;
  return <Tag {...props} />;
}

// CLA flag a279863b (Tim, 2026-07-13) — a drill with no/blank/unrecognized
// category must fall into the explicit 'other' catch-all (added per flag 80a598da),
// NOT 'playerdev'. The old 'playerdev' default made every uncategorized drill —
// e.g. a just-typed "5 V 5 - Offensive Cut Throat" block whose category hadn't been
// set yet — masquerade as Player Development. 'other' is the neutral home for
// uncategorized/talks/intro drills; a real Player Dev drill carries cat:'playerdev'.
function normalizeDrillCat(d){
  const raw = d && (d.cat || d.category);
  return raw && CATEGORIES[raw] ? raw : 'other';
}

// ─────────────────────────────────────────────────────────────────────
// Players + Time on Task (TOT) — CLA flags 9de9db1d / 5eb94b21 / 4b2b5d2a.
// A drill can specify how many players it occupies (playerCount) and
// whether the SAME drill runs on a half court (one end) or a full court
// (both ends). Running full court doubles the bodies engaged. TOT =
// effective players × minutes (player-minutes of engaged work). When a
// drill has no player count we try to infer one from "5v5", "3 on 3",
// "4 players" text so older drills still report something sensible;
// otherwise TOT is just unavailable.
//
// CLA flag 6d5a80ee — the "Ends" toggle is now surfaced as "Half court" /
// "Full court". The underlying doubling math is unchanged: full court
// (two ends) doubles the bodies engaged. We unified on the existing
// `block.court` field (half / full / classroom) as the single source for
// this — `endMode` is kept ONLY as a legacy fallback for older saved
// blocks that have no `court` value.
//
// CLA flag 149a972d — playerCount can be the string 'all', meaning "the
// whole team roster". It resolves to the current team's roster size at
// compute time (so it stays correct when the roster changes). Helpers take
// an optional `rosterSize` to resolve 'all'.
// ─────────────────────────────────────────────────────────────────────
const PLAYERS_ALL = 'all';
function isAllPlayers(v){
  return typeof v === 'string' && v.trim().toLowerCase() === PLAYERS_ALL;
}
// CLA flag 6d3a48aa — total DISTINCT players on a team, across BOTH the squads
// (default_roster.teams[].players) and the by-position roster (rosterGroups[].players).
// Deduped by name so a player listed in both isn't double-counted, and so a roster
// kept only in the by-position view still reports a real size (was 0 before).
function resolveRosterSize(team){
  if (!team) return 0;
  const roster = team.default_roster || team.roster || team;
  if (!roster) return 0;
  const names = new Set();
  const collect = (groups)=>{
    if (!Array.isArray(groups)) return;
    groups.forEach((g)=>{
      const players = g && Array.isArray(g.players) ? g.players : null;
      if (!players) return;
      players.forEach((p)=>{
        const n = (typeof p === 'string') ? p.trim() : (p && p.name ? String(p.name).trim() : '');
        if (n) names.add(n.toLowerCase());
      });
    });
  };
  collect(roster.teams);
  collect(roster.rosterGroups);
  return names.size;
}

const DRILL_END_MODES = [
  { id:'one', label:'1 end',  short:'1E', multiplier:1 },
  { id:'two', label:'2 ends', short:'2E', multiplier:2 },
];
function normalizeEndMode(value){
  return (value === 'two' || value === 'two_ends' || value === 2) ? 'two' : 'one';
}
function endModeMeta(value){
  const id = normalizeEndMode(value);
  return DRILL_END_MODES.find((m)=>m.id===id) || DRILL_END_MODES[0];
}
// CLA flag 6d5a80ee — court IS the half/full control. Full court = both ends
// = ×2 bodies. Classroom and half = ×1. Falls back to legacy endMode when a
// drill carries no `court`.
const DRILL_COURTS = [
  { id:'half', label:'Half court', short:'HC', multiplier:1 },
  { id:'full', label:'Full court', short:'FC', multiplier:2 },
];
function courtMultiplier(d){
  const court = d && d.court;
  if (court === 'full') return 2;
  if (court === 'half' || court === 'classroom') return 1;
  // legacy: no court → fall back to endMode
  return endModeMeta(d && d.endMode).multiplier;
}
function basePlayerCount(d, rosterSize){
  const raw = d && (d.playerCount != null ? d.playerCount
    : (d.players != null ? d.players : d.numPlayers));
  // CLA flag 149a972d — 'all' resolves to the live roster size.
  if (isAllPlayers(raw)){
    const n = Number(rosterSize);
    return Number.isFinite(n) && n > 0 ? n : 0;
  }
  const explicit = Number(raw);
  if (Number.isFinite(explicit) && explicit > 0) return explicit;
  const text = `${(d && d.name) || ''} ${(d && d.desc) || ''} ${(d && d.breakdown) || ''}`;
  const matchup = text.match(/(\d+)\s*(?:v|vs\.?|on|-on-)\s*(\d+)/i);
  if (matchup) return Number(matchup[1]) + Number(matchup[2]);
  const single = text.match(/(\d+)\s*(?:players?|persons?|people)\b/i);
  return single ? Number(single[1]) : 0;
}
function effectivePlayerCount(d, rosterSize){
  const base = basePlayerCount(d, rosterSize);
  return base > 0 ? base * courtMultiplier(d) : 0;
}
function drillMinutes(d){
  const m = Number(d && d.dur);
  return Number.isFinite(m) && m > 0 ? m : 0;
}
function timeOnTask(d, rosterSize){
  const players = effectivePlayerCount(d, rosterSize);
  const minutes = drillMinutes(d);
  return players > 0 && minutes > 0 ? players * minutes : 0;
}
// CLA flag f5fb26d0 (Tim) — TOT as a 0–100 participation %: the share of the day's
// roster actively engaged at any one moment of the drill. 5-on-0 (5 players) with a
// 15-player roster → 33. Full court (both ends) doubles the bodies engaged, so it
// uses effectivePlayerCount; capped at 100 (everyone working).
function totPercent(d, rosterSize){
  const r = Number(rosterSize);
  if (!Number.isFinite(r) || r <= 0) return 0;
  const players = effectivePlayerCount(d, rosterSize);
  if (players <= 0) return 0;
  return Math.min(100, Math.round(players / r * 100));
}
// Mean per-drill participation % across the blocks that have a player count — the
// practice-wide analog of the per-drill TOT.
function avgTotPercent(practice, rosterSize){
  const blocks = ((practice && practice.blocks) || []).filter((b)=> effectivePlayerCount(b, rosterSize) > 0);
  if (!blocks.length) return 0;
  return Math.round(blocks.reduce((s,b)=> s + totPercent(b, rosterSize), 0) / blocks.length);
}
// "5", "All (15)" or "5 × 2 = 10" — shows the full-court doubling.
function drillPlayerLabel(d, rosterSize){
  const all = isAllPlayers(d && (d.playerCount != null ? d.playerCount : d.players));
  const base = basePlayerCount(d, rosterSize);
  if (!base) return all ? 'All' : '—';
  const mult = courtMultiplier(d);
  const head = all ? `All (${base})` : `${base}`;
  return mult === 2 ? `${head} × 2 = ${base*2}` : head;
}
// Practice-wide TOT (player-minutes) + the player ceiling used in the plan.
function practiceTOT(practice, rosterSize){
  const blocks = (practice && practice.blocks) || [];
  let tot = 0, maxPlayers = 0;
  blocks.forEach((b)=>{
    tot += timeOnTask(b, rosterSize);
    const p = effectivePlayerCount(b, rosterSize);
    if (p > maxPlayers) maxPlayers = p;
  });
  return { tot, maxPlayers };
}

// ─────────────────────────────────────────────────────────────────────
// Drill library — picker source. Each drill has a default duration and
// court (half / full / classroom). Real CLA naming where appropriate.
// ─────────────────────────────────────────────────────────────────────
const DRILL_LIBRARY = [
  // Offense
  { cat:'offense', name:'3 v 0 Script',    desc:'Point, Deny, Snap',         court:'half', dur:10, intensity:5 },
  { cat:'offense', name:'5 v 0 Script',    desc:'Point · Drag · Elbow',      court:'full', dur:8,  intensity:5 },
  { cat:'offense', name:'BLOB Reps',       desc:'Base Out — V-Back, 1-4',    court:'half', dur:6,  intensity:4 },
  { cat:'offense', name:'SOB Reps',        desc:'Side Out — Red 1-3 Show',   court:'half', dur:6,  intensity:4 },
  { cat:'offense', name:'Walk Through',    desc:'Half-speed concept review', court:'full', dur:5,  intensity:2 },
  // Defense
  { cat:'defense', name:'Shell Drill',     desc:'Stance, close-out, rotate', court:'half', dur:8,  intensity:6 },
  { cat:'defense', name:'CB / WV: SNL',    desc:'Shake — Nail — Load',       court:'half', dur:8,  intensity:7 },
  { cat:'defense', name:'CH 1 Attack',     desc:'Closeout to contest',       court:'half', dur:8,  intensity:7 },
  { cat:'defense', name:'Red Attack',      desc:'Help & recover live',       court:'full', dur:8,  intensity:7 },
  // Scrimmage
  { cat:'scrimmage', name:'2 v 2 Games',   desc:'3 baskets · 12s clock',     court:'half', dur:15, intensity:8 },
  { cat:'scrimmage', name:'4 v 4 FC',      desc:'Make It / Take It',         court:'full', dur:12, intensity:9 },
  { cat:'scrimmage', name:'5 v 5 Live',    desc:'Auto-options · normal clock', court:'full', dur:18, intensity:9 },
  { cat:'scrimmage', name:'Miss Offense',  desc:'Rebound + outlet → break',  court:'full', dur:6,  intensity:7 },
  // Shooting
  { cat:'shooting', name:'PD Shooting',    desc:'Stations · 4 spots',        court:'half', dur:10, intensity:4 },
  { cat:'shooting', name:'Game Shots',     desc:'Off catch · off bounce',    court:'half', dur:8,  intensity:5 },
  { cat:'shooting', name:'FT Pressure',    desc:'2-and-out · live score',    court:'half', dur:5,  intensity:3 },
  // Player Dev
  { cat:'playerdev', name:'PD Skills',     desc:'Position groups · stations', court:'half', dur:10, intensity:5 },
  { cat:'playerdev', name:'Ball Handling', desc:'2-ball / chair series',      court:'half', dur:6,  intensity:5 },
  { cat:'playerdev', name:'Post Series',   desc:'Drop step · jump hook',      court:'half', dur:6,  intensity:5 },
  // Conditioning
  { cat:'conditioning', name:'Warm Up',    desc:'Dynamic + activation',      court:'full', dur:8,  intensity:3 },
  { cat:'conditioning', name:'17s',        desc:'Sideline-to-sideline',      court:'full', dur:5,  intensity:8 },
  { cat:'conditioning', name:'Cool Down',  desc:'Stretch + recovery',        court:'full', dur:5,  intensity:1 },
  // Film
  { cat:'film',    name:'Film Review',     desc:'Last game · 8 clips',       court:'classroom', dur:15, intensity:1 },
  { cat:'film',    name:'Scout Walk-Thru', desc:'Opponent sets',             court:'classroom', dur:12, intensity:2 },
];

// ─────────────────────────────────────────────────────────────────────
// Default practice — modeled after the 6/20/24 BYU sheet but lightly
// edited so it's our own data. Each direction starts from this seed.
// ─────────────────────────────────────────────────────────────────────
const DEFAULT_PRACTICE = {
  // D5 (v6.1): this is THE canonical sample practice — planner-v4.jsx's PCV4_PRACTICE
  // mirrors these blocks (cat/name/dur/court/intensity MUST stay in sync so landing,
  // dashboard, and app demo all compute the same HC/FC/total). Date is always "today"
  // so TODAY/PAST chips make sense whenever a prospect looks.
  date: new Date().toLocaleDateString('en-US', { weekday:'short', month:'short', day:'numeric' }),
  start: 750,          // 12:30 PM in minutes-since-midnight
  emphasis: 'Spacing on the swing · No-middle on shell · Finish through contact',
  blocks: [
    { id:'b1', cat:'conditioning', name:'Warm Up',       desc:'Activation · dynamic',      dur:8,  court:'full', intensity:3 },
    { id:'b2', cat:'playerdev',    name:'PD Skills',     desc:'Stations · 4 groups',       dur:10, court:'half', intensity:5 },
    { id:'b3', cat:'offense',      name:'3v0 Script',    desc:'Point · Deny · Snap',       dur:8,  court:'half', intensity:5 },
    { id:'b4', cat:'defense',      name:'Shell · SNL',   desc:'Shake — Nail — Load',       dur:10, court:'half', intensity:7 },
    { id:'b5', cat:'shooting',     name:'PD Shooting',   desc:'Stations · 4 spots',        dur:8,  court:'half', intensity:4 },
    { id:'b6', cat:'scrimmage',    name:'2v2 Games',     desc:'3 baskets · 12s clock',     dur:12, court:'half', intensity:8 },
    { id:'b7', cat:'scrimmage',    name:'5v5 Live',      desc:'Auto-options · normal clock', dur:14, court:'full', intensity:9 },
    { id:'b8', cat:'conditioning', name:'Cool Down',     desc:'Stretch + recovery',        dur:5,  court:'full', intensity:1 },
  ],
};

// Roster — used by all three for the "team" affordances.
const ROSTER = {
  guards:  ['Dallin','Egor','Trey'],
  wings:   ['Richie','DB','J-Mac'],
  hybrid:  ['Kanon','Trevin','Brody'],
  centers: ['Keba','Fous','Mag'],
};
const AVAILABILITY = { out: ['Egor','DB','Kanon'], limited: ['Trey'] };

// ─────────────────────────────────────────────────────────────────────
// Time / load helpers
// ─────────────────────────────────────────────────────────────────────
const pad = (n) => String(n).padStart(2,'0');
function fmtTime(minSinceMidnight){
  let m = ((minSinceMidnight % 1440) + 1440) % 1440;
  const h24 = Math.floor(m / 60), mm = m % 60;
  const h12 = ((h24 + 11) % 12) + 1;
  const ap = h24 < 12 ? 'AM' : 'PM';
  return `${h12}:${pad(mm)} ${ap}`;
}
function fmtTimeShort(minSinceMidnight){
  let m = ((minSinceMidnight % 1440) + 1440) % 1440;
  const h24 = Math.floor(m / 60), mm = m % 60;
  const h12 = ((h24 + 11) % 12) + 1;
  return `${h12}:${pad(mm)}`;
}

// ── v5 view preferences (localStorage + cloud sync) ───────────────────
// Tiny persisted-preference helpers used by the Sequence toolbar toggles
// (time-display mode + row density — v2 parity restore). Wrapped in try/catch
// so a locked-down / private-mode browser silently falls back to the default
// instead of throwing. Keys are namespaced under `cla.v5.*`.
//
// CLOUD SYNC v2 (flag d52819fe, Tim 2026-07-06 "allow me to save print settings"):
// localStorage stays the synchronous read path, but every write also pushes a
// debounced snapshot to cla_user_data.settings.view_prefs. The v1 design used ONE
// `_ts` stamp for the whole snapshot — any pref write on a second device stamped
// that device's ENTIRE (possibly stale) snapshot as newest and clobbered the
// server, silently reverting settings made elsewhere. v2 keeps a per-key
// timestamp map (`cla.v5._tsmap`) and merges per key, both on push and on boot
// hydrate: for every key, the newest writer wins — nothing else moves.
// Cloud shape: view_prefs = { _v: 2, entries: { key: { v, t } } }.
// Back-compat: a v1 flat snapshot hydrates as entries stamped with its `_ts`.
const V5_PREF_PREFIX = 'cla.v5.';
const V5_PREF_TS_KEY = 'cla.v5._ts';        // legacy v1 stamp — still bumped for old open tabs
const V5_PREF_TSMAP_KEY = 'cla.v5._tsmap';  // v2: per-key write timestamps
function v5ViewPref(key, fallback){
  try { const v = window.localStorage.getItem(V5_PREF_PREFIX + key); return v == null ? fallback : v; }
  catch (_) { return fallback; }
}
function v5PrefTsMap(){
  try { const m = JSON.parse(window.localStorage.getItem(V5_PREF_TSMAP_KEY) || '{}'); return (m && typeof m === 'object') ? m : {}; }
  catch (_) { return {}; }
}
function v5StampPrefTs(key, t){
  try { const m = v5PrefTsMap(); m[key] = t; window.localStorage.setItem(V5_PREF_TSMAP_KEY, JSON.stringify(m)); } catch (_) {}
}
function v5SetViewPref(key, value){
  try { window.localStorage.setItem(V5_PREF_PREFIX + key, value); } catch (_) {}
  v5StampPrefTs(key, Date.now());
  v5PushViewPrefs(); // debounced cloud copy — no-ops when signed out
}
function v5SnapshotViewPrefs(){
  const out = {};
  try {
    for (let i = 0; i < window.localStorage.length; i++){
      const k = window.localStorage.key(i);
      if (k && k.indexOf(V5_PREF_PREFIX) === 0 && k !== V5_PREF_TS_KEY && k !== V5_PREF_TSMAP_KEY)
        out[k.slice(V5_PREF_PREFIX.length)] = window.localStorage.getItem(k);
    }
  } catch (_) {}
  return out;
}
// Normalize either cloud shape to { key: { v, t } }.
function v5CloudPrefEntries(prefs){
  if (!prefs || typeof prefs !== 'object') return {};
  if (prefs._v === 2 && prefs.entries && typeof prefs.entries === 'object'){
    const out = {};
    Object.keys(prefs.entries).forEach((k) => {
      const e = prefs.entries[k];
      if (e && typeof e === 'object' && e.v != null) out[k] = { v: String(e.v), t: Number(e.t) || 0 };
    });
    return out;
  }
  const legacyTs = Number(prefs._ts) || 0; // v1 flat snapshot
  const out = {};
  Object.keys(prefs).forEach((k) => {
    if (k !== '_ts' && k !== '_v' && k !== 'entries' && prefs[k] != null && typeof prefs[k] !== 'object')
      out[k] = { v: String(prefs[k]), t: legacyTs };
  });
  return out;
}
let v5PrefPushTimer = null;
function v5PushViewPrefs(){
  if (v5PrefPushTimer) window.clearTimeout(v5PrefPushTimer);
  v5PrefPushTimer = window.setTimeout(async () => {
    v5PrefPushTimer = null;
    if (typeof window.saveUserData !== 'function') return; // auth.jsx not loaded / signed out
    // If the boot hydrate hasn't cached the server settings yet, fetch before
    // merging — pushing over an empty cache would drop other settings keys
    // (e.g. last_team_id) and every server-newer pref entry.
    if (!window.__claServerSettings && typeof window.loadUserData === 'function'){
      try { const d = await window.loadUserData(); window.__claServerSettings = (d && d.settings && typeof d.settings === 'object') ? d.settings : {}; } catch (_) {}
    }
    const now = Date.now();
    try { window.localStorage.setItem(V5_PREF_TS_KEY, String(now)); } catch (_) {}
    // Merge per key against the cached server copy: a key only moves up when
    // THIS device wrote it more recently than the server's entry.
    const base = (window.__claServerSettings && typeof window.__claServerSettings === 'object') ? window.__claServerSettings : {};
    const serverEntries = v5CloudPrefEntries(base.view_prefs);
    const localSnap = v5SnapshotViewPrefs();
    const tsMap = v5PrefTsMap();
    const entries = { ...serverEntries };
    Object.keys(localSnap).forEach((k) => {
      const lt = Number(tsMap[k]) || 0;
      const se = serverEntries[k];
      if (!se || lt >= se.t) entries[k] = { v: localSnap[k], t: lt || now };
    });
    const settings = { ...base, view_prefs: { _v: 2, entries } };
    window.__claServerSettings = settings;
    Promise.resolve(window.saveUserData({ settings })).catch(() => {}); // fire-and-forget; next write retries
  }, 900);
}
function v5HydrateViewPrefs(serverSettings){
  window.__claServerSettings = (serverSettings && typeof serverSettings === 'object') ? serverSettings : {};
  const entries = v5CloudPrefEntries(window.__claServerSettings.view_prefs);
  const keys = Object.keys(entries);
  if (!keys.length) return false;
  const tsMap = v5PrefTsMap();
  let applied = 0;
  try {
    keys.forEach((k) => {
      const e = entries[k];
      const lt = Number(tsMap[k]) || 0;
      if (e.t > lt){
        window.localStorage.setItem(V5_PREF_PREFIX + k, e.v);
        tsMap[k] = e.t;
        applied++;
      }
    });
    if (applied) window.localStorage.setItem(V5_PREF_TSMAP_KEY, JSON.stringify(tsMap));
  } catch (_) { return false; }
  if (!applied) return false;
  try { window.dispatchEvent(new Event('cla:viewprefs-hydrated')); } catch (_) {}
  return true;
}

// ── past-practice detection (v2 parity: v2ParsePracticeDate / v2PracticePhase) ──
// Parse a practice's display date ("Thu, Mar 16") to a midnight Date and report
// whether it's strictly before today. Used ONLY for a non-locking "Past" badge —
// per Tim's shipped flags (4803301e), past practices stay fully editable, so this
// never gates editing. Returns false when the date can't be parsed (safer: no badge).
function v5ParsePracticeDate(displayStr){
  if (!displayStr) return null;
  const withYear = /\d{4}/.test(displayStr) ? displayStr : `${displayStr}, ${new Date().getFullYear()}`;
  const d = new Date(withYear);
  if (isNaN(d.getTime())) return null;
  d.setHours(0, 0, 0, 0);
  return d;
}
function v5IsPastPractice(displayStr){
  const pd = v5ParsePracticeDate(displayStr);
  if (!pd) return false;
  const today = new Date(); today.setHours(0, 0, 0, 0);
  return today.getTime() > pd.getTime();
}

// Practice block structure — how the floor is split for a block.
// CLA flag f308d2aa — selectable structures: 2 groups same, 2 groups different,
// 3 groups same, 3 groups different.
//   · 'same'  structures (whole / groups2same / groups3same) are SINGLE timeline
//     slots — the team is split but everyone runs the same drill, one slot of time.
//   · 'diff'  structures run multiple DIFFERENT drills at the SAME time, so they
//     pull the FOLLOWING block(s) up to share this block's start time:
//       groups2diff → the next 1 block is concurrent (2 parallel slots)
//       groups3diff → the next 2 blocks are concurrent (3 parallel slots)
//     `concurrentAfter` is how many following blocks share this block's start.
const BLOCK_STRUCTURES = [
  { id:'whole',       label:'Whole',           sub:'one group',                concurrentAfter: 0 },
  { id:'groups2same', label:'2 Groups · same', sub:'split team · same drill',  concurrentAfter: 0 },
  { id:'groups2diff', label:'2 Groups · diff', sub:'two drills · same time',   concurrentAfter: 1 },
  { id:'groups3same', label:'3 Groups · same', sub:'split team · same drill',  concurrentAfter: 0 },
  { id:'groups3diff', label:'3 Groups · diff', sub:'three drills · same time', concurrentAfter: 2 },
  // CLA flag 8528cd5d (Tim) — 4 / 5 / 6 groups, same or different. 'diff' makes the next
  // (N-1) blocks run concurrently (same start). Shown behind "More" in the picker.
  { id:'groups4same', label:'4 Groups · same', sub:'split team · same drill',  concurrentAfter: 0 },
  { id:'groups4diff', label:'4 Groups · diff', sub:'four drills · same time',  concurrentAfter: 3 },
  { id:'groups5same', label:'5 Groups · same', sub:'split team · same drill',  concurrentAfter: 0 },
  { id:'groups5diff', label:'5 Groups · diff', sub:'five drills · same time',  concurrentAfter: 4 },
  { id:'groups6same', label:'6 Groups · same', sub:'split team · same drill',  concurrentAfter: 0 },
  { id:'groups6diff', label:'6 Groups · diff', sub:'six drills · same time',   concurrentAfter: 5 },
];
// How many blocks AFTER a given block run concurrently with it (share its start).
function structureConcurrentAfter(structure){
  const s = BLOCK_STRUCTURES.find((x)=>x.id===structure);
  return s ? (s.concurrentAfter || 0) : 0;
}
function structureLabel(b){
  if (!b) return '';
  const s = BLOCK_STRUCTURES.find((x)=>x.id===b.structure);
  return s ? s.label : (b.format || '');
}

function computeBlocks(practice){
  let t = practice.start;
  let prevStart = practice.start;
  // CLA flag f308d2aa — a "· diff" block runs its drill alongside the next
  // N blocks (N = concurrentAfter: 1 for 2-groups-diff, 2 for 3-groups-diff).
  // `concurrentLeft` counts how many of the FOLLOWING blocks still share the
  // current `prevStart` (the parallel group's common start time).
  let concurrentLeft = 0;
  return practice.blocks.map((b) => {
    const concurrent = concurrentLeft > 0;
    const start = concurrent ? prevStart : t;
    const end = start + b.dur;
    if (concurrent) {
      t = Math.max(t, end);                       // parallel — extend cursor to the longest slot in the group
      concurrentLeft -= 1;
    } else {
      prevStart = start;
      t = end;
    }
    // If THIS block opens a parallel group, the next N blocks share its start.
    // (A new "· diff" block resets the counter rather than nesting.)
    const opensAfter = structureConcurrentAfter(b.structure);
    if (opensAfter > 0) { prevStart = start; concurrentLeft = opensAfter; }
    return { ...b, start, end, concurrent };
  });
}

// A block is "live" when it's full-on competitive play: any scrimmage, or
// high-intensity guarded work (≥7). Drills, skill stations, film, and
// conditioning are "non-live". Used by the v4 breakdown pies + Analysis tab.
// An explicit per-drill `live` toggle (CLA flag eb3dec4d) overrides the
// inference either way; when unset, fall back to the heuristic.
function isLive(b){
  if (b && typeof b.live === 'boolean') return b.live;
  return b.cat === 'scrimmage' || (b.intensity >= 7 && b.cat !== 'conditioning');
}

// Slate accent for the "Breakdowns" bucket (concurrent slots running different
// categories at the same time). CLA flag efc155a5.
const BREAKDOWN_CAT = { key:'breakdowns', label:'Breakdowns', ink:'#64748b' };

// Category time that respects concurrency. Parallel ("· diff") groups share a
// start, so their WALL-CLOCK time must be counted once — never summed per slot.
//   • A group whose slots are all the SAME category → counts once under it.
//   • A group whose slots span DIFFERENT categories → its wall-clock goes to a
//     "breakdowns" bucket, and `breakdownByCat` records how that time splits
//     across the underlying categories (proportional to each slot's duration).
// So `sum(byCat) + breakdownsMin === totalMin` (the true wall-clock length).
function categoryBreakdown(blocks){
  const byCat = {};
  let breakdownsMin = 0;
  const breakdownByCat = {};
  let i = 0;
  while (i < blocks.length){
    const b = blocks[i];
    const after = structureConcurrentAfter(b.structure);
    if (after > 0){
      const group = blocks.slice(i, i + after + 1);
      const wall = Math.max(...group.map((x)=>x.end)) - b.start; // shared wall-clock
      const cats = new Set(group.map((x)=>x.cat));
      if (cats.size <= 1){
        byCat[b.cat] = (byCat[b.cat]||0) + wall;
      } else {
        breakdownsMin += wall;
        const durSum = group.reduce((s,x)=>s+(x.dur||0),0) || 1;
        group.forEach((x)=>{ breakdownByCat[x.cat] = (breakdownByCat[x.cat]||0) + wall*((x.dur||0)/durSum); });
      }
      i += after + 1;
    } else {
      byCat[b.cat] = (byCat[b.cat]||0) + b.dur;
      i += 1;
    }
  }
  Object.keys(breakdownByCat).forEach((k)=>{ breakdownByCat[k] = Math.round(breakdownByCat[k]); });
  return { byCat, breakdownsMin: Math.round(breakdownsMin), breakdownByCat };
}

function totals(practice){
  const blocks = computeBlocks(practice);
  // Wall-clock length = end of the last block minus practice start. With no
  // concurrency this equals the sum of durations; concurrent (parallel) blocks
  // overlap, so they don't double-extend the schedule end.
  const totalMin = blocks.length ? Math.max(...blocks.map((b)=>b.end)) - practice.start : 0;
  // Category time is concurrency-aware (CLA flag efc155a5) — concurrent-different
  // slots land in a "Breakdowns" bucket instead of double-counting per category.
  const cb = categoryBreakdown(blocks);
  const byCat = {};
  CAT_ORDER.forEach((k)=>{ byCat[k] = cb.byCat[k] || 0; });
  let halfMin = 0, fullMin = 0, classroomMin = 0;
  let liveMin = 0, nonLiveMin = 0;
  // ⚠ loadSum is a PLANNED proxy — Σ(dur × the coach's own 1-10 intensity slider).
  // It is NOT measured athlete load. NEVER print/label it as "Load": it reads as
  // tracking data, and a team with no tracking at all (e.g. Netherlands 26, zero
  // cla_load_sessions) would still show a confident number. Both surfaces that did
  // this were fixed 2026-07-16 (History + the print sheet) — they now show measured
  // load only, from practice.loadData (attached Kinexon/Catapult CSV) or a matching
  // cla_load_sessions row, and render nothing when there is none. If you surface this
  // value, label it "planned"/"intensity × time" and never "Load".
  let loadSum = 0;
  blocks.forEach((b) => {
    if (b.court === 'half') halfMin += b.dur;
    else if (b.court === 'full') fullMin += b.dur;
    else classroomMin += b.dur;
    if (isLive(b)) liveMin += b.dur; else nonLiveMin += b.dur;
    loadSum += b.dur * b.intensity;
  });
  const courtMin = halfMin + fullMin;
  return { blocks, totalMin, byCat, breakdownsMin: cb.breakdownsMin, breakdownByCat: cb.breakdownByCat,
    halfMin, fullMin, classroomMin, courtMin, liveMin, nonLiveMin, loadSum };
}

// ─────────────────────────────────────────────────────────────────────
// Reorder hook — pointer-based vertical reorder for a list of items.
// Returns drag handlers + the live order. No external lib.
// ─────────────────────────────────────────────────────────────────────
function useVerticalReorder(items, onReorder){
  const [dragId, setDragId] = React.useState(null);
  const stateRef = React.useRef({});

  const onPointerDown = (e, id) => {
    e.preventDefault();
    const container = e.currentTarget.closest('[data-reorder-list]');
    if (!container) return;
    const rows = Array.from(container.querySelectorAll('[data-reorder-item]'));
    const rects = rows.map((r) => r.getBoundingClientRect());
    const startY = e.clientY;
    const startIdx = items.findIndex((x)=>x.id===id);
    let liveOrder = items.map((x)=>x.id);
    setDragId(id);

    const move = (ev) => {
      const y = ev.clientY;
      // figure out the nearest slot center
      let target = startIdx;
      for (let i = 0; i < rects.length; i++){
        const r = rects[i];
        if (y < r.top + r.height / 2) { target = i; break; }
        target = i + 1;
      }
      target = Math.max(0, Math.min(items.length - 1, target));
      if (target !== liveOrder.indexOf(id)){
        liveOrder = items.map((x)=>x.id).filter((k)=>k!==id);
        liveOrder.splice(target, 0, id);
        // animate: shift displaced rows by the dragged item's height in the correct direction.
        // When dragging DOWN: rows between startIdx+1..target shift UP (negative).
        // When dragging UP:   rows between target..startIdx-1 shift DOWN (positive).
        // Items outside the affected range are reset to dy=0.
        const draggedHeight = rects[startIdx].height;
        rows.forEach((row, i) => {
          if (row.dataset.reorderItem === id) return;
          let dy = 0;
          if (target > startIdx && i > startIdx && i <= target) {
            dy = -draggedHeight; // shift UP to make room for dragged item landing below
          } else if (target < startIdx && i >= target && i < startIdx) {
            dy = draggedHeight;  // shift DOWN to make room for dragged item landing above
          }
          row.style.transform = `translateY(${dy}px)`;
        });
      }
      const draggedRow = rows[startIdx];
      if (draggedRow) draggedRow.style.transform = `translateY(${y - startY}px)`;
    };

    const up = () => {
      document.removeEventListener('pointermove', move);
      document.removeEventListener('pointerup', up);
      rows.forEach((r) => { r.style.transition = 'none'; r.style.transform = ''; });
      setTimeout(() => rows.forEach((r) => { r.style.transition = ''; }), 0);
      setDragId(null);
      if (liveOrder.join('|') !== items.map((x)=>x.id).join('|')){
        onReorder(liveOrder);
      }
    };
    document.addEventListener('pointermove', move);
    document.addEventListener('pointerup', up);
  };

  return { dragId, onPointerDown };
}

// ─────────────────────────────────────────────────────────────────────
// UI atoms shared across builders
// ─────────────────────────────────────────────────────────────────────
function CategoryDot({ cat, size = 8 }){
  const c = CATEGORIES[cat] || CATEGORIES.other;   // flag a279863b — uncategorized → 'other', not 'playerdev'
  return <span style={{ display:'inline-block', width:size, height:size, borderRadius:'50%', background:c.ink, flexShrink:0 }} />;
}

function CategoryChip({ cat, dense }){
  const c = CATEGORIES[cat] || CATEGORIES.other;   // flag a279863b — uncategorized → 'other', not 'playerdev'
  return (
    <span style={{
      display:'inline-flex', alignItems:'center', gap:5,
      padding: dense ? '1px 6px' : '2px 8px',
      borderRadius: 999,
      fontSize: dense ? 9.5 : 10.5,
      fontWeight: 600,
      letterSpacing:'.04em',
      textTransform:'uppercase',
      color: c.ink,
      background: c.wash,
      boxShadow: `inset 0 0 0 .5px ${c.line}`,
    }}>
      <span style={{ width:5, height:5, borderRadius:'50%', background:c.ink }} />
      {c.label}
    </span>
  );
}

// Taxonomy is useful information, but category hue is reserved for deliberate
// data visualization (the practice-distribution bar, analytics, and print when
// enabled). Library and vocabulary chrome use these neutral primitives so the
// same category is not encoded as a rail, dot, wash, and filter at once.
function CLAP_TaxonomyLabel({ label, count, dense = false, title }){
  return (
    <span title={title} style={{ display:'inline-flex', alignItems:'center', gap: 5,
      minHeight: dense ? 18 : 20, padding: dense ? '0 6px' : '0 8px', borderRadius: 5,
      background:'var(--kit-surface-2, #f8f8f6)', color:'var(--kit-sub, #626872)',
      boxShadow:'inset 0 0 0 .5px var(--kit-hairline, #dfe1e4)',
      fontSize: dense ? 9.5 : 10.5, fontWeight: 650, letterSpacing:'.02em', whiteSpace:'nowrap' }}>
      {label}
      {count != null && <span style={{ color:'var(--kit-faint, #858b94)', fontFamily:'var(--kit-font-mono, ui-monospace, monospace)' }}>{count}</span>}
    </span>
  );
}

function clapFilterStyle(active, dense = false){
  return { minHeight: dense ? 24 : 28, padding: dense ? '0 10px' : '0 12px',
    border:'1px solid transparent', borderRadius: 999, cursor:'pointer',
    fontFamily:'inherit', fontSize: dense ? 10.5 : 11, fontWeight: 650,
    whiteSpace:'nowrap', transition:'background var(--kit-motion-fast, 120ms), color var(--kit-motion-fast, 120ms), border-color var(--kit-motion-fast, 120ms)',
    background: active ? 'var(--kit-neutral-action, #111317)' : 'var(--kit-surface-2, #f8f8f6)',
    color: active ? '#fff' : 'var(--kit-sub, #626872)',
    borderColor: active ? 'var(--kit-neutral-action, #111317)' : 'var(--kit-hairline, #dfe1e4)' };
}

function CLAP_FilterPill({ active, onClick, children, dense = false, title, style, ...props }){
  return (
    <button type="button" aria-pressed={!!active} onClick={onClick} title={title}
      style={{ ...clapFilterStyle(active, dense), ...style }} {...props}>{children}</button>
  );
}

// Compact stat used everywhere
function Stat({ label, value, sub, accent, big }){
  return (
    <div style={{ display:'flex', flexDirection:'column', gap: 2 }}>
      <div style={{ fontSize: 10, letterSpacing:'.1em', textTransform:'uppercase', color:'rgba(22,17,14,.5)', fontWeight: 600 }}>{label}</div>
      <div style={{ fontFamily:'"JetBrains Mono", ui-monospace, monospace', fontSize: big ? 24 : 18, fontWeight: 600, color: accent || '#16110e', lineHeight: 1, letterSpacing:'-0.01em' }}>{value}</div>
      {sub && <div style={{ fontSize: 10, color:'rgba(22,17,14,.5)' }}>{sub}</div>}
    </div>
  );
}

// Stacked horizontal bar by category. items: [{cat, value}]
function StackBar({ items, height = 8, radius = 4, gap = 0, total }){
  const sum = (total ?? items.reduce((a,b)=>a+b.value,0)) || 1;
  return (
    <div style={{ display:'flex', width:'100%', height, borderRadius: radius, overflow:'hidden', background:'rgba(22,17,14,.05)', gap }}>
      {items.map((it, i) => it.value > 0 && (
        <div key={i} title={`${CATEGORIES[it.cat].label}: ${it.value}m`}
             style={{ flex: it.value, background: CATEGORIES[it.cat].ink, transition:'flex .2s' }} />
      ))}
    </div>
  );
}

// Half/full-court split chip
function CourtPill({ value, onChange, dense }){
  const opts = [['half','HC'],['full','FC'],['classroom','RM']];
  const size = dense ? { h: 18, fs: 10, pad: '0 6px' } : { h: 22, fs: 11, pad: '0 8px' };
  return (
    <div style={{ display:'inline-flex', background:'rgba(22,17,14,.06)', borderRadius: 999, padding: 2, gap: 0 }}>
      {opts.map(([k, label]) => (
        <button key={k}
          onClick={(e)=>{ e.stopPropagation(); onChange && onChange(k); }}
          onPointerDown={(e)=>e.stopPropagation()}
          style={{
            height: size.h, padding: size.pad, fontSize: size.fs, fontWeight: 600,
            border: 'none', cursor: 'pointer', borderRadius: 999,
            background: value === k ? '#16110e' : 'transparent',
            color: value === k ? '#f4ede2' : 'rgba(22,17,14,.55)',
            transition: 'background .15s, color .15s',
            fontFamily:'inherit',
          }}>
          {label}
        </button>
      ))}
    </div>
  );
}

// Number stepper input — minutes
function DurationStepper({ value, onChange, dense }){
  const [editing, setEditing] = React.useState(false);
  const inputRef = React.useRef(null);
  React.useEffect(()=>{ if (editing) inputRef.current && inputRef.current.select(); }, [editing]);
  const size = dense ? 18 : 22;
  const fs = dense ? 11 : 12;
  return (
    <div style={{ display:'inline-flex', alignItems:'center', gap: 0, height: size,
      border: '.5px solid rgba(22,17,14,.18)', borderRadius: 6, background: '#fbf7ed',
      fontFamily:'"JetBrains Mono", ui-monospace, monospace' }}>
      <button onPointerDown={(e)=>e.stopPropagation()}
        onClick={(e)=>{ e.stopPropagation(); onChange(Math.max(1, value - 1)); }}
        style={{ width: size, height: size, border:0, background:'transparent', cursor:'pointer', color:'rgba(22,17,14,.55)', fontSize: 14, lineHeight: 1, padding: 0, fontFamily:'inherit' }}>−</button>
      {editing ? (
        <input ref={inputRef} type="number" min={1} max={999} value={value}
          onChange={(e)=>onChange(Math.max(1, Number(e.target.value)||1))}
          onBlur={()=>setEditing(false)}
          onKeyDown={(e)=>{ if (e.key==='Enter') setEditing(false); }}
          style={{ width: 32, fontSize: fs, fontWeight: 600, textAlign:'center', border:0, background:'transparent', outline:'none', fontFamily:'inherit', color:'#16110e', padding: 0, MozAppearance:'textfield' }} />
      ) : (
        <span onClick={(e)=>{ e.stopPropagation(); setEditing(true); }}
              style={{ minWidth: 32, textAlign:'center', fontSize: fs, fontWeight: 600, cursor:'text', color:'#16110e', userSelect:'none' }}>
          {value}
        </span>
      )}
      <button onPointerDown={(e)=>e.stopPropagation()}
        onClick={(e)=>{ e.stopPropagation(); onChange(Math.min(999, value + 1)); }}
        style={{ width: size, height: size, border:0, background:'transparent', cursor:'pointer', color:'rgba(22,17,14,.55)', fontSize: 14, lineHeight: 1, padding: 0, fontFamily:'inherit' }}>+</button>
    </div>
  );
}

// Drill picker — popover. Returns chosen drill via onPick.
function DrillPicker({ onPick, onClose, anchor, drills, onCreateNew }){
  const [q, setQ] = React.useState('');
  const [activeCat, setActiveCat] = React.useState(null);
  const inputRef = React.useRef(null);
  const popRef = React.useRef(null);
  React.useEffect(()=>{ inputRef.current && inputRef.current.focus(); }, []);
  const sourceDrills = Array.isArray(drills) ? drills : DRILL_LIBRARY;
  const filtered = sourceDrills.filter((d) => {
    const catKey = normalizeDrillCat(d);
    if (activeCat && catKey !== activeCat) return false;
    if (q && !(`${d.name || ''} ${d.desc || d.breakdown || ''}`.toLowerCase().includes(q.toLowerCase()))) return false;
    return true;
  });

  React.useEffect(() => {
    const off = (e) => {
      // Capture-phase listener fires before React handlers, so a click on a
      // button INSIDE the picker would otherwise close it before onClick runs
      // (that broke "Create a new drill" and drill picks). Spare clicks inside
      // the popover itself, not just the anchor button.
      if (popRef.current && popRef.current.contains(e.target)) return;
      if (anchor && anchor.current && anchor.current.contains(e.target)) return;
      onClose && onClose();
    };
    // FU96 — Escape dismisses the picker and returns focus to its anchor.
    const onKey = (e)=>{
      if (e.key !== 'Escape' || e.defaultPrevented) return;
      onClose && onClose();
      const a = anchor && anchor.current;
      if (a && a.focus){ try { a.focus(); } catch(_){} }
    };
    setTimeout(()=>document.addEventListener('pointerdown', off, true), 0);
    document.addEventListener('keydown', onKey);
    return ()=>{ document.removeEventListener('pointerdown', off, true);
      document.removeEventListener('keydown', onKey); };
  }, [onClose, anchor]);

  return (
    <div ref={popRef} onPointerDown={(e)=>e.stopPropagation()}
      style={{ background:'var(--kit-surface, #fff)', borderRadius: 10,
        boxShadow:'var(--kit-shadow-popover, 0 12px 30px rgba(18,23,32,.14)), 0 0 0 .5px var(--kit-hairline, #dfe1e4)',
        width:'min(360px, calc(100vw - 24px))', padding: 10, fontFamily:'inherit' }}>
      <input ref={inputRef} value={q} onChange={(e)=>setQ(e.target.value)} placeholder="Search drills…"
        style={{ width:'100%', height: 32, padding:'0 10px', border:'1px solid var(--kit-hairline, #dfe1e4)', borderRadius: 8,
          background:'var(--kit-surface-2, #f8f8f6)', fontFamily:'inherit', fontSize: 12, outline:'none', boxSizing:'border-box', color:'var(--kit-text, #15171a)' }} />
      <div style={{ display:'flex', flexWrap:'wrap', gap: 4, marginTop: 8 }}>
        <CLAP_FilterPill dense active={activeCat == null} onClick={()=>setActiveCat(null)}>All</CLAP_FilterPill>
        {CAT_ORDER.map((k) => (
          <CLAP_FilterPill key={k} dense active={activeCat===k} onClick={()=>setActiveCat(activeCat===k?null:k)}>
            {CATEGORIES[k].label}
          </CLAP_FilterPill>
        ))}
      </div>
      {onCreateNew && (
        <button onClick={()=>onCreateNew(q)}
          style={{ display:'flex', width:'100%', alignItems:'center', gap: 8, marginTop: 8, padding:'8px 9px',
            border:'1px solid var(--kit-neutral-action, #111317)', background:'var(--kit-neutral-action, #111317)', cursor:'pointer',
            borderRadius: 8, fontFamily:'inherit', textAlign:'left', color:'#fff', fontSize: 12.5, fontWeight: 650 }}>
          <span style={{ fontSize: 15, lineHeight: 1 }}>+</span>
          Create a new drill{q.trim() ? ` "${q.trim()}"` : ''}
        </button>
      )}
      <div style={{ maxHeight: 260, overflowY:'auto', marginTop: 8, marginRight: -4, paddingRight: 4 }}>
        {filtered.length === 0 && (
          <div style={{ padding: '20px 8px', color:'rgba(22,17,14,.5)', fontSize: 12, textAlign:'center' }}>
            {onCreateNew ? 'No drills match — create one above.' : 'No drills match.'}
          </div>
        )}
        {filtered.map((d, i) => (
          <button key={i} onClick={()=>onPick({ ...d, id: 'b'+Math.random().toString(36).slice(2,8) })}
            style={{ display:'flex', width:'100%', alignItems:'center', gap: 10, padding:'7px 8px',
              border: 0, background:'transparent', cursor:'pointer', borderRadius: 6, fontFamily:'inherit',
              textAlign:'left' }}
            onMouseEnter={(e)=>e.currentTarget.style.background='rgba(22,17,14,.05)'}
            onMouseLeave={(e)=>e.currentTarget.style.background='transparent'}>
            <div style={{ flex: 1, minWidth: 0 }}>
              <div style={{ display:'flex', alignItems:'center', gap: 7, minWidth: 0 }}>
                <span style={{ minWidth: 0, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', fontSize: 12.5, fontWeight: 650, color:'var(--kit-text, #15171a)' }}>{d.name}</span>
                <CLAP_TaxonomyLabel dense label={CATEGORIES[normalizeDrillCat(d)].label} />
              </div>
              <div style={{ fontSize: 11, color:'var(--kit-sub, #626872)', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{d.desc || d.breakdown || ''}</div>
            </div>
            <div style={{ fontSize: 10.5, color:'var(--kit-faint, #858b94)', fontFamily:'var(--kit-font-mono, ui-monospace, monospace)', fontWeight: 600 }}>
              {d.dur}m · {d.court === 'half' ? 'HC' : d.court === 'full' ? 'FC' : 'RM'}
            </div>
          </button>
        ))}
      </div>
    </div>
  );
}

// Drag handle icon
function DragHandle({ color = 'rgba(22,17,14,.4)' }){
  return (
    <svg width="10" height="14" viewBox="0 0 10 14" fill={color}>
      <circle cx="2" cy="2" r="1.1"/><circle cx="8" cy="2" r="1.1"/>
      <circle cx="2" cy="7" r="1.1"/><circle cx="8" cy="7" r="1.1"/>
      <circle cx="2" cy="12" r="1.1"/><circle cx="8" cy="12" r="1.1"/>
    </svg>
  );
}

// CLA flag 035c1176 — dropdown menus anchored `position:absolute` inside the
// /app shell get clipped/hidden because the shell (and #root) are
// `overflow:hidden`. Render them in a document.body portal with fixed coords
// computed from the trigger's rect (the same pattern the working CatPicker in
// vocabulary.jsx uses). Handles outside-click, scroll-close, and resize.
function ClaPortalMenu({ open, onClose, anchorRef, align = 'right', width, zIndex = 9990, children, style }){
  const [pos, setPos] = React.useState(null);
  const menuRef = React.useRef(null);
  const place = React.useCallback(()=>{
    const el = anchorRef && anchorRef.current; if (!el) return;
    const r = el.getBoundingClientRect();
    setPos(align === 'left'
      ? { top: r.bottom + 6, left: r.left, right: undefined }
      : { top: r.bottom + 6, left: undefined, right: Math.max(8, window.innerWidth - r.right) });
  }, [anchorRef, align]);
  React.useLayoutEffect(()=>{ if (open) place(); }, [open, place]);
  React.useEffect(()=>{
    if (!open) return;
    const onDoc = (e)=>{
      if (menuRef.current && menuRef.current.contains(e.target)) return;
      if (anchorRef && anchorRef.current && anchorRef.current.contains(e.target)) return;
      onClose && onClose();
    };
    const onScroll = ()=>{ onClose && onClose(); };
    const onResize = ()=>place();
    // FU96 — every portal menu dismisses on Escape and hands focus back to the
    // control that opened it (menus are transient surfaces, not destinations).
    const onKey = (e)=>{
      if (e.key !== 'Escape' || e.defaultPrevented) return;
      onClose && onClose();
      const a = anchorRef && anchorRef.current;
      if (a && a.focus){ try { a.focus(); } catch(_){} }
    };
    document.addEventListener('pointerdown', onDoc, true);
    document.addEventListener('keydown', onKey);
    window.addEventListener('scroll', onScroll, true);
    window.addEventListener('resize', onResize);
    return ()=>{ document.removeEventListener('pointerdown', onDoc, true);
      document.removeEventListener('keydown', onKey);
      window.removeEventListener('scroll', onScroll, true); window.removeEventListener('resize', onResize); };
  }, [open, onClose, place, anchorRef]);
  if (!open || !pos || typeof ReactDOM === 'undefined' || !ReactDOM.createPortal) return null;
  return ReactDOM.createPortal(
    <div ref={menuRef} style={{ position:'fixed', top: pos.top, left: pos.left, right: pos.right, zIndex,
      ...(width ? { width } : {}), background:'#fff', borderRadius: 11, padding: 5,
      boxShadow:'0 12px 34px rgba(0,0,0,.18), 0 0 0 .5px rgba(0,0,0,.1)', ...(style||{}) }}>
      {children}
    </div>,
    document.body
  );
}

// ── named groups inside a segment ("teams in the segment") ───────────
// block.groups / block.support are free multiline text — one line per unit,
// usually "LABEL: names" (e.g. "BLUE: Rob, Collin" or "JB: Nate, Bruce").
// Rendered as chips on the drill row (planner-v6-sequence) and as lines in
// the print sheet's Teams/Staff column (print-v6). Dot color keys off a
// leading squad label; coach-initial groups get no dot.
const V6_GROUP_DOT_COLORS = { BLUE:'#1d4f9e', WHITE:'#9099a8', SCOUT:'#d97c3a', ORANGE:'#d97c3a', GREEN:'#3d8a4f', RED:'#c0492c' };
function v6GroupLines(text){ return String(text || '').split('\n').map((s)=>s.trim()).filter(Boolean); }
function v6GroupDot(line, allTeams){
  const label = ((line.split(':')[0] || '').trim().toUpperCase());
  const t = (allTeams || []).find((tm)=> tm.label && label.startsWith(tm.label.toUpperCase()));
  return (t && t.color) || V6_GROUP_DOT_COLORS[label] || null;
}

Object.assign(window, {
  ClaPortalMenu,
  v6GroupLines, v6GroupDot,
  CATEGORIES, CAT_ORDER, DRILL_LIBRARY, DEFAULT_PRACTICE, ROSTER, AVAILABILITY,
  fmtTime, fmtTimeShort, computeBlocks, totals, isLive,
  BLOCK_STRUCTURES, structureLabel, structureConcurrentAfter,
  useVerticalReorder,
  CategoryDot, CategoryChip, CLAP_TaxonomyLabel, CLAP_FilterPill, clapFilterStyle,
  Stat, StackBar, CourtPill, DurationStepper, DrillPicker, DragHandle,
  DRILL_END_MODES, normalizeEndMode, endModeMeta, basePlayerCount, effectivePlayerCount,
  drillMinutes, timeOnTask, totPercent, avgTotPercent, drillPlayerLabel, practiceTOT,
  DRILL_COURTS, courtMultiplier, PLAYERS_ALL, isAllPlayers, resolveRosterSize,
  v5ViewPref, v5SetViewPref, v5ParsePracticeDate, v5IsPastPractice,
  v5SnapshotViewPrefs, v5PushViewPrefs, v5HydrateViewPrefs,
});
