Enhance TrainingCoachPage and trainingPlanUtils with split rejoin 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 0s
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 0s
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 a prompt for users to rejoin parallel phases, improving user guidance during training sessions. - Refactored step management logic to ensure accurate navigation through the timeline, utilizing safe step calculations. - Introduced new utility functions in trainingPlanUtils for building save payloads and determining when to prompt for split rejoin, optimizing data handling. - Enhanced state management for split rejoin prompts, ensuring a seamless user experience during training.
This commit is contained in:
parent
352237bbb9
commit
73ac2218c7
|
|
@ -1,16 +1,18 @@
|
|||
/**
|
||||
* 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 React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
import ExerciseFullContent from '../components/ExerciseFullContent'
|
||||
import ExercisePeekModal from '../components/ExercisePeekModal'
|
||||
import {
|
||||
COACH_ENTRY_BRANCH_GATE,
|
||||
buildCoachSavePlanPayload,
|
||||
coachBranchPicksStepStorageSuffix,
|
||||
coachBranchPicksStorageKey,
|
||||
coachOutlineGroupsFromTimeline,
|
||||
coachShouldPromptSplitRejoin,
|
||||
durationOverridesMapFromDeltas,
|
||||
findCoachTimelineJumpIndexForPhase,
|
||||
flattenPlanTimeline,
|
||||
|
|
@ -18,7 +20,6 @@ import {
|
|||
listCoachStreamFocusOptions,
|
||||
mergeCoachBranchPicksWithUrlFocus,
|
||||
normalizeCoachBranchPicks,
|
||||
sectionsToPutPayload,
|
||||
summarizeTimelineEntry,
|
||||
} from '../utils/trainingPlanUtils'
|
||||
|
||||
|
|
@ -194,6 +195,8 @@ export default function TrainingCoachPage() {
|
|||
const [timerOwningStep, setTimerOwningStep] = useState(null)
|
||||
const [, setPulse] = useState(0)
|
||||
|
||||
const [splitRejoinPrompt, setSplitRejoinPrompt] = useState(null)
|
||||
|
||||
const [trainerAppend, setTrainerAppend] = useState('')
|
||||
const [saveMarkDone, setSaveMarkDone] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
|
@ -398,15 +401,13 @@ export default function TrainingCoachPage() {
|
|||
setStep(0)
|
||||
return
|
||||
}
|
||||
if (coachDebriefPhase) return
|
||||
setStep((prev) => clampStep(prev, timeline.length))
|
||||
if (coachDebriefPhase) {
|
||||
setStep(timeline.length - 1)
|
||||
} else {
|
||||
setStep((prev) => clampStep(prev, timeline.length))
|
||||
}
|
||||
}, [unit, timeline.length, coachDebriefPhase])
|
||||
|
||||
useEffect(() => {
|
||||
if (!coachDebriefPhase || !unit || timeline.length === 0) return
|
||||
setStep(timeline.length - 1)
|
||||
}, [coachDebriefPhase, unit, timeline.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (!unit || Number.isNaN(idNum)) return
|
||||
if (timeline.length === 0) {
|
||||
|
|
@ -419,6 +420,7 @@ export default function TrainingCoachPage() {
|
|||
} else if (prev !== navigationKey) {
|
||||
coachFocusResetRef.current = navigationKey
|
||||
setCoachDebriefPhase(false)
|
||||
setSplitRejoinPrompt(null)
|
||||
timerReset()
|
||||
} else {
|
||||
return
|
||||
|
|
@ -439,24 +441,36 @@ export default function TrainingCoachPage() {
|
|||
|
||||
const tickDisplaySec = Math.max(0, Math.floor(elapsedMs / 1000))
|
||||
|
||||
const currentEntry = timeline[step]
|
||||
const nextEntry = timeline[step + 1] || null
|
||||
const next2Entry = timeline[step + 2] || null
|
||||
const safeStep = useMemo(() => {
|
||||
if (!timeline.length) return 0
|
||||
return Math.min(Math.max(0, step), timeline.length - 1)
|
||||
}, [step, timeline.length])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!timeline.length) return
|
||||
const max = timeline.length - 1
|
||||
const s = Math.min(Math.max(0, step), max)
|
||||
if (s !== step) setStep(s)
|
||||
}, [step, timeline.length])
|
||||
|
||||
const currentEntry = timeline[safeStep] ?? null
|
||||
const nextEntry = timeline[safeStep + 1] || null
|
||||
const next2Entry = timeline[safeStep + 2] || null
|
||||
|
||||
const clockStr = formatClock(tickDisplaySec)
|
||||
const roundedMinApply = elapsedMs <= 650 ? null : Math.max(1, Math.round(elapsedMs / 60000))
|
||||
const showJumpToTimerOwner =
|
||||
timerOwningStep != null &&
|
||||
step !== timerOwningStep &&
|
||||
safeStep !== timerOwningStep &&
|
||||
(runStartAt != null || pausedAccumMs > 0)
|
||||
const isLastCoachStep =
|
||||
timeline.length > 0 &&
|
||||
step >= timeline.length - 1 &&
|
||||
safeStep >= timeline.length - 1 &&
|
||||
currentEntry?.entryKind !== COACH_ENTRY_BRANCH_GATE
|
||||
|
||||
const timerStart = () => {
|
||||
setRunStartAt(Date.now())
|
||||
setTimerOwningStep(step)
|
||||
setTimerOwningStep(safeStep)
|
||||
}
|
||||
|
||||
const timerPause = () => {
|
||||
|
|
@ -467,7 +481,7 @@ export default function TrainingCoachPage() {
|
|||
}
|
||||
|
||||
const applySuggestedDuration = () => {
|
||||
const idx = timerOwningStep != null ? timerOwningStep : step
|
||||
const idx = timerOwningStep != null ? timerOwningStep : safeStep
|
||||
const ent = timeline[idx]
|
||||
const item = ent?.item
|
||||
if (!item || item.item_type !== 'exercise') return
|
||||
|
|
@ -482,6 +496,7 @@ export default function TrainingCoachPage() {
|
|||
(phaseOrder, streamOrder) => {
|
||||
if (!Number.isFinite(phaseOrder) || !Number.isFinite(streamOrder)) return
|
||||
setStreamChoiceHint(null)
|
||||
setSplitRejoinPrompt(null)
|
||||
setBranchPicks((prev) => ({ ...normalizeCoachBranchPicks(prev), [phaseOrder]: streamOrder }))
|
||||
timerReset()
|
||||
setCoachDebriefPhase(false)
|
||||
|
|
@ -496,7 +511,7 @@ export default function TrainingCoachPage() {
|
|||
const goNext = () => setStep((s) => clampStep(s + 1))
|
||||
|
||||
const markCurrentDoneAdvance = () => {
|
||||
const ownerIdx = timerOwningStep != null ? timerOwningStep : step
|
||||
const ownerIdx = timerOwningStep != null ? timerOwningStep : safeStep
|
||||
const ent = timeline[ownerIdx]
|
||||
if (ent?.entryKind === COACH_ENTRY_BRANCH_GATE) return
|
||||
const item = ent?.item
|
||||
|
|
@ -508,7 +523,12 @@ export default function TrainingCoachPage() {
|
|||
timerReset()
|
||||
|
||||
const lastIdx = timeline.length - 1
|
||||
if (step >= lastIdx && lastIdx >= 0) {
|
||||
if (safeStep >= lastIdx && lastIdx >= 0) {
|
||||
const rejoin = coachShouldPromptSplitRejoin(unit, timeline[safeStep])
|
||||
if (rejoin) {
|
||||
setSplitRejoinPrompt(rejoin)
|
||||
return
|
||||
}
|
||||
setCoachDebriefPhase(true)
|
||||
try {
|
||||
sessionStorage.setItem(storageDebriefKey(idNum), '1')
|
||||
|
|
@ -575,10 +595,10 @@ export default function TrainingCoachPage() {
|
|||
setSaveOk(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
const sectionsPayload = sectionsToPutPayload(unit, durationOverridesForApi)
|
||||
const sectionsPayloadPart = buildCoachSavePlanPayload(unit, durationOverridesForApi)
|
||||
const tn = trainerAppend.trim()
|
||||
const payload = {
|
||||
sections: sectionsPayload,
|
||||
...sectionsPayloadPart,
|
||||
...(saveMarkDone ? { status: 'completed' } : {}),
|
||||
}
|
||||
if (tn) {
|
||||
|
|
@ -667,12 +687,14 @@ export default function TrainingCoachPage() {
|
|||
style={{ minWidth: 'min(220px, 72vw)', margin: 0, padding: '6px 8px', fontSize: '0.82rem' }}
|
||||
value={streamQuickSelectValue}
|
||||
onChange={(e) => {
|
||||
setSplitRejoinPrompt(null)
|
||||
setCoachDebriefPhase(false)
|
||||
timerReset()
|
||||
const v = e.target.value
|
||||
if (!v) {
|
||||
setBranchPicks({})
|
||||
setStreamChoiceHint(null)
|
||||
setSplitRejoinPrompt(null)
|
||||
setSearchParams({}, { replace: true })
|
||||
} else {
|
||||
const [ppo, sso] = v.split('-').map((x) => parseInt(x, 10))
|
||||
|
|
@ -714,6 +736,80 @@ export default function TrainingCoachPage() {
|
|||
</p>
|
||||
) : null}
|
||||
|
||||
{splitRejoinPrompt && !coachDebriefPhase ? (
|
||||
<div
|
||||
className="card no-print"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
marginBottom: '10px',
|
||||
padding: '16px 14px',
|
||||
borderRadius: '12px',
|
||||
border: '2px solid var(--accent)',
|
||||
background: 'linear-gradient(135deg, var(--accent-light), var(--surface))',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--accent-dark)', marginBottom: '6px' }}>
|
||||
Parallelphase · Abschluss
|
||||
</div>
|
||||
<p style={{ fontSize: '0.95rem', fontWeight: 700, margin: '0 0 8px', color: 'var(--text1)' }}>
|
||||
{splitRejoinPrompt.phaseTitle != null && String(splitRejoinPrompt.phaseTitle).trim()
|
||||
? String(splitRejoinPrompt.phaseTitle).trim()
|
||||
: `Phase ${splitRejoinPrompt.phaseOrderIndex}`}
|
||||
{' — '}
|
||||
alle Gruppen fertig?
|
||||
</p>
|
||||
<p style={{ fontSize: '0.84rem', color: 'var(--text2)', margin: '0 0 12px', lineHeight: 1.45 }}>
|
||||
Diese Phase hat mehrere Streams. Kurz mit dem anderen Trainer klären, dann gemeinsam Ist-Zeiten und Speichern
|
||||
(gilt auch, wenn danach kein weiterer Block mehr kommt).
|
||||
</p>
|
||||
<ul style={{ margin: '0 0 14px', paddingLeft: '1.2rem', fontSize: '0.82rem', color: 'var(--text2)' }}>
|
||||
{splitRejoinPrompt.streams.map((st) => (
|
||||
<li key={st.printStreamId || `s-${st.streamOrder}`}>
|
||||
{st.streamTitle?.trim() ? String(st.streamTitle).trim() : `Gruppe ${st.streamOrder + 1}`}{' '}
|
||||
<span style={{ color: 'var(--text3)' }}>(ca. {st.minutes} Min. Üb.)</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ minHeight: '48px', fontWeight: 700 }}
|
||||
onClick={() => {
|
||||
setSplitRejoinPrompt(null)
|
||||
setCoachDebriefPhase(true)
|
||||
try {
|
||||
sessionStorage.setItem(storageDebriefKey(idNum), '1')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}}
|
||||
>
|
||||
Alle Gruppen fertig — zur Nachbereitung
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" style={{ minHeight: '44px' }} onClick={() => setSplitRejoinPrompt(null)}>
|
||||
Zurück — andere Gruppe läuft noch
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ minHeight: '44px', fontSize: '0.82rem' }}
|
||||
onClick={() => {
|
||||
setSplitRejoinPrompt(null)
|
||||
setCoachDebriefPhase(true)
|
||||
try {
|
||||
sessionStorage.setItem(storageDebriefKey(idNum), '1')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}}
|
||||
>
|
||||
Ausnahme: trotzdem zur Nachbereitung
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<header
|
||||
className="card training-coach-hero training-coach-hero--compact"
|
||||
style={{
|
||||
|
|
@ -727,7 +823,7 @@ export default function TrainingCoachPage() {
|
|||
>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
Coach ·{' '}
|
||||
{coachDebriefPhase ? 'Nachbereitung · abschließend speichern' : `Schritt ${(step || 0) + 1} / ${Math.max(timeline.length, 1)}`}
|
||||
{coachDebriefPhase ? 'Nachbereitung · abschließend speichern' : `Schritt ${(safeStep || 0) + 1} / ${Math.max(timeline.length, 1)}`}
|
||||
</div>
|
||||
<h1 style={{ fontSize: '1.1rem', margin: '4px 0 0', lineHeight: 1.28 }}>
|
||||
{unit.planned_date}
|
||||
|
|
@ -781,7 +877,7 @@ export default function TrainingCoachPage() {
|
|||
{grp.entries.map(({ ix, ent }) => {
|
||||
const lbl = summarizeTimelineEntry(ent)
|
||||
const ctx = ent.coachContext || ''
|
||||
const active = coachDebriefPhase ? ix === timeline.length - 1 : ix === step
|
||||
const active = coachDebriefPhase ? ix === timeline.length - 1 : ix === safeStep
|
||||
const rowKey =
|
||||
ent.entryKind === COACH_ENTRY_BRANCH_GATE
|
||||
? `gate-${ix}`
|
||||
|
|
@ -905,6 +1001,8 @@ export default function TrainingCoachPage() {
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!splitRejoinPrompt ? (
|
||||
<>
|
||||
<div
|
||||
className="training-coach-assist training-coach-assist--compact"
|
||||
style={{
|
||||
|
|
@ -942,7 +1040,7 @@ export default function TrainingCoachPage() {
|
|||
</div>
|
||||
|
||||
<CoachControlsBand
|
||||
step={step}
|
||||
step={safeStep}
|
||||
timelineLength={timeline.length}
|
||||
onPrev={goPrev}
|
||||
onNext={goNext}
|
||||
|
|
@ -958,7 +1056,7 @@ export default function TrainingCoachPage() {
|
|||
isLastCoachStep={isLastCoachStep}
|
||||
showJumpToTimerOwner={showJumpToTimerOwner}
|
||||
showJumpToTimerOwnerRow
|
||||
onJumpToTimerOwner={() => setStep(timerOwningStep ?? step)}
|
||||
onJumpToTimerOwner={() => setStep(timerOwningStep ?? safeStep)}
|
||||
timerOwnerLabelIndex={timerOwningStep ?? 0}
|
||||
branchGateMode={atBranchGate}
|
||||
/>
|
||||
|
|
@ -968,54 +1066,77 @@ export default function TrainingCoachPage() {
|
|||
<div
|
||||
className="card"
|
||||
style={{
|
||||
padding: '16px 14px',
|
||||
padding: '18px 16px',
|
||||
marginBottom: '12px',
|
||||
borderRadius: '12px',
|
||||
borderLeft: '4px solid var(--accent)',
|
||||
background: 'var(--surface)',
|
||||
borderRadius: '14px',
|
||||
border: '2px solid var(--accent)',
|
||||
background: 'linear-gradient(180deg, var(--accent-light) 0%, var(--surface) 38%)',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text3)', marginBottom: '8px' }}>
|
||||
Parallele Phase · Coaching-Zweig
|
||||
<div style={{ fontSize: '0.74rem', fontWeight: 800, color: 'var(--accent-dark)', marginBottom: '6px', letterSpacing: '0.04em' }}>
|
||||
⑂ SPLIT — GRUPPE WÄHLEN
|
||||
</div>
|
||||
<p style={{ fontSize: '1.02rem', fontWeight: 700, margin: '0 0 8px', color: 'var(--accent-dark)' }}>
|
||||
<p style={{ fontSize: '1.05rem', fontWeight: 800, margin: '0 0 8px', color: 'var(--text1)' }}>
|
||||
{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 style={{ fontSize: '0.88rem', color: 'var(--text2)', margin: '0 0 16px', lineHeight: 1.45 }}>
|
||||
Tippen Sie auf eine Kachel, um <strong>diese Gruppe</strong> zu coachen. Andere Trainer:innen wählen auf
|
||||
ihrem Gerät parallel eine andere Kachel.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '12px' }}>
|
||||
{(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}`
|
||||
const baseBg = hinted ? 'var(--accent)' : 'var(--surface2)'
|
||||
const baseColor = hinted ? '#fff' : 'var(--text1)'
|
||||
const borderCol = hinted ? 'var(--accent-dark)' : 'var(--border)'
|
||||
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 }}
|
||||
className="btn"
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
justifyContent: 'flex-start',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
padding: '14px 16px',
|
||||
minHeight: '96px',
|
||||
fontWeight: 700,
|
||||
background: baseBg,
|
||||
color: baseColor,
|
||||
border: `2px solid ${borderCol}`,
|
||||
borderRadius: '12px',
|
||||
boxShadow: hinted ? '0 4px 0 var(--accent-dark)' : '0 3px 0 hsl(200 20% 78%)',
|
||||
transition: 'transform 0.08s ease, box-shadow 0.08s ease',
|
||||
}}
|
||||
onClick={() => pickStreamForPhase(currentEntry.branchMeta.phaseOrderIndex, st.streamOrder)}
|
||||
>
|
||||
<span style={{ display: 'block' }}>{label}</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '1rem' }}>
|
||||
<span style={{ fontSize: '1.35rem', lineHeight: 1 }} aria-hidden="true">
|
||||
{hinted ? '◉' : '○'}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '0.82rem',
|
||||
fontWeight: 500,
|
||||
opacity: 0.9,
|
||||
marginTop: '4px',
|
||||
fontWeight: 600,
|
||||
opacity: hinted ? 0.95 : 0.88,
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
ca. {st.minutes} Min. (Üb.) · Antippen zum Fortfahren
|
||||
≈ {st.minutes} Min. Üb. · Jetzt aktivieren
|
||||
</span>
|
||||
{hinted ? (
|
||||
<span style={{ display: 'block', fontSize: '0.75rem', marginTop: '6px', opacity: 0.95 }}>
|
||||
Hinweis aus Planansicht — bei Bedarf andere Gruppe wählen.
|
||||
<span style={{ display: 'block', fontSize: '0.74rem', fontWeight: 600, marginTop: '8px', opacity: 0.92 }}>
|
||||
Vorschlag aus Planansicht — trotzdem andere Kachel möglich
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
|
@ -1026,8 +1147,8 @@ export default function TrainingCoachPage() {
|
|||
) : currentEntry?.item?.item_type === 'note' ? (
|
||||
<div className="card" style={{ padding: '16px 14px' }}>
|
||||
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', marginBottom: '8px' }}>
|
||||
{currentEntry.coachContext || currentEntry.sec.title || 'Abschnitt'} · Coach-Notiz · Teil{' '}
|
||||
{step + 1}
|
||||
{currentEntry?.coachContext || currentEntry?.sec?.title || 'Abschnitt'} · Coach-Notiz · Teil{' '}
|
||||
{safeStep + 1}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginBottom: '6px' }}>Coach-Notiz</div>
|
||||
<p style={{ fontSize: '1.05rem', lineHeight: 1.52, whiteSpace: 'pre-wrap', margin: 0 }}>{currentEntry.item.note_body || ''}</p>
|
||||
|
|
@ -1036,8 +1157,8 @@ export default function TrainingCoachPage() {
|
|||
<>
|
||||
<div className="card training-coach-plan-strip" style={{ padding: '12px 14px', marginBottom: '12px', borderRadius: '12px', borderLeft: `3px solid var(--accent)` }}>
|
||||
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', marginBottom: '6px' }}>
|
||||
In diesem Training · {currentEntry.coachContext || currentEntry?.sec.title || 'Abschnitt'} · Teil{' '}
|
||||
{step + 1}
|
||||
In diesem Training · {currentEntry?.coachContext || currentEntry?.sec?.title || 'Abschnitt'} · Teil{' '}
|
||||
{safeStep + 1}
|
||||
</div>
|
||||
{currentEntry?.item && (
|
||||
<>
|
||||
|
|
@ -1180,7 +1301,7 @@ export default function TrainingCoachPage() {
|
|||
</div>
|
||||
|
||||
<CoachControlsBand
|
||||
step={step}
|
||||
step={safeStep}
|
||||
timelineLength={timeline.length}
|
||||
onPrev={goPrev}
|
||||
onNext={goNext}
|
||||
|
|
@ -1196,10 +1317,12 @@ export default function TrainingCoachPage() {
|
|||
isLastCoachStep={isLastCoachStep}
|
||||
showJumpToTimerOwner={showJumpToTimerOwner}
|
||||
showJumpToTimerOwnerRow={false}
|
||||
onJumpToTimerOwner={() => setStep(timerOwningStep ?? step)}
|
||||
onJumpToTimerOwner={() => setStep(timerOwningStep ?? safeStep)}
|
||||
timerOwnerLabelIndex={timerOwningStep ?? 0}
|
||||
branchGateMode={atBranchGate}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
|
||||
import {
|
||||
buildPlanPayloadForSave,
|
||||
cloneJsonSerializablePlanningProfile,
|
||||
inheritPlanLocForPhasedSave,
|
||||
phaseRunsFromSections,
|
||||
|
|
@ -455,6 +456,40 @@ export function durationOverridesMapFromDeltas(unit, deltas) {
|
|||
return out
|
||||
}
|
||||
|
||||
/** PUT-Body für Coach-Speichern: `phases` wenn Plan Phasen hat, sonst `sections` (wie Planungseditor). */
|
||||
export function buildCoachSavePlanPayload(unit, durationOverridesByItemId = {}) {
|
||||
const withLoc = sectionsWithPlanLocForDisplay(unit)
|
||||
const withDur = withLoc.map((sec) => ({
|
||||
...sec,
|
||||
items: sortedItems(sec).map((it) => {
|
||||
if (it.item_type !== 'exercise' || it.id == null) return it
|
||||
const o = durationOverridesByItemId[String(it.id)]
|
||||
const av = o?.actual_duration_min
|
||||
if (av !== undefined && av !== '' && av !== null && Number.isFinite(Number(av))) {
|
||||
return { ...it, actual_duration_min: Number(av) }
|
||||
}
|
||||
return it
|
||||
}),
|
||||
}))
|
||||
return buildPlanPayloadForSave(withDur)
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach dem letzten Block eines Streams: Rückfrage, wenn die parallele Phase mehrere Gruppen hat.
|
||||
*/
|
||||
export function coachShouldPromptSplitRejoin(unit, lastTimelineEntry) {
|
||||
const rm = lastTimelineEntry?.runMeta
|
||||
if (!rm || rm.kind !== 'parallel' || rm.streamOrder == null) return null
|
||||
const model = buildPlanRunViewModelFromSections(sectionsWithPlanLocForDisplay(unit))
|
||||
const run = model.runs.find((r) => r.kind === 'parallel' && r.phaseOrderIndex === rm.phaseOrderIndex)
|
||||
if (!run?.streams || run.streams.length <= 1) return null
|
||||
return {
|
||||
phaseOrderIndex: run.phaseOrderIndex,
|
||||
phaseTitle: run.phaseTitle,
|
||||
streams: run.streams,
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeTimelineEntry(ent) {
|
||||
if (!ent) return ''
|
||||
if (ent.entryKind === COACH_ENTRY_BRANCH_GATE) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user