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 ? ( +
+
+ Parallelphase · Abschluss +
+

+ {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). +

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

{unit.planned_date} @@ -781,7 +877,7 @@ export default function TrainingCoachPage() { {grp.entries.map(({ ix, ent }) => { const lbl = summarizeTimelineEntry(ent) const ctx = ent.coachContext || '' - const active = coachDebriefPhase ? ix === timeline.length - 1 : ix === step + const active = coachDebriefPhase ? ix === timeline.length - 1 : ix === safeStep const rowKey = ent.entryKind === COACH_ENTRY_BRANCH_GATE ? `gate-${ix}` @@ -905,6 +1001,8 @@ export default function TrainingCoachPage() { ) : ( <> + {!splitRejoinPrompt ? ( + <>
setStep(timerOwningStep ?? step)} + onJumpToTimerOwner={() => setStep(timerOwningStep ?? safeStep)} timerOwnerLabelIndex={timerOwningStep ?? 0} branchGateMode={atBranchGate} /> @@ -968,54 +1066,77 @@ export default function TrainingCoachPage() {
-
- Parallele Phase · Coaching-Zweig +
+ ⑂ SPLIT — GRUPPE WÄHLEN
-

+

{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.branchMeta.streams || []).map((st) => { const hinted = streamChoiceHint?.phaseOrder === currentEntry.branchMeta.phaseOrderIndex && streamChoiceHint?.streamOrder === st.streamOrder const label = st.streamTitle?.trim() ? String(st.streamTitle).trim() : `Gruppe ${st.streamOrder + 1}` + const baseBg = hinted ? 'var(--accent)' : 'var(--surface2)' + const baseColor = hinted ? '#fff' : 'var(--text1)' + const borderCol = hinted ? 'var(--accent-dark)' : 'var(--border)' return ( @@ -1026,8 +1147,8 @@ export default function TrainingCoachPage() { ) : currentEntry?.item?.item_type === 'note' ? (
- {currentEntry.coachContext || currentEntry.sec.title || 'Abschnitt'} · Coach-Notiz · Teil{' '} - {step + 1} + {currentEntry?.coachContext || currentEntry?.sec?.title || 'Abschnitt'} · Coach-Notiz · Teil{' '} + {safeStep + 1}
Coach-Notiz

{currentEntry.item.note_body || ''}

@@ -1036,8 +1157,8 @@ export default function TrainingCoachPage() { <>
- In diesem Training · {currentEntry.coachContext || currentEntry?.sec.title || 'Abschnitt'} · Teil{' '} - {step + 1} + In diesem Training · {currentEntry?.coachContext || currentEntry?.sec?.title || 'Abschnitt'} · Teil{' '} + {safeStep + 1}
{currentEntry?.item && ( <> @@ -1180,7 +1301,7 @@ export default function TrainingCoachPage() {
setStep(timerOwningStep ?? step)} + onJumpToTimerOwner={() => setStep(timerOwningStep ?? safeStep)} timerOwnerLabelIndex={timerOwningStep ?? 0} branchGateMode={atBranchGate} /> + + ) : null} )}
diff --git a/frontend/src/utils/trainingPlanUtils.js b/frontend/src/utils/trainingPlanUtils.js index 0a4d7b4..ebcc365 100644 --- a/frontend/src/utils/trainingPlanUtils.js +++ b/frontend/src/utils/trainingPlanUtils.js @@ -3,6 +3,7 @@ */ import { + buildPlanPayloadForSave, cloneJsonSerializablePlanningProfile, inheritPlanLocForPhasedSave, phaseRunsFromSections, @@ -455,6 +456,40 @@ export function durationOverridesMapFromDeltas(unit, deltas) { return out } +/** PUT-Body für Coach-Speichern: `phases` wenn Plan Phasen hat, sonst `sections` (wie Planungseditor). */ +export function buildCoachSavePlanPayload(unit, durationOverridesByItemId = {}) { + const withLoc = sectionsWithPlanLocForDisplay(unit) + const withDur = withLoc.map((sec) => ({ + ...sec, + items: sortedItems(sec).map((it) => { + if (it.item_type !== 'exercise' || it.id == null) return it + const o = durationOverridesByItemId[String(it.id)] + const av = o?.actual_duration_min + if (av !== undefined && av !== '' && av !== null && Number.isFinite(Number(av))) { + return { ...it, actual_duration_min: Number(av) } + } + return it + }), + })) + return buildPlanPayloadForSave(withDur) +} + +/** + * Nach dem letzten Block eines Streams: Rückfrage, wenn die parallele Phase mehrere Gruppen hat. + */ +export function coachShouldPromptSplitRejoin(unit, lastTimelineEntry) { + const rm = lastTimelineEntry?.runMeta + if (!rm || rm.kind !== 'parallel' || rm.streamOrder == null) return null + const model = buildPlanRunViewModelFromSections(sectionsWithPlanLocForDisplay(unit)) + const run = model.runs.find((r) => r.kind === 'parallel' && r.phaseOrderIndex === rm.phaseOrderIndex) + if (!run?.streams || run.streams.length <= 1) return null + return { + phaseOrderIndex: run.phaseOrderIndex, + phaseTitle: run.phaseTitle, + streams: run.streams, + } +} + export function summarizeTimelineEntry(ent) { if (!ent) return '' if (ent.entryKind === COACH_ENTRY_BRANCH_GATE) {