/** * Trainingsablauf anzeigen, drucken und lokal auf der Matte abhaken (Fortschritt im Browser gespeichert). * Phasen: Ganzgruppe vs. Split (planLoc); Druck mit optional getrennten Breakout-Seiten. */ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import api from '../utils/api' import ExercisePeekModal from '../components/ExercisePeekModal' import CombinationPlanBracket from '../components/CombinationPlanBracket' import { buildPlanRunViewModelFromSections, itemStableKey, sectionsWithPlanLocForDisplay, sortedItems, } from '../utils/trainingPlanUtils' import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile' function storageKey(unitId) { return `sj_training_run_checked_${unitId}` } function formatMin(m) { if (m === null || m === undefined || m === '') return null const n = Number(m) if (!Number.isFinite(n)) return null return `${n} Min.` } function statusLabel(s) { if (s === 'completed') return 'Durchgeführt' if (s === 'cancelled') return 'Abgesagt' return 'Geplant' } export default function TrainingUnitRunPage() { const { unitId } = useParams() const navigate = useNavigate() const idNum = unitId ? parseInt(unitId, 10) : NaN const [unit, setUnit] = useState(null) const [loadError, setLoadError] = useState(null) const [loading, setLoading] = useState(true) const [checked, setChecked] = useState(() => new Set()) const [peekCtx, setPeekCtx] = useState(null) /** null | printStreamId z. B. p0-s1 — nur dieser Split-Stream in @media print */ const [printOnlyStreamId, setPrintOnlyStreamId] = useState(null) const loadChecked = useCallback((uid) => { try { const raw = sessionStorage.getItem(storageKey(uid)) if (!raw) return new Set() const arr = JSON.parse(raw) if (!Array.isArray(arr)) return new Set() return new Set(arr.map(String)) } catch { return new Set() } }, []) useEffect(() => { if (!unitId || Number.isNaN(idNum)) { setLoadError('Ungültige Trainingseinheit') setLoading(false) return } let cancelled = false ;(async () => { setLoading(true) setLoadError(null) try { const u = await api.getTrainingUnit(idNum) if (!cancelled) { setUnit(u) setChecked(loadChecked(idNum)) } } catch (e) { if (!cancelled) setLoadError(e.message || 'Laden fehlgeschlagen') } finally { if (!cancelled) setLoading(false) } })() return () => { cancelled = true } }, [unitId, idNum, loadChecked]) const persistChecked = useCallback( (next) => { setChecked(next) try { sessionStorage.setItem(storageKey(idNum), JSON.stringify([...next])) } catch { /* ignore quota */ } }, [idNum] ) const toggle = useCallback( (key) => { const next = new Set(checked) if (next.has(key)) next.delete(key) else next.add(key) persistChecked(next) }, [checked, persistChecked] ) const clearProgress = useCallback(() => { persistChecked(new Set()) try { sessionStorage.removeItem(storageKey(idNum)) } catch { /* ignore */ } }, [idNum, persistChecked]) const sections = useMemo(() => sectionsWithPlanLocForDisplay(unit), [unit]) const planModel = useMemo(() => buildPlanRunViewModelFromSections(sections), [sections]) const printStreamOptions = useMemo(() => { const opts = [] for (const run of planModel.runs) { if (run.kind !== 'parallel' || !run.streams) continue for (const st of run.streams) { opts.push({ id: st.printStreamId, label: `${run.phaseTitle ? String(run.phaseTitle).trim().slice(0, 28) : `Phase ${run.phaseOrderIndex}`} · ${st.streamTitle ? String(st.streamTitle).trim() : `Gruppe ${st.streamOrder + 1}`}`, }) } } return opts }, [planModel.runs]) const totalPlannedMin = planModel.totalMin const showWholeGroupInView = !printOnlyStreamId const showStreamColumn = (streamPrintId) => !printOnlyStreamId || streamPrintId === printOnlyStreamId const renderSectionCard = (sec, siInUnit) => { const secOrder = sec.order_index ?? siInUnit const items = sortedItems(sec) return (

{sec.title || `Abschnitt ${siInUnit + 1}`}

