Enhance TrainingCoachPage and TrainingUnitRunPage with improved coach focus handling and UI updates
All checks were successful
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m8s

- Updated TrainingCoachPage to incorporate coach focus parameters from search queries, allowing for more precise control over displayed training streams.
- Refactored session storage handling to better manage state related to coach focus, ensuring accurate step tracking during training sessions.
- Enhanced TrainingUnitRunPage with improved layout for stream titles and added links for direct navigation to coaching views, improving user experience.
- Introduced new utility functions in trainingPlanUtils for managing coach stream focus options and duration overrides, streamlining data handling across components.
This commit is contained in:
Lars 2026-05-15 16:08:01 +02:00
parent c182ced7cd
commit 4cf7133bce
3 changed files with 234 additions and 40 deletions

View File

@ -2,20 +2,23 @@
* Coach-Modus: eine Position nach der anderen mit Assistentenhinweisen, Zeitnahme und optionaler Nachbereitung.
* Timeline: flach in Phasen-/Stream-Reihenfolge (flattenPlanTimeline).
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import api from '../utils/api'
import ExerciseFullContent from '../components/ExerciseFullContent'
import ExercisePeekModal from '../components/ExercisePeekModal'
import {
durationOverridesMapFromDeltas,
flattenPlanTimeline,
itemStableKey,
listCoachStreamFocusOptions,
sectionsToPutPayload,
summarizeTimelineEntry,
} from '../utils/trainingPlanUtils'
function storageStepKey(unitId) {
return `sj_coach_step_${unitId}`
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 storageDeltasKey(unitId) {
@ -156,8 +159,11 @@ function CoachControlsBand({
export default function TrainingCoachPage() {
const { unitId } = useParams()
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const idNum = unitId ? parseInt(unitId, 10) : NaN
const coachFocusResetRef = useRef(null)
const [unit, setUnit] = useState(null)
const [loadError, setLoadError] = useState(null)
const [loading, setLoading] = useState(true)
@ -188,12 +194,35 @@ export default function TrainingCoachPage() {
setUnit(u)
}, [idNum])
const coachFocus = useMemo(() => {
const poRaw = searchParams.get('po')
const soRaw = searchParams.get('so')
if (poRaw == null || poRaw === '' || soRaw == null || soRaw === '') return null
const po = parseInt(poRaw, 10)
const so = parseInt(soRaw, 10)
if (!Number.isFinite(po) || !Number.isFinite(so)) return null
if (!unit) return null
const opts = listCoachStreamFocusOptions(unit)
if (!opts.some((o) => o.phaseOrder === po && o.streamOrder === so)) return null
return { phaseOrder: po, streamOrder: so }
}, [searchParams, unit])
const streamFocusOptions = useMemo(() => (unit ? listCoachStreamFocusOptions(unit) : []), [unit])
const focusKey = coachFocus ? `${coachFocus.phaseOrder}-${coachFocus.streamOrder}` : 'full'
const hasStreamSelectParams =
(searchParams.get('po') != null && searchParams.get('po') !== '') ||
(searchParams.get('so') != null && searchParams.get('so') !== '')
const streamParamsInvalid = Boolean(unit && hasStreamSelectParams && !coachFocus)
useEffect(() => {
if (!unitId || Number.isNaN(idNum)) {
setLoadError('Ungültige Trainingseinheit')
setLoading(false)
return
}
coachFocusResetRef.current = null
let cancelled = false
;(async () => {
setLoading(true)
@ -201,12 +230,6 @@ export default function TrainingCoachPage() {
try {
await reloadUnit()
if (cancelled) return
try {
const s = parseInt(sessionStorage.getItem(storageStepKey(idNum)), 10)
if (!Number.isNaN(s) && s >= 0) setStep(s)
} catch {
/* ignore */
}
try {
const raw = sessionStorage.getItem(storageDeltasKey(idNum))
if (raw) {
@ -233,8 +256,26 @@ export default function TrainingCoachPage() {
}, [unitId, idNum, reloadUnit])
useEffect(() => {
sessionStorage.setItem(storageStepKey(idNum), String(step))
}, [idNum, step])
if (!unit) return
const po = searchParams.get('po')
const so = searchParams.get('so')
if ((po == null || po === '') && (so == null || so === '')) return
const p = parseInt(po, 10)
const s = parseInt(so, 10)
if (!Number.isFinite(p) || !Number.isFinite(s)) {
setSearchParams({}, { replace: true })
return
}
const opts = listCoachStreamFocusOptions(unit)
if (opts.length === 0 || !opts.some((o) => o.phaseOrder === p && o.streamOrder === s)) {
setSearchParams({}, { replace: true })
}
}, [unit, searchParams, setSearchParams])
useEffect(() => {
if (Number.isNaN(idNum)) return
sessionStorage.setItem(storageStepKey(idNum, coachFocus), String(step))
}, [idNum, coachFocus, step])
useEffect(() => {
try {
@ -258,11 +299,17 @@ export default function TrainingCoachPage() {
return () => clearInterval(iv)
}, [runStartAt])
const timeline = useMemo(() => flattenPlanTimeline(unit), [unit])
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)))
const timerReset = useCallback(() => {
setRunStartAt(null)
setPausedAccumMs(0)
setTimerOwningStep(null)
}, [])
useEffect(() => {
if (!unit) return
if (timeline.length === 0) {
@ -278,6 +325,33 @@ export default function TrainingCoachPage() {
setStep(timeline.length - 1)
}, [coachDebriefPhase, unit, timeline.length])
useEffect(() => {
if (!unit || Number.isNaN(idNum)) return
if (timeline.length === 0) {
setStep(0)
return
}
const prev = coachFocusResetRef.current
if (prev === null) {
coachFocusResetRef.current = focusKey
} else if (prev !== focusKey) {
coachFocusResetRef.current = focusKey
setCoachDebriefPhase(false)
timerReset()
} else {
return
}
try {
const raw = sessionStorage.getItem(storageStepKey(idNum, coachFocus))
const s = parseInt(raw, 10)
const maxIdx = Math.max(0, timeline.length - 1)
if (!Number.isNaN(s) && s >= 0) setStep(Math.min(s, maxIdx))
else setStep(0)
} catch {
setStep(0)
}
}, [unit, idNum, focusKey, coachFocus, timeline.length, timerReset])
const elapsedMs =
pausedAccumMs + (runStartAt != null ? Date.now() - runStartAt : 0)
@ -307,12 +381,6 @@ export default function TrainingCoachPage() {
}
}
const timerReset = () => {
setRunStartAt(null)
setPausedAccumMs(0)
setTimerOwningStep(null)
}
const applySuggestedDuration = () => {
const idx = timerOwningStep != null ? timerOwningStep : step
const ent = timeline[idx]
@ -352,20 +420,7 @@ export default function TrainingCoachPage() {
setStep((s) => clampStep(s + 1, timeline.length))
}
const durationOverridesForApi = useMemo(() => {
const out = {}
for (let i = 0; i < timeline.length; i++) {
const ent = timeline[i]
const { item } = ent
if (item.item_type !== 'exercise' || item.id == null) continue
const k = itemStableKey(item, ent.secOrder, ent.ii)
const dv = deltas[k]?.actual_duration_min
if (dv !== undefined && dv !== '' && dv !== null && !Number.isNaN(Number(dv))) {
out[String(item.id)] = { actual_duration_min: Number(dv) }
}
}
return out
}, [timeline, deltas])
const durationOverridesForApi = useMemo(() => durationOverridesMapFromDeltas(unit, deltas), [unit, deltas])
useEffect(() => {
const item = currentEntry?.item
@ -487,6 +542,36 @@ export default function TrainingCoachPage() {
<button type="button" className="btn btn-secondary" onClick={() => navigate('/planning')}>
Planung
</button>
{streamFocusOptions.length > 0 ? (
<label
className="no-print"
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', fontSize: '0.82rem', color: 'var(--text2)' }}
>
<span style={{ color: 'var(--text3)', whiteSpace: 'nowrap' }}>Ansicht</span>
<select
className="form-input"
style={{ minWidth: 'min(220px, 72vw)', margin: 0, padding: '6px 8px', fontSize: '0.82rem' }}
value={coachFocus ? `${coachFocus.phaseOrder}-${coachFocus.streamOrder}` : ''}
onChange={(e) => {
setCoachDebriefPhase(false)
timerReset()
const v = e.target.value
if (!v) setSearchParams({}, { replace: true })
else {
const [ppo, sso] = v.split('-').map((x) => parseInt(x, 10))
setSearchParams({ po: String(ppo), so: String(sso) }, { replace: true })
}
}}
>
<option value="">Gesamtplan (alle Gruppen)</option>
{streamFocusOptions.map((o) => (
<option key={o.valueKey} value={o.valueKey}>
{o.label}
</option>
))}
</select>
</label>
) : null}
<button
type="button"
className="btn btn-primary"
@ -497,6 +582,21 @@ export default function TrainingCoachPage() {
</button>
</nav>
{streamParamsInvalid ? (
<p
className="no-print card"
style={{
padding: '10px 12px',
fontSize: '0.82rem',
color: 'var(--danger)',
marginBottom: '8px',
flexShrink: 0,
}}
>
Ungültige Stream-Parameter in der Adresszeile es wird der Gesamtplan angezeigt.
</p>
) : null}
<header
className="card training-coach-hero training-coach-hero--compact"
style={{
@ -517,6 +617,15 @@ 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.
</p>
) : null}
</header>
{outlineOpen && (

View File

@ -638,12 +638,33 @@ export default function TrainingUnitRunPage() {
fontSize: '0.88rem',
fontWeight: 700,
color: 'hsl(200 30% 28%)',
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
}}
>
{st.streamTitle ? String(st.streamTitle).trim() : `Gruppe ${st.streamOrder + 1}`}
<span style={{ fontWeight: 500, marginLeft: '8px', opacity: 0.85 }}>
· ca. {st.minutes} Min. (Üb.)
<span>
{st.streamTitle ? String(st.streamTitle).trim() : `Gruppe ${st.streamOrder + 1}`}
<span style={{ fontWeight: 500, marginLeft: '8px', opacity: 0.85 }}>
· ca. {st.minutes} Min. (Üb.)
</span>
</span>
<Link
className="no-print"
to={`/planning/run/${unitId}/coach?po=${run.phaseOrderIndex}&so=${st.streamOrder}`}
style={{
fontSize: '0.78rem',
fontWeight: 600,
color: 'var(--accent-dark)',
textDecoration: 'underline',
textUnderlineOffset: '2px',
whiteSpace: 'nowrap',
}}
>
Coach · nur diese Gruppe
</Link>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{st.sections.map((sec) => renderSectionCard(sec, siUnit(sec)))}

View File

@ -208,8 +208,9 @@ function coachContextLabelForSection(sec, sectionsList) {
return `Parallel · ${pt} · ${st}`
}
/** Flache Reihenfolge für Coach-Timeline (global wie im Editor, inkl. gemischter Split-Abschnitte). */
export function flattenPlanTimeline(unit) {
/** 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) {
const sections = sectionsWithPlanLocForDisplay(unit)
const model = buildPlanRunViewModelFromSections(sections)
if (model.mode === 'empty') return []
@ -232,15 +233,78 @@ export function flattenPlanTimeline(unit) {
})
}
const f = coachFocus
for (const run of model.runs) {
for (const sec of run.globalOrderSections) {
pushSectionItems(sec, coachContextLabelForSection(sec, sections))
if (run.kind === 'legacy' || run.kind === 'whole_group') {
for (const sec of run.globalOrderSections) {
pushSectionItems(sec, coachContextLabelForSection(sec, sections))
}
continue
}
if (run.kind === 'parallel') {
if (f == null || run.phaseOrderIndex !== f.phaseOrder) {
for (const sec of run.globalOrderSections) {
pushSectionItems(sec, coachContextLabelForSection(sec, sections))
}
} else {
const st = run.streams?.find((x) => x.streamOrder === f.streamOrder)
if (st?.sections?.length) {
for (const sec of st.sections) {
pushSectionItems(sec, coachContextLabelForSection(sec, sections))
}
}
}
}
}
return list
}
/** Optionen für Coach-Stream-Auswahl (phases · Gruppe). */
export function listCoachStreamFocusOptions(unit) {
const model = buildPlanRunViewModelFromSections(sectionsWithPlanLocForDisplay(unit))
const opts = []
for (const run of model.runs) {
if (run.kind !== 'parallel' || !run.streams?.length) continue
const phaseLabel =
run.phaseTitle != null && String(run.phaseTitle).trim()
? String(run.phaseTitle).trim()
: `Phase ${run.phaseOrderIndex}`
for (const st of run.streams) {
const gl =
st.streamTitle != null && String(st.streamTitle).trim()
? String(st.streamTitle).trim()
: `Gruppe ${st.streamOrder + 1}`
opts.push({
phaseOrder: run.phaseOrderIndex,
streamOrder: st.streamOrder,
label: `${phaseLabel} · ${gl}`,
valueKey: `${run.phaseOrderIndex}-${st.streamOrder}`,
})
}
}
return opts
}
/** Alle Ist-Minuten-Overrides aus lokalem Delta-State für PUT (unabhängig von gefilterter Coach-Timeline). */
export function durationOverridesMapFromDeltas(unit, deltas) {
const out = {}
if (!unit || !deltas || typeof deltas !== 'object') return out
const sections = sortedSections(unit)
sections.forEach((sec, si) => {
const secOrder = sec.order_index ?? si
sortedItems(sec).forEach((it, ii) => {
if (it.item_type !== 'exercise' || it.id == null) return
const k = itemStableKey(it, secOrder, ii)
const dv = deltas[k]?.actual_duration_min
if (dv !== undefined && dv !== '' && dv !== null && !Number.isNaN(Number(dv))) {
out[String(it.id)] = { actual_duration_min: Number(dv) }
}
})
})
return out
}
export function summarizeTimelineEntry({ item }) {
if (!item) return ''
if (item.item_type === 'note') {