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. * 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 { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import ExerciseFullContent from '../components/ExerciseFullContent' import ExerciseFullContent from '../components/ExerciseFullContent'
import ExercisePeekModal from '../components/ExercisePeekModal' import ExercisePeekModal from '../components/ExercisePeekModal'
import { import {
COACH_ENTRY_BRANCH_GATE, COACH_ENTRY_BRANCH_GATE,
buildCoachSavePlanPayload,
coachBranchPicksStepStorageSuffix, coachBranchPicksStepStorageSuffix,
coachBranchPicksStorageKey, coachBranchPicksStorageKey,
coachOutlineGroupsFromTimeline, coachOutlineGroupsFromTimeline,
coachShouldPromptSplitRejoin,
durationOverridesMapFromDeltas, durationOverridesMapFromDeltas,
findCoachTimelineJumpIndexForPhase, findCoachTimelineJumpIndexForPhase,
flattenPlanTimeline, flattenPlanTimeline,
@ -18,7 +20,6 @@ import {
listCoachStreamFocusOptions, listCoachStreamFocusOptions,
mergeCoachBranchPicksWithUrlFocus, mergeCoachBranchPicksWithUrlFocus,
normalizeCoachBranchPicks, normalizeCoachBranchPicks,
sectionsToPutPayload,
summarizeTimelineEntry, summarizeTimelineEntry,
} from '../utils/trainingPlanUtils' } from '../utils/trainingPlanUtils'
@ -194,6 +195,8 @@ export default function TrainingCoachPage() {
const [timerOwningStep, setTimerOwningStep] = useState(null) const [timerOwningStep, setTimerOwningStep] = useState(null)
const [, setPulse] = useState(0) const [, setPulse] = useState(0)
const [splitRejoinPrompt, setSplitRejoinPrompt] = useState(null)
const [trainerAppend, setTrainerAppend] = useState('') const [trainerAppend, setTrainerAppend] = useState('')
const [saveMarkDone, setSaveMarkDone] = useState(true) const [saveMarkDone, setSaveMarkDone] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@ -398,14 +401,12 @@ export default function TrainingCoachPage() {
setStep(0) setStep(0)
return return
} }
if (coachDebriefPhase) return if (coachDebriefPhase) {
setStep((prev) => clampStep(prev, timeline.length))
}, [unit, timeline.length, coachDebriefPhase])
useEffect(() => {
if (!coachDebriefPhase || !unit || timeline.length === 0) return
setStep(timeline.length - 1) setStep(timeline.length - 1)
}, [coachDebriefPhase, unit, timeline.length]) } else {
setStep((prev) => clampStep(prev, timeline.length))
}
}, [unit, timeline.length, coachDebriefPhase])
useEffect(() => { useEffect(() => {
if (!unit || Number.isNaN(idNum)) return if (!unit || Number.isNaN(idNum)) return
@ -419,6 +420,7 @@ export default function TrainingCoachPage() {
} else if (prev !== navigationKey) { } else if (prev !== navigationKey) {
coachFocusResetRef.current = navigationKey coachFocusResetRef.current = navigationKey
setCoachDebriefPhase(false) setCoachDebriefPhase(false)
setSplitRejoinPrompt(null)
timerReset() timerReset()
} else { } else {
return return
@ -439,24 +441,36 @@ export default function TrainingCoachPage() {
const tickDisplaySec = Math.max(0, Math.floor(elapsedMs / 1000)) const tickDisplaySec = Math.max(0, Math.floor(elapsedMs / 1000))
const currentEntry = timeline[step] const safeStep = useMemo(() => {
const nextEntry = timeline[step + 1] || null if (!timeline.length) return 0
const next2Entry = timeline[step + 2] || null 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 clockStr = formatClock(tickDisplaySec)
const roundedMinApply = elapsedMs <= 650 ? null : Math.max(1, Math.round(elapsedMs / 60000)) const roundedMinApply = elapsedMs <= 650 ? null : Math.max(1, Math.round(elapsedMs / 60000))
const showJumpToTimerOwner = const showJumpToTimerOwner =
timerOwningStep != null && timerOwningStep != null &&
step !== timerOwningStep && safeStep !== timerOwningStep &&
(runStartAt != null || pausedAccumMs > 0) (runStartAt != null || pausedAccumMs > 0)
const isLastCoachStep = const isLastCoachStep =
timeline.length > 0 && timeline.length > 0 &&
step >= timeline.length - 1 && safeStep >= timeline.length - 1 &&
currentEntry?.entryKind !== COACH_ENTRY_BRANCH_GATE currentEntry?.entryKind !== COACH_ENTRY_BRANCH_GATE
const timerStart = () => { const timerStart = () => {
setRunStartAt(Date.now()) setRunStartAt(Date.now())
setTimerOwningStep(step) setTimerOwningStep(safeStep)
} }
const timerPause = () => { const timerPause = () => {
@ -467,7 +481,7 @@ export default function TrainingCoachPage() {
} }
const applySuggestedDuration = () => { const applySuggestedDuration = () => {
const idx = timerOwningStep != null ? timerOwningStep : step const idx = timerOwningStep != null ? timerOwningStep : safeStep
const ent = timeline[idx] const ent = timeline[idx]
const item = ent?.item const item = ent?.item
if (!item || item.item_type !== 'exercise') return if (!item || item.item_type !== 'exercise') return
@ -482,6 +496,7 @@ export default function TrainingCoachPage() {
(phaseOrder, streamOrder) => { (phaseOrder, streamOrder) => {
if (!Number.isFinite(phaseOrder) || !Number.isFinite(streamOrder)) return if (!Number.isFinite(phaseOrder) || !Number.isFinite(streamOrder)) return
setStreamChoiceHint(null) setStreamChoiceHint(null)
setSplitRejoinPrompt(null)
setBranchPicks((prev) => ({ ...normalizeCoachBranchPicks(prev), [phaseOrder]: streamOrder })) setBranchPicks((prev) => ({ ...normalizeCoachBranchPicks(prev), [phaseOrder]: streamOrder }))
timerReset() timerReset()
setCoachDebriefPhase(false) setCoachDebriefPhase(false)
@ -496,7 +511,7 @@ export default function TrainingCoachPage() {
const goNext = () => setStep((s) => clampStep(s + 1)) const goNext = () => setStep((s) => clampStep(s + 1))
const markCurrentDoneAdvance = () => { const markCurrentDoneAdvance = () => {
const ownerIdx = timerOwningStep != null ? timerOwningStep : step const ownerIdx = timerOwningStep != null ? timerOwningStep : safeStep
const ent = timeline[ownerIdx] const ent = timeline[ownerIdx]
if (ent?.entryKind === COACH_ENTRY_BRANCH_GATE) return if (ent?.entryKind === COACH_ENTRY_BRANCH_GATE) return
const item = ent?.item const item = ent?.item
@ -508,7 +523,12 @@ export default function TrainingCoachPage() {
timerReset() timerReset()
const lastIdx = timeline.length - 1 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) setCoachDebriefPhase(true)
try { try {
sessionStorage.setItem(storageDebriefKey(idNum), '1') sessionStorage.setItem(storageDebriefKey(idNum), '1')
@ -575,10 +595,10 @@ export default function TrainingCoachPage() {
setSaveOk(null) setSaveOk(null)
setSaving(true) setSaving(true)
try { try {
const sectionsPayload = sectionsToPutPayload(unit, durationOverridesForApi) const sectionsPayloadPart = buildCoachSavePlanPayload(unit, durationOverridesForApi)
const tn = trainerAppend.trim() const tn = trainerAppend.trim()
const payload = { const payload = {
sections: sectionsPayload, ...sectionsPayloadPart,
...(saveMarkDone ? { status: 'completed' } : {}), ...(saveMarkDone ? { status: 'completed' } : {}),
} }
if (tn) { if (tn) {
@ -667,12 +687,14 @@ export default function TrainingCoachPage() {
style={{ minWidth: 'min(220px, 72vw)', margin: 0, padding: '6px 8px', fontSize: '0.82rem' }} style={{ minWidth: 'min(220px, 72vw)', margin: 0, padding: '6px 8px', fontSize: '0.82rem' }}
value={streamQuickSelectValue} value={streamQuickSelectValue}
onChange={(e) => { onChange={(e) => {
setSplitRejoinPrompt(null)
setCoachDebriefPhase(false) setCoachDebriefPhase(false)
timerReset() timerReset()
const v = e.target.value const v = e.target.value
if (!v) { if (!v) {
setBranchPicks({}) setBranchPicks({})
setStreamChoiceHint(null) setStreamChoiceHint(null)
setSplitRejoinPrompt(null)
setSearchParams({}, { replace: true }) setSearchParams({}, { replace: true })
} else { } else {
const [ppo, sso] = v.split('-').map((x) => parseInt(x, 10)) const [ppo, sso] = v.split('-').map((x) => parseInt(x, 10))
@ -714,6 +736,80 @@ export default function TrainingCoachPage() {
</p> </p>
) : null} ) : 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 <header
className="card training-coach-hero training-coach-hero--compact" className="card training-coach-hero training-coach-hero--compact"
style={{ style={{
@ -727,7 +823,7 @@ export default function TrainingCoachPage() {
> >
<div style={{ fontSize: '0.7rem', color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.06em' }}> <div style={{ fontSize: '0.7rem', color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Coach ·{' '} 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> </div>
<h1 style={{ fontSize: '1.1rem', margin: '4px 0 0', lineHeight: 1.28 }}> <h1 style={{ fontSize: '1.1rem', margin: '4px 0 0', lineHeight: 1.28 }}>
{unit.planned_date} {unit.planned_date}
@ -781,7 +877,7 @@ export default function TrainingCoachPage() {
{grp.entries.map(({ ix, ent }) => { {grp.entries.map(({ ix, ent }) => {
const lbl = summarizeTimelineEntry(ent) const lbl = summarizeTimelineEntry(ent)
const ctx = ent.coachContext || '' const ctx = ent.coachContext || ''
const active = coachDebriefPhase ? ix === timeline.length - 1 : ix === step const active = coachDebriefPhase ? ix === timeline.length - 1 : ix === safeStep
const rowKey = const rowKey =
ent.entryKind === COACH_ENTRY_BRANCH_GATE ent.entryKind === COACH_ENTRY_BRANCH_GATE
? `gate-${ix}` ? `gate-${ix}`
@ -904,6 +1000,8 @@ export default function TrainingCoachPage() {
) : null} ) : null}
</div> </div>
) : ( ) : (
<>
{!splitRejoinPrompt ? (
<> <>
<div <div
className="training-coach-assist training-coach-assist--compact" className="training-coach-assist training-coach-assist--compact"
@ -942,7 +1040,7 @@ export default function TrainingCoachPage() {
</div> </div>
<CoachControlsBand <CoachControlsBand
step={step} step={safeStep}
timelineLength={timeline.length} timelineLength={timeline.length}
onPrev={goPrev} onPrev={goPrev}
onNext={goNext} onNext={goNext}
@ -958,7 +1056,7 @@ export default function TrainingCoachPage() {
isLastCoachStep={isLastCoachStep} isLastCoachStep={isLastCoachStep}
showJumpToTimerOwner={showJumpToTimerOwner} showJumpToTimerOwner={showJumpToTimerOwner}
showJumpToTimerOwnerRow showJumpToTimerOwnerRow
onJumpToTimerOwner={() => setStep(timerOwningStep ?? step)} onJumpToTimerOwner={() => setStep(timerOwningStep ?? safeStep)}
timerOwnerLabelIndex={timerOwningStep ?? 0} timerOwnerLabelIndex={timerOwningStep ?? 0}
branchGateMode={atBranchGate} branchGateMode={atBranchGate}
/> />
@ -968,54 +1066,77 @@ export default function TrainingCoachPage() {
<div <div
className="card" className="card"
style={{ style={{
padding: '16px 14px', padding: '18px 16px',
marginBottom: '12px', marginBottom: '12px',
borderRadius: '12px', borderRadius: '14px',
borderLeft: '4px solid var(--accent)', border: '2px solid var(--accent)',
background: 'var(--surface)', 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' }}> <div style={{ fontSize: '0.74rem', fontWeight: 800, color: 'var(--accent-dark)', marginBottom: '6px', letterSpacing: '0.04em' }}>
Parallele Phase · Coaching-Zweig SPLIT GRUPPE WÄHLEN
</div> </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() {currentEntry.branchMeta.phaseTitle != null && String(currentEntry.branchMeta.phaseTitle).trim()
? String(currentEntry.branchMeta.phaseTitle).trim() ? String(currentEntry.branchMeta.phaseTitle).trim()
: `Phase ${currentEntry.branchMeta.phaseOrderIndex}`} : `Phase ${currentEntry.branchMeta.phaseOrderIndex}`}
</p> </p>
<p style={{ fontSize: '0.86rem', color: 'var(--text2)', margin: '0 0 14px', lineHeight: 1.45 }}> <p style={{ fontSize: '0.88rem', color: 'var(--text2)', margin: '0 0 16px', lineHeight: 1.45 }}>
Welchen Stream coachen Sie jetzt? Jeder Trainer kann auf seinem Gerät eine andere Gruppe wählen. Sobald Sie Tippen Sie auf eine Kachel, um <strong>diese Gruppe</strong> zu coachen. Andere Trainer:innen wählen auf
wählen, folgen nacheinander die Übungen genau dieser Spalte (ohne Verschränkung mit den anderen Streams). ihrem Gerät parallel eine andere Kachel.
</p> </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) => { {(currentEntry.branchMeta.streams || []).map((st) => {
const hinted = const hinted =
streamChoiceHint?.phaseOrder === currentEntry.branchMeta.phaseOrderIndex && streamChoiceHint?.phaseOrder === currentEntry.branchMeta.phaseOrderIndex &&
streamChoiceHint?.streamOrder === st.streamOrder streamChoiceHint?.streamOrder === st.streamOrder
const label = st.streamTitle?.trim() ? String(st.streamTitle).trim() : `Gruppe ${st.streamOrder + 1}` 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 ( return (
<button <button
key={st.printStreamId || `s-${st.streamOrder}`} key={st.printStreamId || `s-${st.streamOrder}`}
type="button" type="button"
className={hinted ? 'btn btn-primary' : 'btn btn-secondary'} className="btn"
style={{ textAlign: 'left', justifyContent: 'flex-start', padding: '12px 14px', fontWeight: 600 }} 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)} 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 <span
style={{ style={{
display: 'block', display: 'block',
fontSize: '0.82rem', fontSize: '0.82rem',
fontWeight: 500, fontWeight: 600,
opacity: 0.9, opacity: hinted ? 0.95 : 0.88,
marginTop: '4px', marginTop: '8px',
}} }}
> >
ca. {st.minutes} Min. (Üb.) · Antippen zum Fortfahren {st.minutes} Min. Üb. · Jetzt aktivieren
</span> </span>
{hinted ? ( {hinted ? (
<span style={{ display: 'block', fontSize: '0.75rem', marginTop: '6px', opacity: 0.95 }}> <span style={{ display: 'block', fontSize: '0.74rem', fontWeight: 600, marginTop: '8px', opacity: 0.92 }}>
Hinweis aus Planansicht bei Bedarf andere Gruppe wählen. Vorschlag aus Planansicht trotzdem andere Kachel möglich
</span> </span>
) : null} ) : null}
</button> </button>
@ -1026,8 +1147,8 @@ export default function TrainingCoachPage() {
) : currentEntry?.item?.item_type === 'note' ? ( ) : currentEntry?.item?.item_type === 'note' ? (
<div className="card" style={{ padding: '16px 14px' }}> <div className="card" style={{ padding: '16px 14px' }}>
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', marginBottom: '8px' }}> <div style={{ fontSize: '0.74rem', color: 'var(--text3)', marginBottom: '8px' }}>
{currentEntry.coachContext || currentEntry.sec.title || 'Abschnitt'} · Coach-Notiz · Teil{' '} {currentEntry?.coachContext || currentEntry?.sec?.title || 'Abschnitt'} · Coach-Notiz · Teil{' '}
{step + 1} {safeStep + 1}
</div> </div>
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginBottom: '6px' }}>Coach-Notiz</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> <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 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' }}> <div style={{ fontSize: '0.74rem', color: 'var(--text3)', marginBottom: '6px' }}>
In diesem Training · {currentEntry.coachContext || currentEntry?.sec.title || 'Abschnitt'} · Teil{' '} In diesem Training · {currentEntry?.coachContext || currentEntry?.sec?.title || 'Abschnitt'} · Teil{' '}
{step + 1} {safeStep + 1}
</div> </div>
{currentEntry?.item && ( {currentEntry?.item && (
<> <>
@ -1180,7 +1301,7 @@ export default function TrainingCoachPage() {
</div> </div>
<CoachControlsBand <CoachControlsBand
step={step} step={safeStep}
timelineLength={timeline.length} timelineLength={timeline.length}
onPrev={goPrev} onPrev={goPrev}
onNext={goNext} onNext={goNext}
@ -1196,11 +1317,13 @@ export default function TrainingCoachPage() {
isLastCoachStep={isLastCoachStep} isLastCoachStep={isLastCoachStep}
showJumpToTimerOwner={showJumpToTimerOwner} showJumpToTimerOwner={showJumpToTimerOwner}
showJumpToTimerOwnerRow={false} showJumpToTimerOwnerRow={false}
onJumpToTimerOwner={() => setStep(timerOwningStep ?? step)} onJumpToTimerOwner={() => setStep(timerOwningStep ?? safeStep)}
timerOwnerLabelIndex={timerOwningStep ?? 0} timerOwnerLabelIndex={timerOwningStep ?? 0}
branchGateMode={atBranchGate} branchGateMode={atBranchGate}
/> />
</> </>
) : null}
</>
)} )}
</div> </div>
) )

View File

@ -3,6 +3,7 @@
*/ */
import { import {
buildPlanPayloadForSave,
cloneJsonSerializablePlanningProfile, cloneJsonSerializablePlanningProfile,
inheritPlanLocForPhasedSave, inheritPlanLocForPhasedSave,
phaseRunsFromSections, phaseRunsFromSections,
@ -455,6 +456,40 @@ export function durationOverridesMapFromDeltas(unit, deltas) {
return out 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) { export function summarizeTimelineEntry(ent) {
if (!ent) return '' if (!ent) return ''
if (ent.entryKind === COACH_ENTRY_BRANCH_GATE) { if (ent.entryKind === COACH_ENTRY_BRANCH_GATE) {