From 4cf7133bce17fbdd24dd8895af1a62f1d006f1e2 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 15 May 2026 16:08:01 +0200 Subject: [PATCH] Enhance TrainingCoachPage and TrainingUnitRunPage with improved coach focus handling and UI updates - 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. --- frontend/src/pages/TrainingCoachPage.jsx | 175 +++++++++++++++++---- frontend/src/pages/TrainingUnitRunPage.jsx | 27 +++- frontend/src/utils/trainingPlanUtils.js | 72 ++++++++- 3 files changed, 234 insertions(+), 40 deletions(-) diff --git a/frontend/src/pages/TrainingCoachPage.jsx b/frontend/src/pages/TrainingCoachPage.jsx index 6df96f4..3e2f207 100644 --- a/frontend/src/pages/TrainingCoachPage.jsx +++ b/frontend/src/pages/TrainingCoachPage.jsx @@ -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() { + {streamFocusOptions.length > 0 ? ( + + ) : null}