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
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:
parent
4cf7133bce
commit
352237bbb9
|
|
@ -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} 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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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' }}>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user