/** * 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 ExercisePeekModal from '../components/ExercisePeekModal' 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 formatClock(totalSec) { const m = Math.floor(totalSec / 60) const s = totalSec % 60 return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` } function statusLabel(s) { if (s === 'completed') return 'Durchgeführt' if (s === 'cancelled') return 'Abgesagt' return 'Geplant' } function mergeDelta(setDeltas, itemKey, patch) { setDeltas((prev) => ({ ...prev, [itemKey]: { ...prev[itemKey], ...patch } })) } 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 [peekId, setPeekId] = useState(null) const [outlineOpen, setOutlineOpen] = useState(false) const [debriefOpen, setDebriefOpen] = useState(false) const [step, setStep] = useState(0) const [deltas, setDeltas] = useState({}) const [runStartAt, setRunStartAt] = useState(null) const [pausedAccumMs, setPausedAccumMs] = useState(0) 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 */ } } 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(() => { 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 } setStep((prev) => clampStep(prev, timeline.length)) }, [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 timerStart = () => { setRunStartAt(Date.now()) } const timerPause = () => { if (runStartAt != null) { setPausedAccumMs((a) => a + (Date.now() - runStartAt)) setRunStartAt(null) } } const timerReset = () => { setRunStartAt(null) setPausedAccumMs(0) } const applySuggestedDuration = () => { if (!currentEntry?.item || currentEntry.item.item_type !== 'exercise') return const key = itemStableKey(currentEntry.item, currentEntry.secOrder, currentEntry.ii) const min = Math.max(1, Math.round(elapsedMs / 60000)) || null if (min != null) mergeDelta(setDeltas, key, { actual_duration_min: min }) timerPause() } const goPrev = () => setStep((s) => clampStep(s - 1)) const goNext = () => setStep((s) => clampStep(s + 1)) const markCurrentDoneAdvance = () => { timerPause() if (step < timeline.length - 1) setStep((s) => s + 1) } 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]) const handleSaveDebrief = async () => { setSaveOk(null) setSaving(true) try { const sectionsPayload = sectionsToPutPayload(unit, durationOverridesForApi) const tn = trainerAppend.trim() const mergedTrainer = tn ? [unit.trainer_notes || '', `--- (${new Date().toLocaleString('de-DE')}) ---`, tn].filter(Boolean).join('\n') : unit.trainer_notes await api.updateTrainingUnit(idNum, { trainer_notes: mergedTrainer.trim() ? mergedTrainer.trim() : unit.trainer_notes, ...(saveMarkDone ? { status: 'completed' } : {}), sections: sectionsPayload, }) await reloadUnit() setTrainerAppend('') try { sessionStorage.removeItem(storageDeltasKey(idNum)) } catch { /* ignore */ } setDeltas({}) 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 (
setPeekId(null)} titleFallback={null} />
Im Training · Coach

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

{unit.group_name && ( Gruppe: {unit.group_name} )} Status: {statusLabel(unit.status)} Schritt {(step || 0) + 1} / {Math.max(timeline.length, 1)}
{outlineOpen && (
Ablauf (Antippen springt zum Schritt)
{timeline.map((ent, ix) => { const lbl = summarizeTimelineEntry(ent) const secTitle = ent.sec.title || `Abschnitt ${ent.si + 1}` const active = ix === step return ( ) })}
)} {timeline.length === 0 ? (

Dieser Plan ist leer.{' '} Unter Planung ergänzen.

) : ( <>
Assistenz · als Nächstes
{nextEntry ? ( <>

Nächste: {summarizeTimelineEntry(nextEntry)}

{next2Entry && (

Daraufhin: {summarizeTimelineEntry(next2Entry)}

)} ) : (

Dies war der letzte Eintrag.
Gute Arbeit — unten kannst du notieren und Ist-Zeiten speichern.

)}
{currentEntry && (
{currentEntry.sec.title || 'Abschnitt'} · Teil {step + 1}
{currentEntry.item.item_type === 'note' && (
Coach-Notiz

{currentEntry.item.note_body || ''}

)} {currentEntry.item.item_type !== 'note' && ( <>

{currentEntry.item.exercise_title || (currentEntry.item.exercise_id ? `Übung #${currentEntry.item.exercise_id}` : 'Übung')} {currentEntry.item.exercise_variant_name ? ( ({currentEntry.item.exercise_variant_name}) ) : null}

geplant {currentEntry.item.planned_duration_min ?? '—'} Min · Ist (Plan/Bearbeitung):{' '} {durationOverridesForApi[String(currentEntry.item.id)] != null ? durationOverridesForApi[String(currentEntry.item.id)].actual_duration_min : currentEntry.item.actual_duration_min ?? '—'}{' '} Min {durationOverridesForApi[String(currentEntry.item.id)] != null ? ' (Entwurf)' : ''}

{currentEntry.item.exercise_focus_area && (

Bereich: {currentEntry.item.exercise_focus_area}

)} {currentEntry.item.notes ? (
Zu dieser Platzierung

{currentEntry.item.notes}

) : null} {currentEntry.item.modifications ? (
Anpassung / Variante

{currentEntry.item.modifications}

) : null} {currentEntry.item.exercise_id ? ( ) : null} )}
)}
Zeitnahme für diesen Punkt
{formatClock(tickDisplaySec)}
{!runStartAt ? ( ) : ( )}

Übernimmt die gemessene Zeit (auf volle Minuten gerundet) als Ist‑Minuten für dieses Element und kann später mit „Nachbereitung“ auf dem Server gespeichert werden.

{unit.trainer_notes && (
Trainernotiz (dieser Einheit)

{unit.trainer_notes}

)}
{debriefOpen && ( <>

Übertragene Ist‑Minuten (Entwürfe aus dem Timer oder hier anpassen). Beim Speichern werden sie mit dem Plan an den Server geschickt.

{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 ( ) })}