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