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

View File

@ -653,7 +653,7 @@ export default function TrainingUnitRunPage() {
</span> </span>
<Link <Link
className="no-print" 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={{ style={{
fontSize: '0.78rem', fontSize: '0.78rem',
fontWeight: 600, fontWeight: 600,
@ -663,7 +663,7 @@ export default function TrainingUnitRunPage() {
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}} }}
> >
Coach · nur diese Gruppe Coach · Split-Punkt (Vorschlag)
</Link> </Link>
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>

View File

@ -208,16 +208,141 @@ function coachContextLabelForSection(sec, sectionsList) {
return `Parallel · ${pt} · ${st}` return `Parallel · ${pt} · ${st}`
} }
/** Flache Reihenfolge für Coach-Timeline. export const COACH_ENTRY_BRANCH_GATE = 'branch_gate'
* @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) { /** 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 sections = sectionsWithPlanLocForDisplay(unit)
const model = buildPlanRunViewModelFromSections(sections) const model = buildPlanRunViewModelFromSections(sections)
if (model.mode === 'empty') return [] if (model.mode === 'empty') return []
const picks = normalizeCoachBranchPicks(branchPicks)
const list = [] const list = []
const pushSectionItems = (sec, coachCtx) => { const pushSectionItems = (sec, coachCtx, runMeta) => {
const si = Math.max(0, sections.indexOf(sec)) const si = Math.max(0, sections.indexOf(sec))
const secOrder = sec.order_index ?? si const secOrder = sec.order_index ?? si
sortedItems(sec).forEach((item, ii) => { sortedItems(sec).forEach((item, ii) => {
@ -229,28 +354,53 @@ export function flattenPlanTimeline(unit, coachFocus = null) {
sec, sec,
item, item,
coachContext: coachCtx, coachContext: coachCtx,
runMeta: runMeta || null,
}) })
}) })
} }
const f = coachFocus
for (const run of model.runs) { 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) { 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 continue
} }
if (run.kind === 'parallel') { if (run.kind === 'parallel') {
if (f == null || run.phaseOrderIndex !== f.phaseOrder) { const po = run.phaseOrderIndex
for (const sec of run.globalOrderSections) { const chosen = picks[po]
pushSectionItems(sec, coachContextLabelForSection(sec, sections)) 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 { } 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) { if (st?.sections?.length) {
for (const sec of st.sections) { 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 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) return ''
if (item.item_type === 'note') { if (item.item_type === 'note') {
const t = String(item.note_body || '').trim() const t = String(item.note_body || '').trim()