Refactor TrainingCoachPage and TrainingUnitRunPage to enhance coach branching 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 1s
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 branching logic for coach steps, allowing for improved navigation through training phases.
- Enhanced session storage handling to manage branch picks and streamline state management during training sessions.
- Modified TrainingUnitRunPage to update links for coaching views, reflecting the new branching structure and improving user experience.
- Introduced new utility functions in trainingPlanUtils for managing coach branch picks and timeline navigation, optimizing data handling across components.
This commit is contained in:
Lars 2026-05-15 16:31:54 +02:00
parent 4cf7133bce
commit 352237bbb9
3 changed files with 459 additions and 82 deletions

View File

@ -1,6 +1,5 @@
/**
* Coach-Modus: eine Position nach der anderen mit Assistentenhinweisen, Zeitnahme und optionaler Nachbereitung.
* Timeline: flach in Phasen-/Stream-Reihenfolge (flattenPlanTimeline).
* 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 { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
@ -8,17 +7,23 @@ import api from '../utils/api'
import ExerciseFullContent from '../components/ExerciseFullContent'
import ExercisePeekModal from '../components/ExercisePeekModal'
import {
COACH_ENTRY_BRANCH_GATE,
coachBranchPicksStepStorageSuffix,
coachBranchPicksStorageKey,
coachOutlineGroupsFromTimeline,
durationOverridesMapFromDeltas,
findCoachTimelineJumpIndexForPhase,
flattenPlanTimeline,
itemStableKey,
listCoachStreamFocusOptions,
mergeCoachBranchPicksWithUrlFocus,
normalizeCoachBranchPicks,
sectionsToPutPayload,
summarizeTimelineEntry,
} from '../utils/trainingPlanUtils'
function storageStepKey(unitId, coachFocus) {
if (coachFocus == null) return `sj_coach_step_${unitId}_full`
return `sj_coach_step_${unitId}_po${coachFocus.phaseOrder}-so${coachFocus.streamOrder}`
function storageStepKey(unitId, mergedPicks) {
return `sj_coach_step_${unitId}_${coachBranchPicksStepStorageSuffix(mergedPicks)}`
}
function storageDeltasKey(unitId) {
@ -58,14 +63,16 @@ function CoachControlsBand({
showJumpToTimerOwnerRow = true,
onJumpToTimerOwner,
timerOwnerLabelIndex,
branchGateMode = false,
}) {
const disPrev = step <= 0
const disNext = step >= timelineLength - 1
const disNext = branchGateMode || step >= timelineLength - 1
const alive = runStartAt != null || pausedAccumMs > 0
const canApplyForOwner = roundedMinForApply != null && roundedMinForApply >= 1
const canApplyForOwner = !branchGateMode && roundedMinForApply != null && roundedMinForApply >= 1
const istLabelMin = roundedMinForApply == null ? '—' : String(roundedMinForApply)
const doneLabel =
timelineLength <= 1
const doneLabel = branchGateMode
? 'Zuerst Gruppe wählen'
: timelineLength <= 1
? 'Nachbereitung öffnen'
: isLastCoachStep
? 'Nachbereitung & Ist-Zeit'
@ -109,6 +116,7 @@ function CoachControlsBand({
type="button"
className="btn btn-primary"
style={{ minHeight: '44px', flex: '1 1 auto', fontWeight: 700 }}
disabled={branchGateMode}
onClick={onTimerStart}
>
Start{alive ? ` · ${clockStr}` : ''}
@ -121,7 +129,7 @@ function CoachControlsBand({
<button type="button" className="btn btn-secondary" style={{ minHeight: '44px', flex: '0 1 auto', fontWeight: 600 }} disabled={!canApplyForOwner} onClick={onApplyActual}>
Ist ({istLabelMin}&nbsp;Min)
</button>
{alive && (
{alive && !branchGateMode && (
<button
type="button"
className="btn btn-secondary"
@ -137,6 +145,7 @@ function CoachControlsBand({
type="button"
className="btn btn-primary"
style={{ width: '100%', minHeight: '44px', fontWeight: 700, lineHeight: 1.2, padding: '6px 10px', whiteSpace: 'normal', textAlign: 'center' }}
disabled={branchGateMode}
onClick={onDone}
>
{doneLabel}
@ -176,6 +185,8 @@ export default function TrainingCoachPage() {
const [coachDebriefPhase, setCoachDebriefPhase] = useState(false)
const [step, setStep] = useState(0)
const [branchPicks, setBranchPicks] = useState({})
const [streamChoiceHint, setStreamChoiceHint] = useState(null)
const [deltas, setDeltas] = useState({})
const [runStartAt, setRunStartAt] = useState(null)
@ -207,15 +218,32 @@ export default function TrainingCoachPage() {
return { phaseOrder: po, streamOrder: so }
}, [searchParams, unit])
const mergedPicks = useMemo(
() => mergeCoachBranchPicksWithUrlFocus(branchPicks, coachFocus),
[branchPicks, coachFocus]
)
const streamFocusOptions = useMemo(() => (unit ? listCoachStreamFocusOptions(unit) : []), [unit])
const focusKey = coachFocus ? `${coachFocus.phaseOrder}-${coachFocus.streamOrder}` : 'full'
const navigationKey = coachBranchPicksStepStorageSuffix(mergedPicks)
const streamQuickSelectValue = useMemo(() => {
for (let i = 0; i < streamFocusOptions.length; i++) {
const o = streamFocusOptions[i]
if (mergedPicks[o.phaseOrder] === o.streamOrder) return o.valueKey
}
return ''
}, [streamFocusOptions, mergedPicks])
const hasStreamSelectParams =
(searchParams.get('po') != null && searchParams.get('po') !== '') ||
(searchParams.get('so') != null && searchParams.get('so') !== '')
const streamParamsInvalid = Boolean(unit && hasStreamSelectParams && !coachFocus)
const timeline = useMemo(() => flattenPlanTimeline(unit, mergedPicks), [unit, mergedPicks])
const outlineGroups = useMemo(() => coachOutlineGroupsFromTimeline(timeline), [timeline])
useEffect(() => {
if (!unitId || Number.isNaN(idNum)) {
setLoadError('Ungültige Trainingseinheit')
@ -230,6 +258,16 @@ export default function TrainingCoachPage() {
try {
await reloadUnit()
if (cancelled) return
let nextBranchPicks = {}
try {
const br = sessionStorage.getItem(coachBranchPicksStorageKey(idNum))
if (br) {
const o = JSON.parse(br)
if (o && typeof o === 'object') nextBranchPicks = normalizeCoachBranchPicks(o)
}
} catch {
/* ignore */
}
try {
const raw = sessionStorage.getItem(storageDeltasKey(idNum))
if (raw) {
@ -244,6 +282,10 @@ export default function TrainingCoachPage() {
} catch {
/* ignore */
}
if (!cancelled) {
setBranchPicks(nextBranchPicks)
setStreamChoiceHint(null)
}
} catch (e) {
if (!cancelled) setLoadError(e.message || 'Laden fehlgeschlagen')
} finally {
@ -272,10 +314,52 @@ export default function TrainingCoachPage() {
}
}, [unit, searchParams, setSearchParams])
useEffect(() => {
if (!unit) return
const ab = searchParams.get('atBranch')
if (ab == null || ab === '') return
const po = parseInt(ab, 10)
const phaseOrders = [...new Set(streamFocusOptions.map((o) => o.phaseOrder))]
if (!Number.isFinite(po) || !phaseOrders.includes(po)) {
const next = new URLSearchParams(searchParams)
next.delete('atBranch')
next.delete('preferSo')
setSearchParams(next, { replace: true })
}
}, [unit, searchParams, setSearchParams, streamFocusOptions])
useEffect(() => {
if (!unit || timeline.length === 0) return
const ab = searchParams.get('atBranch')
if (ab == null || ab === '') return
const po = parseInt(ab, 10)
if (!Number.isFinite(po)) return
const prefRaw = searchParams.get('preferSo')
const pref = prefRaw != null && prefRaw !== '' ? parseInt(prefRaw, 10) : NaN
const ix = findCoachTimelineJumpIndexForPhase(timeline, po, Number.isFinite(pref) ? pref : null)
if (ix >= 0) {
setStep(ix)
if (Number.isFinite(pref)) setStreamChoiceHint({ phaseOrder: po, streamOrder: pref })
}
const next = new URLSearchParams(searchParams)
next.delete('atBranch')
next.delete('preferSo')
setSearchParams(next, { replace: true })
}, [unit, timeline, searchParams, setSearchParams])
useEffect(() => {
if (Number.isNaN(idNum)) return
sessionStorage.setItem(storageStepKey(idNum, coachFocus), String(step))
}, [idNum, coachFocus, step])
try {
sessionStorage.setItem(coachBranchPicksStorageKey(idNum), JSON.stringify(branchPicks))
} catch {
/* quota */
}
}, [idNum, branchPicks])
useEffect(() => {
if (Number.isNaN(idNum)) return
sessionStorage.setItem(storageStepKey(idNum, mergedPicks), String(step))
}, [idNum, mergedPicks, step])
useEffect(() => {
try {
@ -299,8 +383,6 @@ export default function TrainingCoachPage() {
return () => clearInterval(iv)
}, [runStartAt])
const timeline = useMemo(() => flattenPlanTimeline(unit, coachFocus), [unit, coachFocus])
const clampStep = (s, len = timeline.length) =>
Math.max(0, Math.min(s, Math.max(len - 1, 0)))
@ -333,16 +415,16 @@ export default function TrainingCoachPage() {
}
const prev = coachFocusResetRef.current
if (prev === null) {
coachFocusResetRef.current = focusKey
} else if (prev !== focusKey) {
coachFocusResetRef.current = focusKey
coachFocusResetRef.current = navigationKey
} else if (prev !== navigationKey) {
coachFocusResetRef.current = navigationKey
setCoachDebriefPhase(false)
timerReset()
} else {
return
}
try {
const raw = sessionStorage.getItem(storageStepKey(idNum, coachFocus))
const raw = sessionStorage.getItem(storageStepKey(idNum, mergedPicks))
const s = parseInt(raw, 10)
const maxIdx = Math.max(0, timeline.length - 1)
if (!Number.isNaN(s) && s >= 0) setStep(Math.min(s, maxIdx))
@ -350,7 +432,7 @@ export default function TrainingCoachPage() {
} catch {
setStep(0)
}
}, [unit, idNum, focusKey, coachFocus, timeline.length, timerReset])
}, [unit, idNum, navigationKey, mergedPicks, timeline.length, timerReset])
const elapsedMs =
pausedAccumMs + (runStartAt != null ? Date.now() - runStartAt : 0)
@ -367,7 +449,10 @@ export default function TrainingCoachPage() {
timerOwningStep != null &&
step !== timerOwningStep &&
(runStartAt != null || pausedAccumMs > 0)
const isLastCoachStep = timeline.length > 0 && step >= timeline.length - 1
const isLastCoachStep =
timeline.length > 0 &&
step >= timeline.length - 1 &&
currentEntry?.entryKind !== COACH_ENTRY_BRANCH_GATE
const timerStart = () => {
setRunStartAt(Date.now())
@ -393,12 +478,27 @@ export default function TrainingCoachPage() {
timerPause()
}
const pickStreamForPhase = useCallback(
(phaseOrder, streamOrder) => {
if (!Number.isFinite(phaseOrder) || !Number.isFinite(streamOrder)) return
setStreamChoiceHint(null)
setBranchPicks((prev) => ({ ...normalizeCoachBranchPicks(prev), [phaseOrder]: streamOrder }))
timerReset()
setCoachDebriefPhase(false)
setSearchParams({ po: String(phaseOrder), so: String(streamOrder) }, { replace: true })
},
[timerReset, setSearchParams]
)
const atBranchGate = currentEntry?.entryKind === COACH_ENTRY_BRANCH_GATE
const goPrev = () => setStep((s) => clampStep(s - 1))
const goNext = () => setStep((s) => clampStep(s + 1))
const markCurrentDoneAdvance = () => {
const ownerIdx = timerOwningStep != null ? timerOwningStep : step
const ent = timeline[ownerIdx]
if (ent?.entryKind === COACH_ENTRY_BRANCH_GATE) return
const item = ent?.item
if (item?.item_type === 'exercise' && elapsedMs > 650) {
const key = itemStableKey(item, ent.secOrder, ent.ii)
@ -420,9 +520,20 @@ export default function TrainingCoachPage() {
setStep((s) => clampStep(s + 1, timeline.length))
}
useEffect(() => {
if (!atBranchGate) return
if (runStartAt != null || pausedAccumMs > 0) timerReset()
}, [step, atBranchGate, runStartAt, pausedAccumMs, timerReset])
const durationOverridesForApi = useMemo(() => durationOverridesMapFromDeltas(unit, deltas), [unit, deltas])
useEffect(() => {
if (currentEntry?.entryKind === COACH_ENTRY_BRANCH_GATE) {
setCatalogExercise(null)
setCatalogError(null)
setCatalogLoading(false)
return
}
const item = currentEntry?.item
if (!item || item.item_type === 'note') {
setCatalogExercise(null)
@ -458,7 +569,7 @@ export default function TrainingCoachPage() {
return () => {
cancelled = true
}
}, [step, currentEntry?.item?.exercise_id, currentEntry?.item?.exercise_variant_id, currentEntry?.item?.item_type])
}, [step, currentEntry?.entryKind, currentEntry?.item?.exercise_id, currentEntry?.item?.exercise_variant_id, currentEntry?.item?.item_type])
const handleSaveDebrief = async () => {
setSaveOk(null)
@ -482,10 +593,13 @@ export default function TrainingCoachPage() {
try {
sessionStorage.removeItem(storageDeltasKey(idNum))
sessionStorage.removeItem(storageDebriefKey(idNum))
sessionStorage.removeItem(coachBranchPicksStorageKey(idNum))
} catch {
/* ignore */
}
setDeltas({})
setBranchPicks({})
setStreamChoiceHint(null)
setCoachDebriefPhase(false)
setSaveOk('Gespeichert.')
setDebriefOpen(false)
@ -551,19 +665,22 @@ export default function TrainingCoachPage() {
<select
className="form-input"
style={{ minWidth: 'min(220px, 72vw)', margin: 0, padding: '6px 8px', fontSize: '0.82rem' }}
value={coachFocus ? `${coachFocus.phaseOrder}-${coachFocus.streamOrder}` : ''}
value={streamQuickSelectValue}
onChange={(e) => {
setCoachDebriefPhase(false)
timerReset()
const v = e.target.value
if (!v) setSearchParams({}, { replace: true })
else {
if (!v) {
setBranchPicks({})
setStreamChoiceHint(null)
setSearchParams({}, { replace: true })
} else {
const [ppo, sso] = v.split('-').map((x) => parseInt(x, 10))
setSearchParams({ po: String(ppo), so: String(sso) }, { replace: true })
pickStreamForPhase(ppo, sso)
}
}}
>
<option value="">Gesamtplan (alle Gruppen)</option>
<option value="">Ablauf mit Split-Punkten (Standard)</option>
{streamFocusOptions.map((o) => (
<option key={o.valueKey} value={o.valueKey}>
{o.label}
@ -617,50 +734,86 @@ export default function TrainingCoachPage() {
{unit.planned_time_start && ` · ${String(unit.planned_time_start).slice(0, 5)}`}
{unit.planned_focus ? ` · ${unit.planned_focus}` : ''}
</h1>
{coachFocus ? (
<p style={{ fontSize: '0.8rem', color: 'var(--text2)', margin: '8px 0 0', lineHeight: 1.35 }}>
Fokus:{' '}
{streamFocusOptions.find((o) => o.phaseOrder === coachFocus.phaseOrder && o.streamOrder === coachFocus.streamOrder)
?.label ?? `Phase ${coachFocus.phaseOrder} · Gruppe ${coachFocus.streamOrder + 1}`}
{' · '}
Ganzgruppen-Abschnitte bleiben voll sichtbar; in der gewählten Split-Phase nur diese Spalte.
{streamFocusOptions.length > 0 ? (
<p style={{ fontSize: '0.8rem', color: 'var(--text2)', margin: '8px 0 0', lineHeight: 1.4 }}>
Parallele Phasen erscheinen als Schritt <strong>Gruppe wählen</strong> kein Durcheinander mehr aus
verschränkten Streams. Pro paralleler Phase entscheiden Sie (oder der Co-Trainer auf dem eigenen Gerät), welcher
Stream coacht wird. Kurzwahl oben springt die betreffende Phase sofort auf einen Stream (z. B. zwischen Gruppen
wechseln).
{Object.keys(mergedPicks).length > 0 ? (
<span style={{ display: 'block', marginTop: '6px', color: 'var(--text3)', fontSize: '0.78rem' }}>
Aktuell festgelegt:{' '}
{Object.keys(mergedPicks)
.map((pk) => {
const po = parseInt(pk, 10)
const so = mergedPicks[pk]
const o = streamFocusOptions.find((x) => x.phaseOrder === po && x.streamOrder === so)
return o?.label ?? `Phase ${po} · Stream ${so}`
})
.join(' · ')}
</span>
) : null}
</p>
) : null}
</header>
{outlineOpen && (
<div className="card training-coach-outline" style={{ flexShrink: 0, marginBottom: '8px', padding: '10px 12px', maxHeight: 'min(28vh, 260px)', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ fontSize: '0.75rem', color: 'var(--text3)', marginBottom: '6px', flexShrink: 0 }}>Ablauf · Antippen zum Springen</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', overflowY: 'auto', minHeight: 0 }}>
{timeline.map((ent, ix) => {
const lbl = summarizeTimelineEntry(ent)
const ctx = ent.coachContext || ''
const active = coachDebriefPhase ? ix === timeline.length - 1 : ix === step
return (
<button
key={`${itemStableKey(ent.item, ent.secOrder, ent.ii)}-${ix}`}
type="button"
className={`btn ${active ? 'btn-primary' : 'btn-secondary'}`}
<div style={{ fontSize: '0.75rem', color: 'var(--text3)', marginBottom: '6px', flexShrink: 0 }}>
Trainingsrahmen · nach Blöcken und Streams
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', overflowY: 'auto', minHeight: 0 }}>
{outlineGroups.map((grp) => (
<div key={grp.mergeKey} style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<div
style={{
textAlign: 'left',
justifyContent: 'flex-start',
opacity: active ? 1 : 0.92,
fontWeight: active ? 700 : 500
}}
onClick={() => {
setCoachDebriefPhase(false)
setStep(ix)
fontSize: '0.72rem',
fontWeight: 700,
color: 'var(--accent-dark)',
textTransform: 'uppercase',
letterSpacing: '0.04em',
}}
>
{ctx ? (
<span style={{ opacity: 0.8, display: 'block', fontSize: '0.72rem', marginBottom: '2px' }}>
{ctx.length > 42 ? `${ctx.slice(0, 40)}` : ctx}
</span>
) : null}
<span>{lbl}</span>
</button>
)
})}
{grp.heading}
{grp.sub ? <span style={{ fontWeight: 600, color: 'var(--text3)', textTransform: 'none' }}> · {grp.sub}</span> : null}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{grp.entries.map(({ ix, ent }) => {
const lbl = summarizeTimelineEntry(ent)
const ctx = ent.coachContext || ''
const active = coachDebriefPhase ? ix === timeline.length - 1 : ix === step
const rowKey =
ent.entryKind === COACH_ENTRY_BRANCH_GATE
? `gate-${ix}`
: `${itemStableKey(ent.item, ent.secOrder, ent.ii)}-${ix}`
return (
<button
key={rowKey}
type="button"
className={`btn ${active ? 'btn-primary' : 'btn-secondary'}`}
style={{
textAlign: 'left',
justifyContent: 'flex-start',
opacity: active ? 1 : 0.92,
fontWeight: active ? 700 : 500,
}}
onClick={() => {
setCoachDebriefPhase(false)
setStep(ix)
}}
>
{ctx ? (
<span style={{ opacity: 0.8, display: 'block', fontSize: '0.72rem', marginBottom: '2px' }}>
{ctx.length > 42 ? `${ctx.slice(0, 40)}` : ctx}
</span>
) : null}
<span>{lbl}</span>
</button>
)
})}
</div>
</div>
))}
</div>
</div>
)}
@ -680,7 +833,7 @@ export default function TrainingCoachPage() {
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '12px' }}>
{timeline
.filter((e) => e.item.item_type === 'exercise')
.filter((e) => e.item?.item_type === 'exercise')
.map((ent) => {
const k = itemStableKey(ent.item, ent.secOrder, ent.ii)
const val = deltas[k]?.actual_duration_min ?? ent.item.actual_duration_min ?? ''
@ -765,7 +918,12 @@ export default function TrainingCoachPage() {
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--accent-dark)', marginBottom: '6px' }}>
Als Nächstes
</div>
{nextEntry ? (
{atBranchGate ? (
<p style={{ margin: 0, fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45 }}>
Wählen Sie unten eine Gruppe. Danach zeigt der Coach fortlaufend nur die Übungen dieses Streams in dieser
parallelen Phase.
</p>
) : nextEntry ? (
<>
<p style={{ margin: '0', fontSize: '0.9rem', color: 'var(--text1)', lineHeight: 1.4 }}>
<strong>Nächste:</strong> {summarizeTimelineEntry(nextEntry)}
@ -802,10 +960,70 @@ export default function TrainingCoachPage() {
showJumpToTimerOwnerRow
onJumpToTimerOwner={() => setStep(timerOwningStep ?? step)}
timerOwnerLabelIndex={timerOwningStep ?? 0}
branchGateMode={atBranchGate}
/>
<div className="training-coach-scroll">
{currentEntry?.item?.item_type === 'note' ? (
{atBranchGate && currentEntry?.branchMeta ? (
<div
className="card"
style={{
padding: '16px 14px',
marginBottom: '12px',
borderRadius: '12px',
borderLeft: '4px solid var(--accent)',
background: 'var(--surface)',
}}
>
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text3)', marginBottom: '8px' }}>
Parallele Phase · Coaching-Zweig
</div>
<p style={{ fontSize: '1.02rem', fontWeight: 700, margin: '0 0 8px', color: 'var(--accent-dark)' }}>
{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>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{(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}`
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 }}
onClick={() => pickStreamForPhase(currentEntry.branchMeta.phaseOrderIndex, st.streamOrder)}
>
<span style={{ display: 'block' }}>{label}</span>
<span
style={{
display: 'block',
fontSize: '0.82rem',
fontWeight: 500,
opacity: 0.9,
marginTop: '4px',
}}
>
ca. {st.minutes} Min. (Üb.) · Antippen zum Fortfahren
</span>
{hinted ? (
<span style={{ display: 'block', fontSize: '0.75rem', marginTop: '6px', opacity: 0.95 }}>
Hinweis aus Planansicht bei Bedarf andere Gruppe wählen.
</span>
) : null}
</button>
)
})}
</div>
</div>
) : 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{' '}
@ -907,7 +1125,7 @@ export default function TrainingCoachPage() {
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '12px' }}>
{timeline
.filter((e) => e.item.item_type === 'exercise')
.filter((e) => e.item?.item_type === 'exercise')
.map((ent) => {
const k = itemStableKey(ent.item, ent.secOrder, ent.ii)
const val = deltas[k]?.actual_duration_min ?? ent.item.actual_duration_min ?? ''
@ -980,6 +1198,7 @@ export default function TrainingCoachPage() {
showJumpToTimerOwnerRow={false}
onJumpToTimerOwner={() => setStep(timerOwningStep ?? step)}
timerOwnerLabelIndex={timerOwningStep ?? 0}
branchGateMode={atBranchGate}
/>
</>
)}

View File

@ -653,7 +653,7 @@ export default function TrainingUnitRunPage() {
</span>
<Link
className="no-print"
to={`/planning/run/${unitId}/coach?po=${run.phaseOrderIndex}&so=${st.streamOrder}`}
to={`/planning/run/${unitId}/coach?atBranch=${run.phaseOrderIndex}&preferSo=${st.streamOrder}`}
style={{
fontSize: '0.78rem',
fontWeight: 600,
@ -663,7 +663,7 @@ export default function TrainingUnitRunPage() {
whiteSpace: 'nowrap',
}}
>
Coach · nur diese Gruppe
Coach · Split-Punkt (Vorschlag)
</Link>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>

View File

@ -208,16 +208,141 @@ function coachContextLabelForSection(sec, sectionsList) {
return `Parallel · ${pt} · ${st}`
}
/** Flache Reihenfolge für Coach-Timeline.
* @param {object|null} coachFocus `{ phaseOrder, streamOrder }` = nur dieser Stream in dieser parallelen Phase; andere Split-Phasen weiterhin voll (alle Streams verschränkt). Null = Gesamtplan.*/
export function flattenPlanTimeline(unit, coachFocus = null) {
export const COACH_ENTRY_BRANCH_GATE = 'branch_gate'
/** Normalisierte Stream-Wahl pro paralleler Phase (Phase-Index → Stream-Order). */
export function normalizeCoachBranchPicks(raw) {
const out = {}
if (!raw || typeof raw !== 'object') return out
for (const [k, v] of Object.entries(raw)) {
const pk = parseInt(String(k), 10)
const sv = typeof v === 'number' ? v : parseInt(String(v), 10)
if (Number.isFinite(pk) && Number.isFinite(sv)) out[pk] = sv
}
return out
}
/**
* URL-/Dropdown-Fokus `coachFocus` in Pick-Map mergen (fest gewählter Stream für eine Phase).
* @param {object} branchPicks Roh-Picks (z. B. aus Session)
* @param {{ phaseOrder: number, streamOrder: number }|null} coachFocusUrl z. B. ?po=&so=
*/
export function mergeCoachBranchPicksWithUrlFocus(branchPicks, coachFocusUrl) {
const m = normalizeCoachBranchPicks(branchPicks)
if (
coachFocusUrl != null &&
Number.isFinite(coachFocusUrl.phaseOrder) &&
Number.isFinite(coachFocusUrl.streamOrder)
) {
m[coachFocusUrl.phaseOrder] = coachFocusUrl.streamOrder
}
return m
}
/** Kurzstring für SessionStorage-Schlüssel (Sortierung stabil). */
export function coachBranchPicksStepStorageSuffix(mergedPicks) {
const keys = Object.keys(mergedPicks)
.map((k) => parseInt(String(k), 10))
.filter((n) => Number.isFinite(n))
.sort((a, b) => a - b)
if (!keys.length) return 'full'
return keys.map((k) => `p${k}-s${mergedPicks[k]}`).join('_')
}
export function coachBranchPicksStorageKey(unitId) {
return `sj_coach_branches_${unitId}`
}
/**
* Index zum Springen: Co-Trainer-Link atBranch+preferSo Gate falls noch offen, sonst erste Kachel im Stream.
*/
export function findCoachTimelineJumpIndexForPhase(timeline, phaseOrder, preferStreamOrder = null) {
const po = Number(phaseOrder)
if (!Number.isFinite(po) || !Array.isArray(timeline)) return -1
const hint = preferStreamOrder != null && Number.isFinite(Number(preferStreamOrder)) ? Number(preferStreamOrder) : null
if (hint != null) {
const ixStream = timeline.findIndex(
(e) =>
e.entryKind !== COACH_ENTRY_BRANCH_GATE &&
e.runMeta?.kind === 'parallel' &&
e.runMeta.phaseOrderIndex === po &&
e.runMeta.streamOrder === hint
)
if (ixStream >= 0) return ixStream
}
const ixGate = timeline.findIndex(
(e) => e.entryKind === COACH_ENTRY_BRANCH_GATE && e.branchMeta?.phaseOrderIndex === po
)
return ixGate
}
/** Gruppiert die flache Coach-Timeline für den Trainingsrahmen (Überschriften + Einträge). */
export function coachOutlineGroupsFromTimeline(timeline) {
const groups = []
for (let ix = 0; ix < timeline.length; ix++) {
const ent = timeline[ix]
let mergeKey = `ix-${ix}`
let heading = 'Ablauf'
let sub = ''
if (ent.entryKind === COACH_ENTRY_BRANCH_GATE) {
const po = ent.branchMeta?.phaseOrderIndex ?? 0
mergeKey = `gate-${po}`
heading = 'Split · Gruppe wählen'
const pt = ent.branchMeta?.phaseTitle
sub =
pt != null && String(pt).trim()
? String(pt).trim()
: `Parallele Phase ${po}`
} else {
const rm = ent.runMeta
if (rm?.kind === 'whole_group') {
mergeKey = `wg-${rm.phaseOrderIndex}`
const pl = ent.sec?.planLoc
const ptt = pl?.phaseTitle
heading = 'Ganzgruppe'
sub = ptt != null && String(ptt).trim() ? String(ptt).trim() : ''
} else if (rm?.kind === 'parallel' && rm.streamOrder != null) {
mergeKey = `par-${rm.phaseOrderIndex}-s${rm.streamOrder}`
const pl = ent.sec?.planLoc
const ptt = pl?.phaseTitle
const stt = pl?.streamTitle
const ptl = ptt != null && String(ptt).trim() ? String(ptt).trim() : `Phase ${rm.phaseOrderIndex}`
const stl = stt != null && String(stt).trim() ? String(stt).trim() : `Gruppe ${rm.streamOrder + 1}`
heading = `Parallel · ${ptl}`
sub = stl
} else if (rm?.kind === 'legacy') {
mergeKey = 'legacy'
heading = 'Ablauf'
sub = ''
} else {
mergeKey = `misc-${ix}`
}
}
const prev = groups[groups.length - 1]
if (!prev || prev.mergeKey !== mergeKey) {
groups.push({ mergeKey, heading, sub, entries: [{ ix, ent }] })
} else {
prev.entries.push({ ix, ent })
}
}
return groups
}
/**
* Flache Coach-Reihenfolge. Pro paralleler Phase ohne Eintrag in branchPicks: ein sichtbarer branch_gate,
* damit keine verschränkten Split-Übungen, bis eine Gruppe gewählt wurde.
* @param {object} unit
* @param {object} branchPicks z. B. { 0: 1 } = Phase 0 Stream 1
*/
export function flattenPlanTimeline(unit, branchPicks = {}) {
const sections = sectionsWithPlanLocForDisplay(unit)
const model = buildPlanRunViewModelFromSections(sections)
if (model.mode === 'empty') return []
const picks = normalizeCoachBranchPicks(branchPicks)
const list = []
const pushSectionItems = (sec, coachCtx) => {
const pushSectionItems = (sec, coachCtx, runMeta) => {
const si = Math.max(0, sections.indexOf(sec))
const secOrder = sec.order_index ?? si
sortedItems(sec).forEach((item, ii) => {
@ -229,28 +354,53 @@ export function flattenPlanTimeline(unit, coachFocus = null) {
sec,
item,
coachContext: coachCtx,
runMeta: runMeta || null,
})
})
}
const f = coachFocus
for (const run of model.runs) {
if (run.kind === 'legacy' || run.kind === 'whole_group') {
if (run.kind === 'legacy') {
const meta = { kind: 'legacy', phaseOrderIndex: 0, streamOrder: null }
for (const sec of run.globalOrderSections) {
pushSectionItems(sec, coachContextLabelForSection(sec, sections))
pushSectionItems(sec, coachContextLabelForSection(sec, sections), meta)
}
continue
}
if (run.kind === 'whole_group') {
const meta = { kind: 'whole_group', phaseOrderIndex: run.phaseOrderIndex, streamOrder: null }
for (const sec of run.globalOrderSections) {
pushSectionItems(sec, coachContextLabelForSection(sec, sections), meta)
}
continue
}
if (run.kind === 'parallel') {
if (f == null || run.phaseOrderIndex !== f.phaseOrder) {
for (const sec of run.globalOrderSections) {
pushSectionItems(sec, coachContextLabelForSection(sec, sections))
}
const po = run.phaseOrderIndex
const chosen = picks[po]
const hasPick = chosen !== undefined && chosen !== null && Number.isFinite(Number(chosen))
if (!hasPick) {
list.push({
entryKind: COACH_ENTRY_BRANCH_GATE,
si: -1,
ii: -1,
secOrder: -1,
flatIndex: list.length,
sec: null,
item: null,
coachContext: '',
branchMeta: {
phaseOrderIndex: po,
phaseTitle: run.phaseTitle,
streams: run.streams || [],
},
runMeta: { kind: 'parallel', phaseOrderIndex: po, streamOrder: null },
})
} else {
const st = run.streams?.find((x) => x.streamOrder === f.streamOrder)
const st = run.streams?.find((x) => x.streamOrder === Number(chosen))
const meta = { kind: 'parallel', phaseOrderIndex: po, streamOrder: Number(chosen) }
if (st?.sections?.length) {
for (const sec of st.sections) {
pushSectionItems(sec, coachContextLabelForSection(sec, sections))
pushSectionItems(sec, coachContextLabelForSection(sec, sections), meta)
}
}
}
@ -305,7 +455,15 @@ export function durationOverridesMapFromDeltas(unit, deltas) {
return out
}
export function summarizeTimelineEntry({ item }) {
export function summarizeTimelineEntry(ent) {
if (!ent) return ''
if (ent.entryKind === COACH_ENTRY_BRANCH_GATE) {
const t = ent.branchMeta?.phaseTitle != null && String(ent.branchMeta.phaseTitle).trim()
? String(ent.branchMeta.phaseTitle).trim()
: ''
return t ? `Split wählen · ${t}` : 'Split · Gruppe wählen'
}
const { item } = ent
if (!item) return ''
if (item.item_type === 'note') {
const t = String(item.note_body || '').trim()