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.
|
* Coach-Modus: eine Position nach der anderen mit Assistentenhinweisen, Zeitnahme und optionaler Nachbereitung.
|
||||||
* Timeline: flach in Phasen-/Stream-Reihenfolge (flattenPlanTimeline).
|
* Timeline: flach in Phasen-/Stream-Reihenfolge (flattenPlanTimeline).
|
||||||
*/
|
*/
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||||
import api from '../utils/api'
|
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 {
|
||||||
|
durationOverridesMapFromDeltas,
|
||||||
flattenPlanTimeline,
|
flattenPlanTimeline,
|
||||||
itemStableKey,
|
itemStableKey,
|
||||||
|
listCoachStreamFocusOptions,
|
||||||
sectionsToPutPayload,
|
sectionsToPutPayload,
|
||||||
summarizeTimelineEntry,
|
summarizeTimelineEntry,
|
||||||
} from '../utils/trainingPlanUtils'
|
} from '../utils/trainingPlanUtils'
|
||||||
|
|
||||||
function storageStepKey(unitId) {
|
function storageStepKey(unitId, coachFocus) {
|
||||||
return `sj_coach_step_${unitId}`
|
if (coachFocus == null) return `sj_coach_step_${unitId}_full`
|
||||||
|
return `sj_coach_step_${unitId}_po${coachFocus.phaseOrder}-so${coachFocus.streamOrder}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function storageDeltasKey(unitId) {
|
function storageDeltasKey(unitId) {
|
||||||
|
|
@ -156,8 +159,11 @@ function CoachControlsBand({
|
||||||
export default function TrainingCoachPage() {
|
export default function TrainingCoachPage() {
|
||||||
const { unitId } = useParams()
|
const { unitId } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const idNum = unitId ? parseInt(unitId, 10) : NaN
|
const idNum = unitId ? parseInt(unitId, 10) : NaN
|
||||||
|
|
||||||
|
const coachFocusResetRef = useRef(null)
|
||||||
|
|
||||||
const [unit, setUnit] = useState(null)
|
const [unit, setUnit] = useState(null)
|
||||||
const [loadError, setLoadError] = useState(null)
|
const [loadError, setLoadError] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
@ -188,12 +194,35 @@ export default function TrainingCoachPage() {
|
||||||
setUnit(u)
|
setUnit(u)
|
||||||
}, [idNum])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!unitId || Number.isNaN(idNum)) {
|
if (!unitId || Number.isNaN(idNum)) {
|
||||||
setLoadError('Ungültige Trainingseinheit')
|
setLoadError('Ungültige Trainingseinheit')
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
coachFocusResetRef.current = null
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
;(async () => {
|
;(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -201,12 +230,6 @@ export default function TrainingCoachPage() {
|
||||||
try {
|
try {
|
||||||
await reloadUnit()
|
await reloadUnit()
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
try {
|
|
||||||
const s = parseInt(sessionStorage.getItem(storageStepKey(idNum)), 10)
|
|
||||||
if (!Number.isNaN(s) && s >= 0) setStep(s)
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const raw = sessionStorage.getItem(storageDeltasKey(idNum))
|
const raw = sessionStorage.getItem(storageDeltasKey(idNum))
|
||||||
if (raw) {
|
if (raw) {
|
||||||
|
|
@ -233,8 +256,26 @@ export default function TrainingCoachPage() {
|
||||||
}, [unitId, idNum, reloadUnit])
|
}, [unitId, idNum, reloadUnit])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sessionStorage.setItem(storageStepKey(idNum), String(step))
|
if (!unit) return
|
||||||
}, [idNum, step])
|
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(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -258,11 +299,17 @@ export default function TrainingCoachPage() {
|
||||||
return () => clearInterval(iv)
|
return () => clearInterval(iv)
|
||||||
}, [runStartAt])
|
}, [runStartAt])
|
||||||
|
|
||||||
const timeline = useMemo(() => flattenPlanTimeline(unit), [unit])
|
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)))
|
||||||
|
|
||||||
|
const timerReset = useCallback(() => {
|
||||||
|
setRunStartAt(null)
|
||||||
|
setPausedAccumMs(0)
|
||||||
|
setTimerOwningStep(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!unit) return
|
if (!unit) return
|
||||||
if (timeline.length === 0) {
|
if (timeline.length === 0) {
|
||||||
|
|
@ -278,6 +325,33 @@ export default function TrainingCoachPage() {
|
||||||
setStep(timeline.length - 1)
|
setStep(timeline.length - 1)
|
||||||
}, [coachDebriefPhase, unit, timeline.length])
|
}, [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 =
|
const elapsedMs =
|
||||||
pausedAccumMs + (runStartAt != null ? Date.now() - runStartAt : 0)
|
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 applySuggestedDuration = () => {
|
||||||
const idx = timerOwningStep != null ? timerOwningStep : step
|
const idx = timerOwningStep != null ? timerOwningStep : step
|
||||||
const ent = timeline[idx]
|
const ent = timeline[idx]
|
||||||
|
|
@ -352,20 +420,7 @@ export default function TrainingCoachPage() {
|
||||||
setStep((s) => clampStep(s + 1, timeline.length))
|
setStep((s) => clampStep(s + 1, timeline.length))
|
||||||
}
|
}
|
||||||
|
|
||||||
const durationOverridesForApi = useMemo(() => {
|
const durationOverridesForApi = useMemo(() => durationOverridesMapFromDeltas(unit, deltas), [unit, deltas])
|
||||||
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])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const item = currentEntry?.item
|
const item = currentEntry?.item
|
||||||
|
|
@ -487,6 +542,36 @@ export default function TrainingCoachPage() {
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => navigate('/planning')}>
|
<button type="button" className="btn btn-secondary" onClick={() => navigate('/planning')}>
|
||||||
Planung
|
Planung
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
|
|
@ -497,6 +582,21 @@ export default function TrainingCoachPage() {
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</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
|
<header
|
||||||
className="card training-coach-hero training-coach-hero--compact"
|
className="card training-coach-hero training-coach-hero--compact"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -517,6 +617,15 @@ 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 ? (
|
||||||
|
<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>
|
</header>
|
||||||
|
|
||||||
{outlineOpen && (
|
{outlineOpen && (
|
||||||
|
|
|
||||||
|
|
@ -638,12 +638,33 @@ export default function TrainingUnitRunPage() {
|
||||||
fontSize: '0.88rem',
|
fontSize: '0.88rem',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: 'hsl(200 30% 28%)',
|
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>
|
||||||
<span style={{ fontWeight: 500, marginLeft: '8px', opacity: 0.85 }}>
|
{st.streamTitle ? String(st.streamTitle).trim() : `Gruppe ${st.streamOrder + 1}`}
|
||||||
· ca. {st.minutes} Min. (Üb.)
|
<span style={{ fontWeight: 500, marginLeft: '8px', opacity: 0.85 }}>
|
||||||
|
· ca. {st.minutes} Min. (Üb.)
|
||||||
|
</span>
|
||||||
</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>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
{st.sections.map((sec) => renderSectionCard(sec, siUnit(sec)))}
|
{st.sections.map((sec) => renderSectionCard(sec, siUnit(sec)))}
|
||||||
|
|
|
||||||
|
|
@ -208,8 +208,9 @@ function coachContextLabelForSection(sec, sectionsList) {
|
||||||
return `Parallel · ${pt} · ${st}`
|
return `Parallel · ${pt} · ${st}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Flache Reihenfolge für Coach-Timeline (global wie im Editor, inkl. gemischter Split-Abschnitte). */
|
/** Flache Reihenfolge für Coach-Timeline.
|
||||||
export function flattenPlanTimeline(unit) {
|
* @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 sections = sectionsWithPlanLocForDisplay(unit)
|
||||||
const model = buildPlanRunViewModelFromSections(sections)
|
const model = buildPlanRunViewModelFromSections(sections)
|
||||||
if (model.mode === 'empty') return []
|
if (model.mode === 'empty') return []
|
||||||
|
|
@ -232,15 +233,78 @@ export function flattenPlanTimeline(unit) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const f = coachFocus
|
||||||
for (const run of model.runs) {
|
for (const run of model.runs) {
|
||||||
for (const sec of run.globalOrderSections) {
|
if (run.kind === 'legacy' || run.kind === 'whole_group') {
|
||||||
pushSectionItems(sec, coachContextLabelForSection(sec, sections))
|
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
|
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 }) {
|
export function summarizeTimelineEntry({ item }) {
|
||||||
if (!item) return ''
|
if (!item) return ''
|
||||||
if (item.item_type === 'note') {
|
if (item.item_type === 'note') {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user