diff --git a/frontend/src/pages/TrainingCoachPage.jsx b/frontend/src/pages/TrainingCoachPage.jsx
index 3e2f207..c855ba1 100644
--- a/frontend/src/pages/TrainingCoachPage.jsx
+++ b/frontend/src/pages/TrainingCoachPage.jsx
@@ -1,6 +1,5 @@
/**
- * Coach-Modus: eine Position nach der anderen mit Assistentenhinweisen, Zeitnahme und optionaler Nachbereitung.
- * Timeline: flach in Phasen-/Stream-Reihenfolge (flattenPlanTimeline).
+ * Coach-Modus: Schrittfolge mit Split-Punkten (branch_gate), Stream-Wahl pro paralleler Phase, Assistenz und Zeitnahme.
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
@@ -8,17 +7,23 @@ import api from '../utils/api'
import ExerciseFullContent from '../components/ExerciseFullContent'
import ExercisePeekModal from '../components/ExercisePeekModal'
import {
+ COACH_ENTRY_BRANCH_GATE,
+ coachBranchPicksStepStorageSuffix,
+ coachBranchPicksStorageKey,
+ coachOutlineGroupsFromTimeline,
durationOverridesMapFromDeltas,
+ findCoachTimelineJumpIndexForPhase,
flattenPlanTimeline,
itemStableKey,
listCoachStreamFocusOptions,
+ mergeCoachBranchPicksWithUrlFocus,
+ normalizeCoachBranchPicks,
sectionsToPutPayload,
summarizeTimelineEntry,
} from '../utils/trainingPlanUtils'
-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 storageStepKey(unitId, mergedPicks) {
+ return `sj_coach_step_${unitId}_${coachBranchPicksStepStorageSuffix(mergedPicks)}`
}
function storageDeltasKey(unitId) {
@@ -58,14 +63,16 @@ function CoachControlsBand({
showJumpToTimerOwnerRow = true,
onJumpToTimerOwner,
timerOwnerLabelIndex,
+ branchGateMode = false,
}) {
const disPrev = step <= 0
- const disNext = step >= timelineLength - 1
+ const disNext = branchGateMode || step >= timelineLength - 1
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 doneLabel =
- timelineLength <= 1
+ const doneLabel = branchGateMode
+ ? 'Zuerst Gruppe wählen'
+ : timelineLength <= 1
? 'Nachbereitung öffnen'
: isLastCoachStep
? 'Nachbereitung & Ist-Zeit'
@@ -109,6 +116,7 @@ function CoachControlsBand({
type="button"
className="btn btn-primary"
style={{ minHeight: '44px', flex: '1 1 auto', fontWeight: 700 }}
+ disabled={branchGateMode}
onClick={onTimerStart}
>
▶ Start{alive ? ` · ${clockStr}` : ''}
@@ -121,7 +129,7 @@ function CoachControlsBand({
Ist ({istLabelMin} Min)
- {alive && (
+ {alive && !branchGateMode && (
{doneLabel}
@@ -176,6 +185,8 @@ export default function TrainingCoachPage() {
const [coachDebriefPhase, setCoachDebriefPhase] = useState(false)
const [step, setStep] = useState(0)
+ const [branchPicks, setBranchPicks] = useState({})
+ const [streamChoiceHint, setStreamChoiceHint] = useState(null)
const [deltas, setDeltas] = useState({})
const [runStartAt, setRunStartAt] = useState(null)
@@ -207,15 +218,32 @@ export default function TrainingCoachPage() {
return { phaseOrder: po, streamOrder: so }
}, [searchParams, unit])
+ const mergedPicks = useMemo(
+ () => mergeCoachBranchPicksWithUrlFocus(branchPicks, coachFocus),
+ [branchPicks, coachFocus]
+ )
+
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 =
(searchParams.get('po') != null && searchParams.get('po') !== '') ||
(searchParams.get('so') != null && searchParams.get('so') !== '')
const streamParamsInvalid = Boolean(unit && hasStreamSelectParams && !coachFocus)
+ const timeline = useMemo(() => flattenPlanTimeline(unit, mergedPicks), [unit, mergedPicks])
+
+ const outlineGroups = useMemo(() => coachOutlineGroupsFromTimeline(timeline), [timeline])
+
useEffect(() => {
if (!unitId || Number.isNaN(idNum)) {
setLoadError('Ungültige Trainingseinheit')
@@ -230,6 +258,16 @@ export default function TrainingCoachPage() {
try {
await reloadUnit()
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 {
const raw = sessionStorage.getItem(storageDeltasKey(idNum))
if (raw) {
@@ -244,6 +282,10 @@ export default function TrainingCoachPage() {
} catch {
/* ignore */
}
+ if (!cancelled) {
+ setBranchPicks(nextBranchPicks)
+ setStreamChoiceHint(null)
+ }
} catch (e) {
if (!cancelled) setLoadError(e.message || 'Laden fehlgeschlagen')
} finally {
@@ -272,10 +314,52 @@ export default function TrainingCoachPage() {
}
}, [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(() => {
if (Number.isNaN(idNum)) return
- sessionStorage.setItem(storageStepKey(idNum, coachFocus), String(step))
- }, [idNum, coachFocus, step])
+ try {
+ 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(() => {
try {
@@ -299,8 +383,6 @@ export default function TrainingCoachPage() {
return () => clearInterval(iv)
}, [runStartAt])
- 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)))
@@ -333,16 +415,16 @@ export default function TrainingCoachPage() {
}
const prev = coachFocusResetRef.current
if (prev === null) {
- coachFocusResetRef.current = focusKey
- } else if (prev !== focusKey) {
- coachFocusResetRef.current = focusKey
+ coachFocusResetRef.current = navigationKey
+ } else if (prev !== navigationKey) {
+ coachFocusResetRef.current = navigationKey
setCoachDebriefPhase(false)
timerReset()
} else {
return
}
try {
- const raw = sessionStorage.getItem(storageStepKey(idNum, coachFocus))
+ const raw = sessionStorage.getItem(storageStepKey(idNum, mergedPicks))
const s = parseInt(raw, 10)
const maxIdx = Math.max(0, timeline.length - 1)
if (!Number.isNaN(s) && s >= 0) setStep(Math.min(s, maxIdx))
@@ -350,7 +432,7 @@ export default function TrainingCoachPage() {
} catch {
setStep(0)
}
- }, [unit, idNum, focusKey, coachFocus, timeline.length, timerReset])
+ }, [unit, idNum, navigationKey, mergedPicks, timeline.length, timerReset])
const elapsedMs =
pausedAccumMs + (runStartAt != null ? Date.now() - runStartAt : 0)
@@ -367,7 +449,10 @@ export default function TrainingCoachPage() {
timerOwningStep != null &&
step !== timerOwningStep &&
(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 = () => {
setRunStartAt(Date.now())
@@ -393,12 +478,27 @@ export default function TrainingCoachPage() {
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 goNext = () => setStep((s) => clampStep(s + 1))
const markCurrentDoneAdvance = () => {
const ownerIdx = timerOwningStep != null ? timerOwningStep : step
const ent = timeline[ownerIdx]
+ if (ent?.entryKind === COACH_ENTRY_BRANCH_GATE) return
const item = ent?.item
if (item?.item_type === 'exercise' && elapsedMs > 650) {
const key = itemStableKey(item, ent.secOrder, ent.ii)
@@ -420,9 +520,20 @@ export default function TrainingCoachPage() {
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])
useEffect(() => {
+ if (currentEntry?.entryKind === COACH_ENTRY_BRANCH_GATE) {
+ setCatalogExercise(null)
+ setCatalogError(null)
+ setCatalogLoading(false)
+ return
+ }
const item = currentEntry?.item
if (!item || item.item_type === 'note') {
setCatalogExercise(null)
@@ -458,7 +569,7 @@ export default function TrainingCoachPage() {
return () => {
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 () => {
setSaveOk(null)
@@ -482,10 +593,13 @@ export default function TrainingCoachPage() {
try {
sessionStorage.removeItem(storageDeltasKey(idNum))
sessionStorage.removeItem(storageDebriefKey(idNum))
+ sessionStorage.removeItem(coachBranchPicksStorageKey(idNum))
} catch {
/* ignore */
}
setDeltas({})
+ setBranchPicks({})
+ setStreamChoiceHint(null)
setCoachDebriefPhase(false)
setSaveOk('Gespeichert.')
setDebriefOpen(false)
@@ -551,19 +665,22 @@ export default function TrainingCoachPage() {
{
setCoachDebriefPhase(false)
timerReset()
const v = e.target.value
- if (!v) setSearchParams({}, { replace: true })
- else {
+ if (!v) {
+ setBranchPicks({})
+ setStreamChoiceHint(null)
+ setSearchParams({}, { replace: true })
+ } else {
const [ppo, sso] = v.split('-').map((x) => parseInt(x, 10))
- setSearchParams({ po: String(ppo), so: String(sso) }, { replace: true })
+ pickStreamForPhase(ppo, sso)
}
}}
>
- Gesamtplan (alle Gruppen)
+ Ablauf mit Split-Punkten (Standard)
{streamFocusOptions.map((o) => (
{o.label}
@@ -617,50 +734,86 @@ export default function TrainingCoachPage() {
{unit.planned_time_start && ` · ${String(unit.planned_time_start).slice(0, 5)}`}
{unit.planned_focus ? ` · ${unit.planned_focus}` : ''}
- {coachFocus ? (
-
- 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.
+ {streamFocusOptions.length > 0 ? (
+
+ Parallele Phasen erscheinen als Schritt Gruppe wählen — kein Durcheinander mehr aus
+ verschränkten Streams. Pro paralleler Phase entscheiden Sie (oder der Co-Trainer auf dem eigenen Gerät), welcher
+ Stream coacht wird. Kurzwahl oben springt die betreffende Phase sofort auf einen Stream (z. B. zwischen Gruppen
+ wechseln).
+ {Object.keys(mergedPicks).length > 0 ? (
+
+ 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(' · ')}
+
+ ) : null}
) : null}
{outlineOpen && (
-
Ablauf · Antippen zum Springen
-
- {timeline.map((ent, ix) => {
- const lbl = summarizeTimelineEntry(ent)
- const ctx = ent.coachContext || ''
- const active = coachDebriefPhase ? ix === timeline.length - 1 : ix === step
- return (
-
+ Trainingsrahmen · nach Blöcken und Streams
+
+
+ {outlineGroups.map((grp) => (
+
+
{
- setCoachDebriefPhase(false)
- setStep(ix)
+ fontSize: '0.72rem',
+ fontWeight: 700,
+ color: 'var(--accent-dark)',
+ textTransform: 'uppercase',
+ letterSpacing: '0.04em',
}}
>
- {ctx ? (
-
- {ctx.length > 42 ? `${ctx.slice(0, 40)}…` : ctx}
-
- ) : null}
- {lbl}
-
- )
- })}
+ {grp.heading}
+ {grp.sub ? · {grp.sub} : null}
+
+
+ {grp.entries.map(({ ix, ent }) => {
+ const lbl = summarizeTimelineEntry(ent)
+ 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 (
+ {
+ setCoachDebriefPhase(false)
+ setStep(ix)
+ }}
+ >
+ {ctx ? (
+
+ {ctx.length > 42 ? `${ctx.slice(0, 40)}…` : ctx}
+
+ ) : null}
+ {lbl}
+
+ )
+ })}
+
+
+ ))}
)}
@@ -680,7 +833,7 @@ export default function TrainingCoachPage() {
{timeline
- .filter((e) => e.item.item_type === 'exercise')
+ .filter((e) => e.item?.item_type === 'exercise')
.map((ent) => {
const k = itemStableKey(ent.item, ent.secOrder, ent.ii)
const val = deltas[k]?.actual_duration_min ?? ent.item.actual_duration_min ?? ''
@@ -765,7 +918,12 @@ export default function TrainingCoachPage() {
Als Nächstes
- {nextEntry ? (
+ {atBranchGate ? (
+
+ Wählen Sie unten eine Gruppe. Danach zeigt der Coach fortlaufend nur die Übungen dieses Streams in dieser
+ parallelen Phase.
+
+ ) : nextEntry ? (
<>
Nächste: {summarizeTimelineEntry(nextEntry)}
@@ -802,10 +960,70 @@ export default function TrainingCoachPage() {
showJumpToTimerOwnerRow
onJumpToTimerOwner={() => setStep(timerOwningStep ?? step)}
timerOwnerLabelIndex={timerOwningStep ?? 0}
+ branchGateMode={atBranchGate}
/>
- {currentEntry?.item?.item_type === 'note' ? (
+ {atBranchGate && currentEntry?.branchMeta ? (
+
+
+ Parallele Phase · Coaching-Zweig
+
+
+ {currentEntry.branchMeta.phaseTitle != null && String(currentEntry.branchMeta.phaseTitle).trim()
+ ? String(currentEntry.branchMeta.phaseTitle).trim()
+ : `Phase ${currentEntry.branchMeta.phaseOrderIndex}`}
+
+
+ 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).
+
+
+ {(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 (
+ pickStreamForPhase(currentEntry.branchMeta.phaseOrderIndex, st.streamOrder)}
+ >
+ {label}
+
+ ca. {st.minutes} Min. (Üb.) · Antippen zum Fortfahren
+
+ {hinted ? (
+
+ Hinweis aus Planansicht — bei Bedarf andere Gruppe wählen.
+
+ ) : null}
+
+ )
+ })}
+
+
+ ) : currentEntry?.item?.item_type === 'note' ? (
{currentEntry.coachContext || currentEntry.sec.title || 'Abschnitt'} · Coach-Notiz · Teil{' '}
@@ -907,7 +1125,7 @@ export default function TrainingCoachPage() {
{timeline
- .filter((e) => e.item.item_type === 'exercise')
+ .filter((e) => e.item?.item_type === 'exercise')
.map((ent) => {
const k = itemStableKey(ent.item, ent.secOrder, ent.ii)
const val = deltas[k]?.actual_duration_min ?? ent.item.actual_duration_min ?? ''
@@ -980,6 +1198,7 @@ export default function TrainingCoachPage() {
showJumpToTimerOwnerRow={false}
onJumpToTimerOwner={() => setStep(timerOwningStep ?? step)}
timerOwnerLabelIndex={timerOwningStep ?? 0}
+ branchGateMode={atBranchGate}
/>
>
)}
diff --git a/frontend/src/pages/TrainingUnitRunPage.jsx b/frontend/src/pages/TrainingUnitRunPage.jsx
index 0952256..f456325 100644
--- a/frontend/src/pages/TrainingUnitRunPage.jsx
+++ b/frontend/src/pages/TrainingUnitRunPage.jsx
@@ -653,7 +653,7 @@ export default function TrainingUnitRunPage() {
- Coach · nur diese Gruppe
+ Coach · Split-Punkt (Vorschlag)
diff --git a/frontend/src/utils/trainingPlanUtils.js b/frontend/src/utils/trainingPlanUtils.js
index 1844d73..0a4d7b4 100644
--- a/frontend/src/utils/trainingPlanUtils.js
+++ b/frontend/src/utils/trainingPlanUtils.js
@@ -208,16 +208,141 @@ function coachContextLabelForSection(sec, sectionsList) {
return `Parallel · ${pt} · ${st}`
}
-/** 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) {
+export const COACH_ENTRY_BRANCH_GATE = 'branch_gate'
+
+/** 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 model = buildPlanRunViewModelFromSections(sections)
if (model.mode === 'empty') return []
+ const picks = normalizeCoachBranchPicks(branchPicks)
const list = []
- const pushSectionItems = (sec, coachCtx) => {
+ const pushSectionItems = (sec, coachCtx, runMeta) => {
const si = Math.max(0, sections.indexOf(sec))
const secOrder = sec.order_index ?? si
sortedItems(sec).forEach((item, ii) => {
@@ -229,28 +354,53 @@ export function flattenPlanTimeline(unit, coachFocus = null) {
sec,
item,
coachContext: coachCtx,
+ runMeta: runMeta || null,
})
})
}
- const f = coachFocus
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) {
- 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
}
if (run.kind === 'parallel') {
- if (f == null || run.phaseOrderIndex !== f.phaseOrder) {
- for (const sec of run.globalOrderSections) {
- pushSectionItems(sec, coachContextLabelForSection(sec, sections))
- }
+ const po = run.phaseOrderIndex
+ const chosen = picks[po]
+ 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 {
- 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) {
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
}
-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.item_type === 'note') {
const t = String(item.note_body || '').trim()