/** * Coach-Modus: eine Position nach der anderen mit Assistentenhinweisen, Zeitnahme und optionaler Nachbereitung. */ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import api from '../utils/api' import ExerciseFullContent from '../components/ExerciseFullContent' import { flattenPlanTimeline, itemStableKey, sectionsToPutPayload, summarizeTimelineEntry, } from '../utils/trainingPlanUtils' function storageStepKey(unitId) { return `sj_coach_step_${unitId}` } function storageDeltasKey(unitId) { return `sj_coach_deltas_${unitId}` } function storageDebriefKey(unitId) { return `sj_coach_debrief_${unitId}` } function formatClock(totalSec) { const m = Math.floor(totalSec / 60) const s = totalSec % 60 return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` } function mergeDelta(setDeltas, itemKey, patch) { setDeltas((prev) => ({ ...prev, [itemKey]: { ...prev[itemKey], ...patch } })) } function CoachControlsBand({ step, timelineLength, onPrev, onNext, onDone, clockStr, runStartAt, pausedAccumMs, onTimerStart, onTimerPause, onTimerReset, onApplyActual, roundedMinForApply, isLastCoachStep, showJumpToTimerOwner, showJumpToTimerOwnerRow = true, onJumpToTimerOwner, timerOwnerLabelIndex, }) { const disPrev = step <= 0 const disNext = step >= timelineLength - 1 const alive = runStartAt != null || pausedAccumMs > 0 const canApplyForOwner = roundedMinForApply != null && roundedMinForApply >= 1 const istLabelMin = roundedMinForApply == null ? '—' : String(roundedMinForApply) const doneLabel = timelineLength <= 1 ? 'Nachbereitung öffnen' : isLastCoachStep ? 'Nachbereitung & Ist-Zeit' : 'Abgeschlossen & weiter' return (
{showJumpToTimerOwner && showJumpToTimerOwnerRow && (
)}
{runStartAt == null ? ( ) : ( )} {alive && ( )}
) } export default function TrainingCoachPage() { 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 [outlineOpen, setOutlineOpen] = useState(false) const [catalogExercise, setCatalogExercise] = useState(null) const [catalogLoading, setCatalogLoading] = useState(false) const [catalogError, setCatalogError] = useState(null) const [debriefOpen, setDebriefOpen] = useState(false) const [coachDebriefPhase, setCoachDebriefPhase] = useState(false) const [step, setStep] = useState(0) const [deltas, setDeltas] = useState({}) const [runStartAt, setRunStartAt] = useState(null) const [pausedAccumMs, setPausedAccumMs] = useState(0) const [timerOwningStep, setTimerOwningStep] = useState(null) const [, setPulse] = useState(0) const [trainerAppend, setTrainerAppend] = useState('') const [saveMarkDone, setSaveMarkDone] = useState(true) const [saving, setSaving] = useState(false) const [saveOk, setSaveOk] = useState(null) const reloadUnit = useCallback(async () => { const u = await api.getTrainingUnit(idNum) setUnit(u) }, [idNum]) useEffect(() => { if (!unitId || Number.isNaN(idNum)) { setLoadError('Ungültige Trainingseinheit') setLoading(false) return } let cancelled = false ;(async () => { setLoading(true) setLoadError(null) try { await reloadUnit() if (cancelled) return try { const s = parseInt(sessionStorage.getItem(storageStepKey(idNum)), 10) if (!Number.isNaN(s) && s >= 0) setStep(s) } catch { /* ignore */ } try { const raw = sessionStorage.getItem(storageDeltasKey(idNum)) if (raw) { const o = JSON.parse(raw) if (o && typeof o === 'object') setDeltas(o) } } catch { /* ignore */ } try { if (sessionStorage.getItem(storageDebriefKey(idNum)) === '1') setCoachDebriefPhase(true) } catch { /* ignore */ } } catch (e) { if (!cancelled) setLoadError(e.message || 'Laden fehlgeschlagen') } finally { if (!cancelled) setLoading(false) } })() return () => { cancelled = true } }, [unitId, idNum, reloadUnit]) useEffect(() => { sessionStorage.setItem(storageStepKey(idNum), String(step)) }, [idNum, step]) useEffect(() => { try { sessionStorage.setItem(storageDeltasKey(idNum), JSON.stringify(deltas)) } catch { /* quota */ } }, [idNum, deltas]) useEffect(() => { try { sessionStorage.setItem(storageDebriefKey(idNum), coachDebriefPhase ? '1' : '0') } catch { /* quota */ } }, [idNum, coachDebriefPhase]) useEffect(() => { if (runStartAt == null) return undefined const iv = setInterval(() => setPulse((p) => p + 1), 380) return () => clearInterval(iv) }, [runStartAt]) const timeline = useMemo(() => flattenPlanTimeline(unit), [unit]) const clampStep = (s, len = timeline.length) => Math.max(0, Math.min(s, Math.max(len - 1, 0))) useEffect(() => { if (!unit) return if (timeline.length === 0) { setStep(0) return } if (coachDebriefPhase) return setStep((prev) => clampStep(prev, timeline.length)) }, [unit, timeline.length, coachDebriefPhase]) useEffect(() => { if (!coachDebriefPhase || !unit || timeline.length === 0) return setStep(timeline.length - 1) }, [coachDebriefPhase, unit, timeline.length]) const elapsedMs = pausedAccumMs + (runStartAt != null ? Date.now() - runStartAt : 0) const tickDisplaySec = Math.max(0, Math.floor(elapsedMs / 1000)) const currentEntry = timeline[step] const nextEntry = timeline[step + 1] || null const next2Entry = timeline[step + 2] || null const clockStr = formatClock(tickDisplaySec) const roundedMinApply = elapsedMs <= 650 ? null : Math.max(1, Math.round(elapsedMs / 60000)) const showJumpToTimerOwner = timerOwningStep != null && step !== timerOwningStep && (runStartAt != null || pausedAccumMs > 0) const isLastCoachStep = timeline.length > 0 && step >= timeline.length - 1 const timerStart = () => { setRunStartAt(Date.now()) setTimerOwningStep(step) } const timerPause = () => { if (runStartAt != null) { setPausedAccumMs((a) => a + (Date.now() - runStartAt)) setRunStartAt(null) } } const timerReset = () => { setRunStartAt(null) setPausedAccumMs(0) setTimerOwningStep(null) } const applySuggestedDuration = () => { const idx = timerOwningStep != null ? timerOwningStep : step const ent = timeline[idx] const item = ent?.item if (!item || item.item_type !== 'exercise') return if (elapsedMs <= 650) return const min = Math.max(1, Math.round(elapsedMs / 60000)) const key = itemStableKey(item, ent.secOrder, ent.ii) mergeDelta(setDeltas, key, { actual_duration_min: min }) timerPause() } const goPrev = () => setStep((s) => clampStep(s - 1)) const goNext = () => setStep((s) => clampStep(s + 1)) const markCurrentDoneAdvance = () => { const ownerIdx = timerOwningStep != null ? timerOwningStep : step const ent = timeline[ownerIdx] const item = ent?.item if (item?.item_type === 'exercise' && elapsedMs > 650) { const key = itemStableKey(item, ent.secOrder, ent.ii) const min = Math.max(1, Math.round(elapsedMs / 60000)) mergeDelta(setDeltas, key, { actual_duration_min: min }) } timerReset() const lastIdx = timeline.length - 1 if (step >= lastIdx && lastIdx >= 0) { setCoachDebriefPhase(true) try { sessionStorage.setItem(storageDebriefKey(idNum), '1') } catch { /* ignore */ } return } setStep((s) => clampStep(s + 1, timeline.length)) } const durationOverridesForApi = useMemo(() => { const out = {} for (let i = 0; i < timeline.length; i++) { const ent = timeline[i] const { item } = ent if (item.item_type !== 'exercise' || item.id == null) continue const k = itemStableKey(item, ent.secOrder, ent.ii) const dv = deltas[k]?.actual_duration_min if (dv !== undefined && dv !== '' && dv !== null && !Number.isNaN(Number(dv))) { out[String(item.id)] = { actual_duration_min: Number(dv) } } } return out }, [timeline, deltas]) useEffect(() => { const item = currentEntry?.item if (!item || item.item_type === 'note') { setCatalogExercise(null) setCatalogError(null) setCatalogLoading(false) return } const eid = item.exercise_id if (!eid) { setCatalogExercise(null) setCatalogError(null) setCatalogLoading(false) return } let cancelled = false setCatalogLoading(true) setCatalogError(null) setCatalogExercise(null) api.getExercise(eid) .then((ex) => { if (!cancelled) { setCatalogExercise(ex) setCatalogLoading(false) } }) .catch((err) => { if (!cancelled) { setCatalogError(err.message || String(err)) setCatalogExercise(null) setCatalogLoading(false) } }) return () => { cancelled = true } }, [step, currentEntry?.item?.exercise_id, currentEntry?.item?.exercise_variant_id, currentEntry?.item?.item_type]) const handleSaveDebrief = async () => { setSaveOk(null) setSaving(true) try { const sectionsPayload = sectionsToPutPayload(unit, durationOverridesForApi) const tn = trainerAppend.trim() const payload = { sections: sectionsPayload, ...(saveMarkDone ? { status: 'completed' } : {}), } if (tn) { payload.trainer_notes = [unit.trainer_notes || '', `--- (${new Date().toLocaleString('de-DE')}) ---`, tn] .filter(Boolean) .join('\n') .trim() } await api.updateTrainingUnit(idNum, payload) await reloadUnit() setTrainerAppend('') try { sessionStorage.removeItem(storageDeltasKey(idNum)) sessionStorage.removeItem(storageDebriefKey(idNum)) } catch { /* ignore */ } setDeltas({}) setCoachDebriefPhase(false) setSaveOk('Gespeichert.') setDebriefOpen(false) } catch (e) { setSaveOk(`Fehler: ${e.message || e}`) } finally { setSaving(false) } } if (loading) { return (

Coach laden…

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

{loadError || 'Nicht gefunden.'}

) } return (
Coach ·{' '} {coachDebriefPhase ? 'Nachbereitung · abschließend speichern' : `Schritt ${(step || 0) + 1} / ${Math.max(timeline.length, 1)}`}

{unit.planned_date} {unit.planned_time_start && ` · ${String(unit.planned_time_start).slice(0, 5)}`} {unit.planned_focus ? ` · ${unit.planned_focus}` : ''}

{outlineOpen && (
Ablauf · Antippen zum Springen
{timeline.map((ent, ix) => { const lbl = summarizeTimelineEntry(ent) const secTitle = ent.sec.title || `Abschnitt ${ent.si + 1}` const active = coachDebriefPhase ? ix === timeline.length - 1 : ix === step return ( ) })}
)} {timeline.length === 0 ? (

Dieser Plan ist leer. Unter Planung ergänzen.

) : coachDebriefPhase ? (
Nachbereitung

Ist‑Minuten prüfen, Trainer‑Ergänzung anlegen und Einheit sichern (zeigt bestehende Notizen weiter unten an).

{timeline .filter((e) => e.item.item_type === 'exercise') .map((ent) => { const k = itemStableKey(ent.item, ent.secOrder, ent.ii) const val = deltas[k]?.actual_duration_min ?? ent.item.actual_duration_min ?? '' return ( ) })}