From 73ac2218c769c23adedac7443a0a4972941a3c30 Mon Sep 17 00:00:00 2001
From: Lars
Date: Fri, 15 May 2026 16:49:05 +0200
Subject: [PATCH] Enhance TrainingCoachPage and trainingPlanUtils with split
rejoin functionality
- Updated TrainingCoachPage to implement a prompt for users to rejoin parallel phases, improving user guidance during training sessions.
- Refactored step management logic to ensure accurate navigation through the timeline, utilizing safe step calculations.
- Introduced new utility functions in trainingPlanUtils for building save payloads and determining when to prompt for split rejoin, optimizing data handling.
- Enhanced state management for split rejoin prompts, ensuring a seamless user experience during training.
---
frontend/src/pages/TrainingCoachPage.jsx | 223 ++++++++++++++++++-----
frontend/src/utils/trainingPlanUtils.js | 35 ++++
2 files changed, 208 insertions(+), 50 deletions(-)
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).
+
+
+ {splitRejoinPrompt.streams.map((st) => (
+
+ {st.streamTitle?.trim() ? String(st.streamTitle).trim() : `Gruppe ${st.streamOrder + 1}`}{' '}
+ (ca. {st.minutes} Min. Üb.)
+
+ ))}
+
+
+ {
+ setSplitRejoinPrompt(null)
+ setCoachDebriefPhase(true)
+ try {
+ sessionStorage.setItem(storageDebriefKey(idNum), '1')
+ } catch {
+ /* ignore */
+ }
+ }}
+ >
+ Alle Gruppen fertig — zur Nachbereitung
+
+ setSplitRejoinPrompt(null)}>
+ Zurück — andere Gruppe läuft noch
+
+ {
+ setSplitRejoinPrompt(null)
+ setCoachDebriefPhase(true)
+ try {
+ sessionStorage.setItem(storageDebriefKey(idNum), '1')
+ } catch {
+ /* ignore */
+ }
+ }}
+ >
+ Ausnahme: trotzdem zur Nachbereitung
+
+
+
+ ) : 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 (
pickStreamForPhase(currentEntry.branchMeta.phaseOrderIndex, st.streamOrder)}
>
- {label}
+
+
+ {hinted ? '◉' : '○'}
+
+ {label}
+
- ca. {st.minutes} Min. (Üb.) · Antippen zum Fortfahren
+ ≈ {st.minutes} Min. Üb. · Jetzt aktivieren
{hinted ? (
-
- Hinweis aus Planansicht — bei Bedarf andere Gruppe wählen.
+
+ Vorschlag aus Planansicht — trotzdem andere Kachel möglich
) : null}
@@ -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) {