/**
* 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?.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 (
)
}
if (loadError || !unit) {
return (
{loadError || 'Nicht gefunden.'}
)
}
return (
{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 (
)
})}
{saveOk && (
{saveOk}
)}
{unit.trainer_notes ? (
Einheit · Trainernotizen
{unit.trainer_notes}
) : null}
) : (
<>
Als Nächstes
{nextEntry ? (
<>
Nächste: {summarizeTimelineEntry(nextEntry)}
{next2Entry && (
Daraufhin: {summarizeTimelineEntry(next2Entry)}
)}
>
) : (
Letzter Punkt — „Nachbereitung & Ist-Zeit“ öffnet die Abschlussseite zum Speichern.
)}
setStep(timerOwningStep ?? step)}
timerOwnerLabelIndex={timerOwningStep ?? 0}
/>
{currentEntry?.item?.item_type === 'note' ? (
{currentEntry.sec.title || 'Abschnitt'} · Coach-Notiz · Teil {step + 1}
Coach-Notiz
{currentEntry.item.note_body || ''}
) : (
<>
In diesem Training · {currentEntry?.sec.title || 'Abschnitt'} · Teil {step + 1}
{currentEntry?.item && (
<>
{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):{' '}
{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 ? (
{currentEntry.item.exercise_focus_area}
) : null}
{currentEntry.item.notes ? (
Zu dieser Platzierung
{currentEntry.item.notes}
) : null}
{currentEntry.item.modifications ? (
Anpassung
{currentEntry.item.modifications}
) : null}
>
)}
Übung aus dem Katalog (vollständig)
>
)}
{unit.trainer_notes ? (
Einheit · Trainernotizen
{unit.trainer_notes}
) : null}
{debriefOpen && (
<>
Ist‑Minuten anpassen und Einheit speichern (inkl. Trainer-Ergänzung).
{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 (
)
})}
{saveOk && (
{saveOk}
)}
>
)}
setStep(timerOwningStep ?? step)}
timerOwnerLabelIndex={timerOwningStep ?? 0}
/>
>
)}
)
}