Enhance TrainingCoachPage and trainingPlanUtils with split rejoin functionality
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m8s

- 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.
This commit is contained in:
Lars 2026-05-15 16:49:05 +02:00
parent 352237bbb9
commit 73ac2218c7
2 changed files with 208 additions and 50 deletions

View File

@ -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() {
</p>
) : null}
{splitRejoinPrompt && !coachDebriefPhase ? (
<div
className="card no-print"
style={{
flexShrink: 0,
marginBottom: '10px',
padding: '16px 14px',
borderRadius: '12px',
border: '2px solid var(--accent)',
background: 'linear-gradient(135deg, var(--accent-light), var(--surface))',
}}
>
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--accent-dark)', marginBottom: '6px' }}>
Parallelphase · Abschluss
</div>
<p style={{ fontSize: '0.95rem', fontWeight: 700, margin: '0 0 8px', color: 'var(--text1)' }}>
{splitRejoinPrompt.phaseTitle != null && String(splitRejoinPrompt.phaseTitle).trim()
? String(splitRejoinPrompt.phaseTitle).trim()
: `Phase ${splitRejoinPrompt.phaseOrderIndex}`}
{' — '}
alle Gruppen fertig?
</p>
<p style={{ fontSize: '0.84rem', color: 'var(--text2)', margin: '0 0 12px', lineHeight: 1.45 }}>
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).
</p>
<ul style={{ margin: '0 0 14px', paddingLeft: '1.2rem', fontSize: '0.82rem', color: 'var(--text2)' }}>
{splitRejoinPrompt.streams.map((st) => (
<li key={st.printStreamId || `s-${st.streamOrder}`}>
{st.streamTitle?.trim() ? String(st.streamTitle).trim() : `Gruppe ${st.streamOrder + 1}`}{' '}
<span style={{ color: 'var(--text3)' }}>(ca. {st.minutes} Min. Üb.)</span>
</li>
))}
</ul>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<button
type="button"
className="btn btn-primary"
style={{ minHeight: '48px', fontWeight: 700 }}
onClick={() => {
setSplitRejoinPrompt(null)
setCoachDebriefPhase(true)
try {
sessionStorage.setItem(storageDebriefKey(idNum), '1')
} catch {
/* ignore */
}
}}
>
Alle Gruppen fertig zur Nachbereitung
</button>
<button type="button" className="btn btn-secondary" style={{ minHeight: '44px' }} onClick={() => setSplitRejoinPrompt(null)}>
Zurück andere Gruppe läuft noch
</button>
<button
type="button"
className="btn btn-secondary"
style={{ minHeight: '44px', fontSize: '0.82rem' }}
onClick={() => {
setSplitRejoinPrompt(null)
setCoachDebriefPhase(true)
try {
sessionStorage.setItem(storageDebriefKey(idNum), '1')
} catch {
/* ignore */
}
}}
>
Ausnahme: trotzdem zur Nachbereitung
</button>
</div>
</div>
) : null}
<header
className="card training-coach-hero training-coach-hero--compact"
style={{
@ -727,7 +823,7 @@ export default function TrainingCoachPage() {
>
<div style={{ fontSize: '0.7rem', color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
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)}`}
</div>
<h1 style={{ fontSize: '1.1rem', margin: '4px 0 0', lineHeight: 1.28 }}>
{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() {
</div>
) : (
<>
{!splitRejoinPrompt ? (
<>
<div
className="training-coach-assist training-coach-assist--compact"
style={{
@ -942,7 +1040,7 @@ export default function TrainingCoachPage() {
</div>
<CoachControlsBand
step={step}
step={safeStep}
timelineLength={timeline.length}
onPrev={goPrev}
onNext={goNext}
@ -958,7 +1056,7 @@ export default function TrainingCoachPage() {
isLastCoachStep={isLastCoachStep}
showJumpToTimerOwner={showJumpToTimerOwner}
showJumpToTimerOwnerRow
onJumpToTimerOwner={() => setStep(timerOwningStep ?? step)}
onJumpToTimerOwner={() => setStep(timerOwningStep ?? safeStep)}
timerOwnerLabelIndex={timerOwningStep ?? 0}
branchGateMode={atBranchGate}
/>
@ -968,54 +1066,77 @@ export default function TrainingCoachPage() {
<div
className="card"
style={{
padding: '16px 14px',
padding: '18px 16px',
marginBottom: '12px',
borderRadius: '12px',
borderLeft: '4px solid var(--accent)',
background: 'var(--surface)',
borderRadius: '14px',
border: '2px solid var(--accent)',
background: 'linear-gradient(180deg, var(--accent-light) 0%, var(--surface) 38%)',
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
}}
>
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text3)', marginBottom: '8px' }}>
Parallele Phase · Coaching-Zweig
<div style={{ fontSize: '0.74rem', fontWeight: 800, color: 'var(--accent-dark)', marginBottom: '6px', letterSpacing: '0.04em' }}>
SPLIT GRUPPE WÄHLEN
</div>
<p style={{ fontSize: '1.02rem', fontWeight: 700, margin: '0 0 8px', color: 'var(--accent-dark)' }}>
<p style={{ fontSize: '1.05rem', fontWeight: 800, margin: '0 0 8px', color: 'var(--text1)' }}>
{currentEntry.branchMeta.phaseTitle != null && String(currentEntry.branchMeta.phaseTitle).trim()
? String(currentEntry.branchMeta.phaseTitle).trim()
: `Phase ${currentEntry.branchMeta.phaseOrderIndex}`}
</p>
<p style={{ fontSize: '0.86rem', color: 'var(--text2)', margin: '0 0 14px', lineHeight: 1.45 }}>
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).
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', margin: '0 0 16px', lineHeight: 1.45 }}>
Tippen Sie auf eine Kachel, um <strong>diese Gruppe</strong> zu coachen. Andere Trainer:innen wählen auf
ihrem Gerät parallel eine andere Kachel.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '12px' }}>
{(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 (
<button
key={st.printStreamId || `s-${st.streamOrder}`}
type="button"
className={hinted ? 'btn btn-primary' : 'btn btn-secondary'}
style={{ textAlign: 'left', justifyContent: 'flex-start', padding: '12px 14px', fontWeight: 600 }}
className="btn"
style={{
textAlign: 'left',
justifyContent: 'flex-start',
flexDirection: 'column',
alignItems: 'stretch',
padding: '14px 16px',
minHeight: '96px',
fontWeight: 700,
background: baseBg,
color: baseColor,
border: `2px solid ${borderCol}`,
borderRadius: '12px',
boxShadow: hinted ? '0 4px 0 var(--accent-dark)' : '0 3px 0 hsl(200 20% 78%)',
transition: 'transform 0.08s ease, box-shadow 0.08s ease',
}}
onClick={() => pickStreamForPhase(currentEntry.branchMeta.phaseOrderIndex, st.streamOrder)}
>
<span style={{ display: 'block' }}>{label}</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '1rem' }}>
<span style={{ fontSize: '1.35rem', lineHeight: 1 }} aria-hidden="true">
{hinted ? '◉' : '○'}
</span>
<span>{label}</span>
</span>
<span
style={{
display: 'block',
fontSize: '0.82rem',
fontWeight: 500,
opacity: 0.9,
marginTop: '4px',
fontWeight: 600,
opacity: hinted ? 0.95 : 0.88,
marginTop: '8px',
}}
>
ca. {st.minutes} Min. (Üb.) · Antippen zum Fortfahren
{st.minutes} Min. Üb. · Jetzt aktivieren
</span>
{hinted ? (
<span style={{ display: 'block', fontSize: '0.75rem', marginTop: '6px', opacity: 0.95 }}>
Hinweis aus Planansicht bei Bedarf andere Gruppe wählen.
<span style={{ display: 'block', fontSize: '0.74rem', fontWeight: 600, marginTop: '8px', opacity: 0.92 }}>
Vorschlag aus Planansicht trotzdem andere Kachel möglich
</span>
) : null}
</button>
@ -1026,8 +1147,8 @@ export default function TrainingCoachPage() {
) : currentEntry?.item?.item_type === 'note' ? (
<div className="card" style={{ padding: '16px 14px' }}>
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', marginBottom: '8px' }}>
{currentEntry.coachContext || currentEntry.sec.title || 'Abschnitt'} · Coach-Notiz · Teil{' '}
{step + 1}
{currentEntry?.coachContext || currentEntry?.sec?.title || 'Abschnitt'} · Coach-Notiz · Teil{' '}
{safeStep + 1}
</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginBottom: '6px' }}>Coach-Notiz</div>
<p style={{ fontSize: '1.05rem', lineHeight: 1.52, whiteSpace: 'pre-wrap', margin: 0 }}>{currentEntry.item.note_body || ''}</p>
@ -1036,8 +1157,8 @@ export default function TrainingCoachPage() {
<>
<div className="card training-coach-plan-strip" style={{ padding: '12px 14px', marginBottom: '12px', borderRadius: '12px', borderLeft: `3px solid var(--accent)` }}>
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', marginBottom: '6px' }}>
In diesem Training · {currentEntry.coachContext || currentEntry?.sec.title || 'Abschnitt'} · Teil{' '}
{step + 1}
In diesem Training · {currentEntry?.coachContext || currentEntry?.sec?.title || 'Abschnitt'} · Teil{' '}
{safeStep + 1}
</div>
{currentEntry?.item && (
<>
@ -1180,7 +1301,7 @@ export default function TrainingCoachPage() {
</div>
<CoachControlsBand
step={step}
step={safeStep}
timelineLength={timeline.length}
onPrev={goPrev}
onNext={goNext}
@ -1196,10 +1317,12 @@ export default function TrainingCoachPage() {
isLastCoachStep={isLastCoachStep}
showJumpToTimerOwner={showJumpToTimerOwner}
showJumpToTimerOwnerRow={false}
onJumpToTimerOwner={() => setStep(timerOwningStep ?? step)}
onJumpToTimerOwner={() => setStep(timerOwningStep ?? safeStep)}
timerOwnerLabelIndex={timerOwningStep ?? 0}
branchGateMode={atBranchGate}
/>
</>
) : null}
</>
)}
</div>

View File

@ -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) {