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.
|
* Coach-Modus: Schrittfolge mit Split-Punkten (branch_gate), Stream-Wahl pro paralleler Phase, Assistenz und Zeitnahme.
|
||||||
* Timeline: flach in Phasen-/Stream-Reihenfolge (flattenPlanTimeline).
|
|
||||||
*/
|
*/
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||||
|
|
@ -8,17 +7,23 @@ 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 {
|
||||||
|
COACH_ENTRY_BRANCH_GATE,
|
||||||
|
coachBranchPicksStepStorageSuffix,
|
||||||
|
coachBranchPicksStorageKey,
|
||||||
|
coachOutlineGroupsFromTimeline,
|
||||||
durationOverridesMapFromDeltas,
|
durationOverridesMapFromDeltas,
|
||||||
|
findCoachTimelineJumpIndexForPhase,
|
||||||
flattenPlanTimeline,
|
flattenPlanTimeline,
|
||||||
itemStableKey,
|
itemStableKey,
|
||||||
listCoachStreamFocusOptions,
|
listCoachStreamFocusOptions,
|
||||||
|
mergeCoachBranchPicksWithUrlFocus,
|
||||||
|
normalizeCoachBranchPicks,
|
||||||
sectionsToPutPayload,
|
sectionsToPutPayload,
|
||||||
summarizeTimelineEntry,
|
summarizeTimelineEntry,
|
||||||
} from '../utils/trainingPlanUtils'
|
} from '../utils/trainingPlanUtils'
|
||||||
|
|
||||||
function storageStepKey(unitId, coachFocus) {
|
function storageStepKey(unitId, mergedPicks) {
|
||||||
if (coachFocus == null) return `sj_coach_step_${unitId}_full`
|
return `sj_coach_step_${unitId}_${coachBranchPicksStepStorageSuffix(mergedPicks)}`
|
||||||
return `sj_coach_step_${unitId}_po${coachFocus.phaseOrder}-so${coachFocus.streamOrder}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function storageDeltasKey(unitId) {
|
function storageDeltasKey(unitId) {
|
||||||
|
|
@ -58,14 +63,16 @@ function CoachControlsBand({
|
||||||
showJumpToTimerOwnerRow = true,
|
showJumpToTimerOwnerRow = true,
|
||||||
onJumpToTimerOwner,
|
onJumpToTimerOwner,
|
||||||
timerOwnerLabelIndex,
|
timerOwnerLabelIndex,
|
||||||
|
branchGateMode = false,
|
||||||
}) {
|
}) {
|
||||||
const disPrev = step <= 0
|
const disPrev = step <= 0
|
||||||
const disNext = step >= timelineLength - 1
|
const disNext = branchGateMode || step >= timelineLength - 1
|
||||||
const alive = runStartAt != null || pausedAccumMs > 0
|
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 istLabelMin = roundedMinForApply == null ? '—' : String(roundedMinForApply)
|
||||||
const doneLabel =
|
const doneLabel = branchGateMode
|
||||||
timelineLength <= 1
|
? 'Zuerst Gruppe wählen'
|
||||||
|
: timelineLength <= 1
|
||||||
? 'Nachbereitung öffnen'
|
? 'Nachbereitung öffnen'
|
||||||
: isLastCoachStep
|
: isLastCoachStep
|
||||||
? 'Nachbereitung & Ist-Zeit'
|
? 'Nachbereitung & Ist-Zeit'
|
||||||
|
|
@ -109,6 +116,7 @@ function CoachControlsBand({
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
style={{ minHeight: '44px', flex: '1 1 auto', fontWeight: 700 }}
|
style={{ minHeight: '44px', flex: '1 1 auto', fontWeight: 700 }}
|
||||||
|
disabled={branchGateMode}
|
||||||
onClick={onTimerStart}
|
onClick={onTimerStart}
|
||||||
>
|
>
|
||||||
▶ Start{alive ? ` · ${clockStr}` : ''}
|
▶ 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}>
|
<button type="button" className="btn btn-secondary" style={{ minHeight: '44px', flex: '0 1 auto', fontWeight: 600 }} disabled={!canApplyForOwner} onClick={onApplyActual}>
|
||||||
Ist ({istLabelMin} Min)
|
Ist ({istLabelMin} Min)
|
||||||
</button>
|
</button>
|
||||||
{alive && (
|
{alive && !branchGateMode && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
|
|
@ -137,6 +145,7 @@ function CoachControlsBand({
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
style={{ width: '100%', minHeight: '44px', fontWeight: 700, lineHeight: 1.2, padding: '6px 10px', whiteSpace: 'normal', textAlign: 'center' }}
|
style={{ width: '100%', minHeight: '44px', fontWeight: 700, lineHeight: 1.2, padding: '6px 10px', whiteSpace: 'normal', textAlign: 'center' }}
|
||||||
|
disabled={branchGateMode}
|
||||||
onClick={onDone}
|
onClick={onDone}
|
||||||
>
|
>
|
||||||
{doneLabel}
|
{doneLabel}
|
||||||
|
|
@ -176,6 +185,8 @@ export default function TrainingCoachPage() {
|
||||||
const [coachDebriefPhase, setCoachDebriefPhase] = useState(false)
|
const [coachDebriefPhase, setCoachDebriefPhase] = useState(false)
|
||||||
|
|
||||||
const [step, setStep] = useState(0)
|
const [step, setStep] = useState(0)
|
||||||
|
const [branchPicks, setBranchPicks] = useState({})
|
||||||
|
const [streamChoiceHint, setStreamChoiceHint] = useState(null)
|
||||||
const [deltas, setDeltas] = useState({})
|
const [deltas, setDeltas] = useState({})
|
||||||
|
|
||||||
const [runStartAt, setRunStartAt] = useState(null)
|
const [runStartAt, setRunStartAt] = useState(null)
|
||||||
|
|
@ -207,15 +218,32 @@ export default function TrainingCoachPage() {
|
||||||
return { phaseOrder: po, streamOrder: so }
|
return { phaseOrder: po, streamOrder: so }
|
||||||
}, [searchParams, unit])
|
}, [searchParams, unit])
|
||||||
|
|
||||||
|
const mergedPicks = useMemo(
|
||||||
|
() => mergeCoachBranchPicksWithUrlFocus(branchPicks, coachFocus),
|
||||||
|
[branchPicks, coachFocus]
|
||||||
|
)
|
||||||
|
|
||||||
const streamFocusOptions = useMemo(() => (unit ? listCoachStreamFocusOptions(unit) : []), [unit])
|
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 =
|
const hasStreamSelectParams =
|
||||||
(searchParams.get('po') != null && searchParams.get('po') !== '') ||
|
(searchParams.get('po') != null && searchParams.get('po') !== '') ||
|
||||||
(searchParams.get('so') != null && searchParams.get('so') !== '')
|
(searchParams.get('so') != null && searchParams.get('so') !== '')
|
||||||
const streamParamsInvalid = Boolean(unit && hasStreamSelectParams && !coachFocus)
|
const streamParamsInvalid = Boolean(unit && hasStreamSelectParams && !coachFocus)
|
||||||
|
|
||||||
|
const timeline = useMemo(() => flattenPlanTimeline(unit, mergedPicks), [unit, mergedPicks])
|
||||||
|
|
||||||
|
const outlineGroups = useMemo(() => coachOutlineGroupsFromTimeline(timeline), [timeline])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!unitId || Number.isNaN(idNum)) {
|
if (!unitId || Number.isNaN(idNum)) {
|
||||||
setLoadError('Ungültige Trainingseinheit')
|
setLoadError('Ungültige Trainingseinheit')
|
||||||
|
|
@ -230,6 +258,16 @@ export default function TrainingCoachPage() {
|
||||||
try {
|
try {
|
||||||
await reloadUnit()
|
await reloadUnit()
|
||||||
if (cancelled) return
|
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 {
|
try {
|
||||||
const raw = sessionStorage.getItem(storageDeltasKey(idNum))
|
const raw = sessionStorage.getItem(storageDeltasKey(idNum))
|
||||||
if (raw) {
|
if (raw) {
|
||||||
|
|
@ -244,6 +282,10 @@ export default function TrainingCoachPage() {
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
if (!cancelled) {
|
||||||
|
setBranchPicks(nextBranchPicks)
|
||||||
|
setStreamChoiceHint(null)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!cancelled) setLoadError(e.message || 'Laden fehlgeschlagen')
|
if (!cancelled) setLoadError(e.message || 'Laden fehlgeschlagen')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -272,10 +314,52 @@ export default function TrainingCoachPage() {
|
||||||
}
|
}
|
||||||
}, [unit, searchParams, setSearchParams])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (Number.isNaN(idNum)) return
|
if (Number.isNaN(idNum)) return
|
||||||
sessionStorage.setItem(storageStepKey(idNum, coachFocus), String(step))
|
try {
|
||||||
}, [idNum, coachFocus, step])
|
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(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -299,8 +383,6 @@ export default function TrainingCoachPage() {
|
||||||
return () => clearInterval(iv)
|
return () => clearInterval(iv)
|
||||||
}, [runStartAt])
|
}, [runStartAt])
|
||||||
|
|
||||||
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)))
|
||||||
|
|
||||||
|
|
@ -333,16 +415,16 @@ export default function TrainingCoachPage() {
|
||||||
}
|
}
|
||||||
const prev = coachFocusResetRef.current
|
const prev = coachFocusResetRef.current
|
||||||
if (prev === null) {
|
if (prev === null) {
|
||||||
coachFocusResetRef.current = focusKey
|
coachFocusResetRef.current = navigationKey
|
||||||
} else if (prev !== focusKey) {
|
} else if (prev !== navigationKey) {
|
||||||
coachFocusResetRef.current = focusKey
|
coachFocusResetRef.current = navigationKey
|
||||||
setCoachDebriefPhase(false)
|
setCoachDebriefPhase(false)
|
||||||
timerReset()
|
timerReset()
|
||||||
} else {
|
} else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const raw = sessionStorage.getItem(storageStepKey(idNum, coachFocus))
|
const raw = sessionStorage.getItem(storageStepKey(idNum, mergedPicks))
|
||||||
const s = parseInt(raw, 10)
|
const s = parseInt(raw, 10)
|
||||||
const maxIdx = Math.max(0, timeline.length - 1)
|
const maxIdx = Math.max(0, timeline.length - 1)
|
||||||
if (!Number.isNaN(s) && s >= 0) setStep(Math.min(s, maxIdx))
|
if (!Number.isNaN(s) && s >= 0) setStep(Math.min(s, maxIdx))
|
||||||
|
|
@ -350,7 +432,7 @@ export default function TrainingCoachPage() {
|
||||||
} catch {
|
} catch {
|
||||||
setStep(0)
|
setStep(0)
|
||||||
}
|
}
|
||||||
}, [unit, idNum, focusKey, coachFocus, timeline.length, timerReset])
|
}, [unit, idNum, navigationKey, mergedPicks, timeline.length, timerReset])
|
||||||
|
|
||||||
const elapsedMs =
|
const elapsedMs =
|
||||||
pausedAccumMs + (runStartAt != null ? Date.now() - runStartAt : 0)
|
pausedAccumMs + (runStartAt != null ? Date.now() - runStartAt : 0)
|
||||||
|
|
@ -367,7 +449,10 @@ export default function TrainingCoachPage() {
|
||||||
timerOwningStep != null &&
|
timerOwningStep != null &&
|
||||||
step !== timerOwningStep &&
|
step !== timerOwningStep &&
|
||||||
(runStartAt != null || pausedAccumMs > 0)
|
(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 = () => {
|
const timerStart = () => {
|
||||||
setRunStartAt(Date.now())
|
setRunStartAt(Date.now())
|
||||||
|
|
@ -393,12 +478,27 @@ export default function TrainingCoachPage() {
|
||||||
timerPause()
|
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 goPrev = () => setStep((s) => clampStep(s - 1))
|
||||||
const goNext = () => setStep((s) => clampStep(s + 1))
|
const goNext = () => setStep((s) => clampStep(s + 1))
|
||||||
|
|
||||||
const markCurrentDoneAdvance = () => {
|
const markCurrentDoneAdvance = () => {
|
||||||
const ownerIdx = timerOwningStep != null ? timerOwningStep : step
|
const ownerIdx = timerOwningStep != null ? timerOwningStep : step
|
||||||
const ent = timeline[ownerIdx]
|
const ent = timeline[ownerIdx]
|
||||||
|
if (ent?.entryKind === COACH_ENTRY_BRANCH_GATE) return
|
||||||
const item = ent?.item
|
const item = ent?.item
|
||||||
if (item?.item_type === 'exercise' && elapsedMs > 650) {
|
if (item?.item_type === 'exercise' && elapsedMs > 650) {
|
||||||
const key = itemStableKey(item, ent.secOrder, ent.ii)
|
const key = itemStableKey(item, ent.secOrder, ent.ii)
|
||||||
|
|
@ -420,9 +520,20 @@ export default function TrainingCoachPage() {
|
||||||
setStep((s) => clampStep(s + 1, timeline.length))
|
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])
|
const durationOverridesForApi = useMemo(() => durationOverridesMapFromDeltas(unit, deltas), [unit, deltas])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (currentEntry?.entryKind === COACH_ENTRY_BRANCH_GATE) {
|
||||||
|
setCatalogExercise(null)
|
||||||
|
setCatalogError(null)
|
||||||
|
setCatalogLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
const item = currentEntry?.item
|
const item = currentEntry?.item
|
||||||
if (!item || item.item_type === 'note') {
|
if (!item || item.item_type === 'note') {
|
||||||
setCatalogExercise(null)
|
setCatalogExercise(null)
|
||||||
|
|
@ -458,7 +569,7 @@ export default function TrainingCoachPage() {
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
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 () => {
|
const handleSaveDebrief = async () => {
|
||||||
setSaveOk(null)
|
setSaveOk(null)
|
||||||
|
|
@ -482,10 +593,13 @@ export default function TrainingCoachPage() {
|
||||||
try {
|
try {
|
||||||
sessionStorage.removeItem(storageDeltasKey(idNum))
|
sessionStorage.removeItem(storageDeltasKey(idNum))
|
||||||
sessionStorage.removeItem(storageDebriefKey(idNum))
|
sessionStorage.removeItem(storageDebriefKey(idNum))
|
||||||
|
sessionStorage.removeItem(coachBranchPicksStorageKey(idNum))
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
setDeltas({})
|
setDeltas({})
|
||||||
|
setBranchPicks({})
|
||||||
|
setStreamChoiceHint(null)
|
||||||
setCoachDebriefPhase(false)
|
setCoachDebriefPhase(false)
|
||||||
setSaveOk('Gespeichert.')
|
setSaveOk('Gespeichert.')
|
||||||
setDebriefOpen(false)
|
setDebriefOpen(false)
|
||||||
|
|
@ -551,19 +665,22 @@ export default function TrainingCoachPage() {
|
||||||
<select
|
<select
|
||||||
className="form-input"
|
className="form-input"
|
||||||
style={{ minWidth: 'min(220px, 72vw)', margin: 0, padding: '6px 8px', fontSize: '0.82rem' }}
|
style={{ minWidth: 'min(220px, 72vw)', margin: 0, padding: '6px 8px', fontSize: '0.82rem' }}
|
||||||
value={coachFocus ? `${coachFocus.phaseOrder}-${coachFocus.streamOrder}` : ''}
|
value={streamQuickSelectValue}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setCoachDebriefPhase(false)
|
setCoachDebriefPhase(false)
|
||||||
timerReset()
|
timerReset()
|
||||||
const v = e.target.value
|
const v = e.target.value
|
||||||
if (!v) setSearchParams({}, { replace: true })
|
if (!v) {
|
||||||
else {
|
setBranchPicks({})
|
||||||
|
setStreamChoiceHint(null)
|
||||||
|
setSearchParams({}, { replace: true })
|
||||||
|
} else {
|
||||||
const [ppo, sso] = v.split('-').map((x) => parseInt(x, 10))
|
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) => (
|
{streamFocusOptions.map((o) => (
|
||||||
<option key={o.valueKey} value={o.valueKey}>
|
<option key={o.valueKey} value={o.valueKey}>
|
||||||
{o.label}
|
{o.label}
|
||||||
|
|
@ -617,50 +734,86 @@ 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 ? (
|
{streamFocusOptions.length > 0 ? (
|
||||||
<p style={{ fontSize: '0.8rem', color: 'var(--text2)', margin: '8px 0 0', lineHeight: 1.35 }}>
|
<p style={{ fontSize: '0.8rem', color: 'var(--text2)', margin: '8px 0 0', lineHeight: 1.4 }}>
|
||||||
Fokus:{' '}
|
Parallele Phasen erscheinen als Schritt <strong>Gruppe wählen</strong> — kein Durcheinander mehr aus
|
||||||
{streamFocusOptions.find((o) => o.phaseOrder === coachFocus.phaseOrder && o.streamOrder === coachFocus.streamOrder)
|
verschränkten Streams. Pro paralleler Phase entscheiden Sie (oder der Co-Trainer auf dem eigenen Gerät), welcher
|
||||||
?.label ?? `Phase ${coachFocus.phaseOrder} · Gruppe ${coachFocus.streamOrder + 1}`}
|
Stream coacht wird. Kurzwahl oben springt die betreffende Phase sofort auf einen Stream (z. B. zwischen Gruppen
|
||||||
{' · '}
|
wechseln).
|
||||||
Ganzgruppen-Abschnitte bleiben voll sichtbar; in der gewählten Split-Phase nur diese Spalte.
|
{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>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{outlineOpen && (
|
{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 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={{ fontSize: '0.75rem', color: 'var(--text3)', marginBottom: '6px', flexShrink: 0 }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', overflowY: 'auto', minHeight: 0 }}>
|
Trainingsrahmen · nach Blöcken und Streams
|
||||||
{timeline.map((ent, ix) => {
|
</div>
|
||||||
const lbl = summarizeTimelineEntry(ent)
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', overflowY: 'auto', minHeight: 0 }}>
|
||||||
const ctx = ent.coachContext || ''
|
{outlineGroups.map((grp) => (
|
||||||
const active = coachDebriefPhase ? ix === timeline.length - 1 : ix === step
|
<div key={grp.mergeKey} style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||||
return (
|
<div
|
||||||
<button
|
|
||||||
key={`${itemStableKey(ent.item, ent.secOrder, ent.ii)}-${ix}`}
|
|
||||||
type="button"
|
|
||||||
className={`btn ${active ? 'btn-primary' : 'btn-secondary'}`}
|
|
||||||
style={{
|
style={{
|
||||||
textAlign: 'left',
|
fontSize: '0.72rem',
|
||||||
justifyContent: 'flex-start',
|
fontWeight: 700,
|
||||||
opacity: active ? 1 : 0.92,
|
color: 'var(--accent-dark)',
|
||||||
fontWeight: active ? 700 : 500
|
textTransform: 'uppercase',
|
||||||
}}
|
letterSpacing: '0.04em',
|
||||||
onClick={() => {
|
|
||||||
setCoachDebriefPhase(false)
|
|
||||||
setStep(ix)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ctx ? (
|
{grp.heading}
|
||||||
<span style={{ opacity: 0.8, display: 'block', fontSize: '0.72rem', marginBottom: '2px' }}>
|
{grp.sub ? <span style={{ fontWeight: 600, color: 'var(--text3)', textTransform: 'none' }}> · {grp.sub}</span> : null}
|
||||||
{ctx.length > 42 ? `${ctx.slice(0, 40)}…` : ctx}
|
</div>
|
||||||
</span>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||||
) : null}
|
{grp.entries.map(({ ix, ent }) => {
|
||||||
<span>{lbl}</span>
|
const lbl = summarizeTimelineEntry(ent)
|
||||||
</button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -680,7 +833,7 @@ export default function TrainingCoachPage() {
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '12px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '12px' }}>
|
||||||
{timeline
|
{timeline
|
||||||
.filter((e) => e.item.item_type === 'exercise')
|
.filter((e) => e.item?.item_type === 'exercise')
|
||||||
.map((ent) => {
|
.map((ent) => {
|
||||||
const k = itemStableKey(ent.item, ent.secOrder, ent.ii)
|
const k = itemStableKey(ent.item, ent.secOrder, ent.ii)
|
||||||
const val = deltas[k]?.actual_duration_min ?? ent.item.actual_duration_min ?? ''
|
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' }}>
|
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--accent-dark)', marginBottom: '6px' }}>
|
||||||
Als Nächstes
|
Als Nächstes
|
||||||
</div>
|
</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 }}>
|
<p style={{ margin: '0', fontSize: '0.9rem', color: 'var(--text1)', lineHeight: 1.4 }}>
|
||||||
<strong>Nächste:</strong> {summarizeTimelineEntry(nextEntry)}
|
<strong>Nächste:</strong> {summarizeTimelineEntry(nextEntry)}
|
||||||
|
|
@ -802,10 +960,70 @@ export default function TrainingCoachPage() {
|
||||||
showJumpToTimerOwnerRow
|
showJumpToTimerOwnerRow
|
||||||
onJumpToTimerOwner={() => setStep(timerOwningStep ?? step)}
|
onJumpToTimerOwner={() => setStep(timerOwningStep ?? step)}
|
||||||
timerOwnerLabelIndex={timerOwningStep ?? 0}
|
timerOwnerLabelIndex={timerOwningStep ?? 0}
|
||||||
|
branchGateMode={atBranchGate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="training-coach-scroll">
|
<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 className="card" style={{ padding: '16px 14px' }}>
|
||||||
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', marginBottom: '8px' }}>
|
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', marginBottom: '8px' }}>
|
||||||
{currentEntry.coachContext || currentEntry.sec.title || 'Abschnitt'} · Coach-Notiz · Teil{' '}
|
{currentEntry.coachContext || currentEntry.sec.title || 'Abschnitt'} · Coach-Notiz · Teil{' '}
|
||||||
|
|
@ -907,7 +1125,7 @@ export default function TrainingCoachPage() {
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '12px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '12px' }}>
|
||||||
{timeline
|
{timeline
|
||||||
.filter((e) => e.item.item_type === 'exercise')
|
.filter((e) => e.item?.item_type === 'exercise')
|
||||||
.map((ent) => {
|
.map((ent) => {
|
||||||
const k = itemStableKey(ent.item, ent.secOrder, ent.ii)
|
const k = itemStableKey(ent.item, ent.secOrder, ent.ii)
|
||||||
const val = deltas[k]?.actual_duration_min ?? ent.item.actual_duration_min ?? ''
|
const val = deltas[k]?.actual_duration_min ?? ent.item.actual_duration_min ?? ''
|
||||||
|
|
@ -980,6 +1198,7 @@ export default function TrainingCoachPage() {
|
||||||
showJumpToTimerOwnerRow={false}
|
showJumpToTimerOwnerRow={false}
|
||||||
onJumpToTimerOwner={() => setStep(timerOwningStep ?? step)}
|
onJumpToTimerOwner={() => setStep(timerOwningStep ?? step)}
|
||||||
timerOwnerLabelIndex={timerOwningStep ?? 0}
|
timerOwnerLabelIndex={timerOwningStep ?? 0}
|
||||||
|
branchGateMode={atBranchGate}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -653,7 +653,7 @@ export default function TrainingUnitRunPage() {
|
||||||
</span>
|
</span>
|
||||||
<Link
|
<Link
|
||||||
className="no-print"
|
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={{
|
style={{
|
||||||
fontSize: '0.78rem',
|
fontSize: '0.78rem',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
|
|
@ -663,7 +663,7 @@ export default function TrainingUnitRunPage() {
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Coach · nur diese Gruppe
|
Coach · Split-Punkt (Vorschlag)
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
|
|
|
||||||
|
|
@ -208,16 +208,141 @@ function coachContextLabelForSection(sec, sectionsList) {
|
||||||
return `Parallel · ${pt} · ${st}`
|
return `Parallel · ${pt} · ${st}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Flache Reihenfolge für Coach-Timeline.
|
export const COACH_ENTRY_BRANCH_GATE = 'branch_gate'
|
||||||
* @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) {
|
/** 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 sections = sectionsWithPlanLocForDisplay(unit)
|
||||||
const model = buildPlanRunViewModelFromSections(sections)
|
const model = buildPlanRunViewModelFromSections(sections)
|
||||||
if (model.mode === 'empty') return []
|
if (model.mode === 'empty') return []
|
||||||
|
|
||||||
|
const picks = normalizeCoachBranchPicks(branchPicks)
|
||||||
const list = []
|
const list = []
|
||||||
|
|
||||||
const pushSectionItems = (sec, coachCtx) => {
|
const pushSectionItems = (sec, coachCtx, runMeta) => {
|
||||||
const si = Math.max(0, sections.indexOf(sec))
|
const si = Math.max(0, sections.indexOf(sec))
|
||||||
const secOrder = sec.order_index ?? si
|
const secOrder = sec.order_index ?? si
|
||||||
sortedItems(sec).forEach((item, ii) => {
|
sortedItems(sec).forEach((item, ii) => {
|
||||||
|
|
@ -229,28 +354,53 @@ export function flattenPlanTimeline(unit, coachFocus = null) {
|
||||||
sec,
|
sec,
|
||||||
item,
|
item,
|
||||||
coachContext: coachCtx,
|
coachContext: coachCtx,
|
||||||
|
runMeta: runMeta || null,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const f = coachFocus
|
|
||||||
for (const run of model.runs) {
|
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) {
|
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
|
continue
|
||||||
}
|
}
|
||||||
if (run.kind === 'parallel') {
|
if (run.kind === 'parallel') {
|
||||||
if (f == null || run.phaseOrderIndex !== f.phaseOrder) {
|
const po = run.phaseOrderIndex
|
||||||
for (const sec of run.globalOrderSections) {
|
const chosen = picks[po]
|
||||||
pushSectionItems(sec, coachContextLabelForSection(sec, sections))
|
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 {
|
} 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) {
|
if (st?.sections?.length) {
|
||||||
for (const sec of st.sections) {
|
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
|
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) return ''
|
||||||
if (item.item_type === 'note') {
|
if (item.item_type === 'note') {
|
||||||
const t = String(item.note_body || '').trim()
|
const t = String(item.note_body || '').trim()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user