{sec.guidance_notes && (

{sec.guidance_notes}

)}
) } if (loading) { return (

Plan wird geladen…

) } if (loadError || !unit) { return (

{loadError || 'Trainingseinheit nicht gefunden.'}

) } return (
setPeekCtx(null)} />

Training {unit.planned_date && ` · ${unit.planned_date}`} {unit.planned_time_start && ` · ${String(unit.planned_time_start).slice(0, 5)}`} {unit.planned_time_end && `–${String(unit.planned_time_end).slice(0, 5)}`}

{unit.group_name && ( Gruppe: {unit.group_name} {unit.club_name && ` (${unit.club_name})`} )} {unit.group_location && ( Ort: {unit.group_location} )} {unit.planned_focus && ( Fokus: {unit.planned_focus} )} Status: {statusLabel(unit.status)} {totalPlannedMin > 0 && ( Geplante Zeit (Übungen, gesamt): ca. {totalPlannedMin} Min. )}
{printOnlyStreamId ? (
Vorschau wie fürs Drucken: nur die gewählte Split-Gruppe. Ganzgruppen-Blöcke sind ausgeblendet.
) : null} {planModel.mode === 'phased' && planModel.runs.length > 0 && showWholeGroupInView && (
Zeitplanung (Summe Übungsminuten) {planModel.runs.map((run, ri) => { const cum = planModel.runCumulativeEnds[ri] const label = run.phaseTitle != null && String(run.phaseTitle).trim() ? String(run.phaseTitle).trim() : run.kind === 'legacy' ? 'Ablauf' : run.kind === 'whole_group' ? `Ganzgruppe (Phase ${run.phaseOrderIndex})` : `Parallel (Phase ${run.phaseOrderIndex})` return ( ) })}
Block Art Min ∑ bis hier
{label} {run.kind === 'parallel' ? 'Split' : run.kind === 'legacy' ? '—' : 'Ganzgruppe'} {run.minutes || '—'} {cum}
{unit.planned_time_start && (

Hinweis: Startzeit oben im Kopf; Minuten sind aus den Übungseinplanungen — ohne Pausen und ohne automatische Uhrzeitliste.

)}
)} {unit.notes && (
Hinweis Teilnehmer: {unit.notes}
)}
{sections.length === 0 ? (

Noch keine Abschnitte in diesem Plan. Unter Planung bearbeiten.

) : (
{planModel.runs.map((run, runIdx) => { if (run.kind === 'parallel') { const visibleStreams = run.streams.filter((st) => showStreamColumn(st.printStreamId)) if (!visibleStreams.length) return null return (
PARALLELE PHASE
{run.phaseTitle ? String(run.phaseTitle).trim() : `Phase ${run.phaseOrderIndex}`}
{printOnlyStreamId ? `Auszug eine Gruppe · ca. ${visibleStreams[0]?.minutes ?? run.minutes} Min. (Üb.)` : `Geplante Übungszeit (gesamt): ca. ${run.minutes} Min. · Jede Spalte kann separat gedruckt werden (Dropdown oder Seitenumbruch).`}
{visibleStreams.map((st, streamIdx) => { const siUnit = (sec) => Math.max(0, sections.indexOf(sec)) return (
0 ? ' training-run-breakout-stream--page-break' : '') } data-print-id={st.printStreamId} >
{st.streamTitle ? String(st.streamTitle).trim() : `Gruppe ${st.streamOrder + 1}`} · ca. {st.minutes} Min. (Üb.) Coach · Split-Punkt (Vorschlag)
{st.sections.map((sec) => renderSectionCard(sec, siUnit(sec)))}
) })}
) } if (!showWholeGroupInView) return null return (
{run.kind !== 'legacy' ? (
GANZGRUPPE
{run.phaseTitle ? String(run.phaseTitle).trim() : `Phase ${run.phaseOrderIndex}`}
Geplante Übungszeit: ca. {run.minutes} Min.
) : null}
{run.sections.map((sec) => renderSectionCard(sec, Math.max(0, sections.indexOf(sec))))}
) })}
)} {unit.trainer_notes && (
Nur Trainer

{unit.trainer_notes}

)}
) }