diff --git a/frontend/src/pages/TrainingCoachPage.jsx b/frontend/src/pages/TrainingCoachPage.jsx index c855ba1..48b3084 100644 --- a/frontend/src/pages/TrainingCoachPage.jsx +++ b/frontend/src/pages/TrainingCoachPage.jsx @@ -1,16 +1,18 @@ /** * Coach-Modus: Schrittfolge mit Split-Punkten (branch_gate), Stream-Wahl pro paralleler Phase, Assistenz und Zeitnahme. */ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom' import api from '../utils/api' import ExerciseFullContent from '../components/ExerciseFullContent' import ExercisePeekModal from '../components/ExercisePeekModal' import { COACH_ENTRY_BRANCH_GATE, + buildCoachSavePlanPayload, coachBranchPicksStepStorageSuffix, coachBranchPicksStorageKey, coachOutlineGroupsFromTimeline, + coachShouldPromptSplitRejoin, durationOverridesMapFromDeltas, findCoachTimelineJumpIndexForPhase, flattenPlanTimeline, @@ -18,7 +20,6 @@ import { listCoachStreamFocusOptions, mergeCoachBranchPicksWithUrlFocus, normalizeCoachBranchPicks, - sectionsToPutPayload, summarizeTimelineEntry, } from '../utils/trainingPlanUtils' @@ -194,6 +195,8 @@ export default function TrainingCoachPage() { const [timerOwningStep, setTimerOwningStep] = useState(null) const [, setPulse] = useState(0) + const [splitRejoinPrompt, setSplitRejoinPrompt] = useState(null) + const [trainerAppend, setTrainerAppend] = useState('') const [saveMarkDone, setSaveMarkDone] = useState(true) const [saving, setSaving] = useState(false) @@ -398,15 +401,13 @@ export default function TrainingCoachPage() { setStep(0) return } - if (coachDebriefPhase) return - setStep((prev) => clampStep(prev, timeline.length)) + if (coachDebriefPhase) { + setStep(timeline.length - 1) + } else { + 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]) - useEffect(() => { if (!unit || Number.isNaN(idNum)) return if (timeline.length === 0) { @@ -419,6 +420,7 @@ export default function TrainingCoachPage() { } else if (prev !== navigationKey) { coachFocusResetRef.current = navigationKey setCoachDebriefPhase(false) + setSplitRejoinPrompt(null) timerReset() } else { return @@ -439,24 +441,36 @@ export default function TrainingCoachPage() { 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 safeStep = useMemo(() => { + if (!timeline.length) return 0 + return Math.min(Math.max(0, step), timeline.length - 1) + }, [step, timeline.length]) + + useLayoutEffect(() => { + if (!timeline.length) return + const max = timeline.length - 1 + const s = Math.min(Math.max(0, step), max) + if (s !== step) setStep(s) + }, [step, timeline.length]) + + const currentEntry = timeline[safeStep] ?? null + const nextEntry = timeline[safeStep + 1] || null + const next2Entry = timeline[safeStep + 2] || null const clockStr = formatClock(tickDisplaySec) const roundedMinApply = elapsedMs <= 650 ? null : Math.max(1, Math.round(elapsedMs / 60000)) const showJumpToTimerOwner = timerOwningStep != null && - step !== timerOwningStep && + safeStep !== timerOwningStep && (runStartAt != null || pausedAccumMs > 0) const isLastCoachStep = timeline.length > 0 && - step >= timeline.length - 1 && + safeStep >= timeline.length - 1 && currentEntry?.entryKind !== COACH_ENTRY_BRANCH_GATE const timerStart = () => { setRunStartAt(Date.now()) - setTimerOwningStep(step) + setTimerOwningStep(safeStep) } const timerPause = () => { @@ -467,7 +481,7 @@ export default function TrainingCoachPage() { } const applySuggestedDuration = () => { - const idx = timerOwningStep != null ? timerOwningStep : step + const idx = timerOwningStep != null ? timerOwningStep : safeStep const ent = timeline[idx] const item = ent?.item if (!item || item.item_type !== 'exercise') return @@ -482,6 +496,7 @@ export default function TrainingCoachPage() { (phaseOrder, streamOrder) => { if (!Number.isFinite(phaseOrder) || !Number.isFinite(streamOrder)) return setStreamChoiceHint(null) + setSplitRejoinPrompt(null) setBranchPicks((prev) => ({ ...normalizeCoachBranchPicks(prev), [phaseOrder]: streamOrder })) timerReset() setCoachDebriefPhase(false) @@ -496,7 +511,7 @@ export default function TrainingCoachPage() { const goNext = () => setStep((s) => clampStep(s + 1)) const markCurrentDoneAdvance = () => { - const ownerIdx = timerOwningStep != null ? timerOwningStep : step + const ownerIdx = timerOwningStep != null ? timerOwningStep : safeStep const ent = timeline[ownerIdx] if (ent?.entryKind === COACH_ENTRY_BRANCH_GATE) return const item = ent?.item @@ -508,7 +523,12 @@ export default function TrainingCoachPage() { timerReset() const lastIdx = timeline.length - 1 - if (step >= lastIdx && lastIdx >= 0) { + if (safeStep >= lastIdx && lastIdx >= 0) { + const rejoin = coachShouldPromptSplitRejoin(unit, timeline[safeStep]) + if (rejoin) { + setSplitRejoinPrompt(rejoin) + return + } setCoachDebriefPhase(true) try { sessionStorage.setItem(storageDebriefKey(idNum), '1') @@ -575,10 +595,10 @@ export default function TrainingCoachPage() { setSaveOk(null) setSaving(true) try { - const sectionsPayload = sectionsToPutPayload(unit, durationOverridesForApi) + const sectionsPayloadPart = buildCoachSavePlanPayload(unit, durationOverridesForApi) const tn = trainerAppend.trim() const payload = { - sections: sectionsPayload, + ...sectionsPayloadPart, ...(saveMarkDone ? { status: 'completed' } : {}), } if (tn) { @@ -667,12 +687,14 @@ export default function TrainingCoachPage() { style={{ minWidth: 'min(220px, 72vw)', margin: 0, padding: '6px 8px', fontSize: '0.82rem' }} value={streamQuickSelectValue} onChange={(e) => { + setSplitRejoinPrompt(null) setCoachDebriefPhase(false) timerReset() const v = e.target.value if (!v) { setBranchPicks({}) setStreamChoiceHint(null) + setSplitRejoinPrompt(null) setSearchParams({}, { replace: true }) } else { const [ppo, sso] = v.split('-').map((x) => parseInt(x, 10)) @@ -714,6 +736,80 @@ export default function TrainingCoachPage() {
) : null} + {splitRejoinPrompt && !coachDebriefPhase ? ( ++ {splitRejoinPrompt.phaseTitle != null && String(splitRejoinPrompt.phaseTitle).trim() + ? String(splitRejoinPrompt.phaseTitle).trim() + : `Phase ${splitRejoinPrompt.phaseOrderIndex}`} + {' — '} + alle Gruppen fertig? +
++ Diese Phase hat mehrere Streams. Kurz mit dem anderen Trainer klären, dann gemeinsam Ist-Zeiten und Speichern + (gilt auch, wenn danach kein weiterer Block mehr kommt). +
++
{currentEntry.branchMeta.phaseTitle != null && String(currentEntry.branchMeta.phaseTitle).trim() ? String(currentEntry.branchMeta.phaseTitle).trim() : `Phase ${currentEntry.branchMeta.phaseOrderIndex}`}
-- Welchen Stream coachen Sie jetzt? Jeder Trainer kann auf seinem Gerät eine andere Gruppe wählen. Sobald Sie - wählen, folgen nacheinander die Übungen genau dieser Spalte (ohne Verschränkung mit den anderen Streams). +
+ Tippen Sie auf eine Kachel, um diese Gruppe zu coachen. Andere Trainer:innen wählen auf + ihrem Gerät parallel eine andere Kachel.
-{currentEntry.item.note_body || ''}
@@ -1036,8 +1157,8 @@ export default function TrainingCoachPage() { <>