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.
|
* 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 { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import ExerciseFullContent from '../components/ExerciseFullContent'
|
import ExerciseFullContent from '../components/ExerciseFullContent'
|
||||||
import ExercisePeekModal from '../components/ExercisePeekModal'
|
import ExercisePeekModal from '../components/ExercisePeekModal'
|
||||||
import {
|
import {
|
||||||
COACH_ENTRY_BRANCH_GATE,
|
COACH_ENTRY_BRANCH_GATE,
|
||||||
|
buildCoachSavePlanPayload,
|
||||||
coachBranchPicksStepStorageSuffix,
|
coachBranchPicksStepStorageSuffix,
|
||||||
coachBranchPicksStorageKey,
|
coachBranchPicksStorageKey,
|
||||||
coachOutlineGroupsFromTimeline,
|
coachOutlineGroupsFromTimeline,
|
||||||
|
coachShouldPromptSplitRejoin,
|
||||||
durationOverridesMapFromDeltas,
|
durationOverridesMapFromDeltas,
|
||||||
findCoachTimelineJumpIndexForPhase,
|
findCoachTimelineJumpIndexForPhase,
|
||||||
flattenPlanTimeline,
|
flattenPlanTimeline,
|
||||||
|
|
@ -18,7 +20,6 @@ import {
|
||||||
listCoachStreamFocusOptions,
|
listCoachStreamFocusOptions,
|
||||||
mergeCoachBranchPicksWithUrlFocus,
|
mergeCoachBranchPicksWithUrlFocus,
|
||||||
normalizeCoachBranchPicks,
|
normalizeCoachBranchPicks,
|
||||||
sectionsToPutPayload,
|
|
||||||
summarizeTimelineEntry,
|
summarizeTimelineEntry,
|
||||||
} from '../utils/trainingPlanUtils'
|
} from '../utils/trainingPlanUtils'
|
||||||
|
|
||||||
|
|
@ -194,6 +195,8 @@ export default function TrainingCoachPage() {
|
||||||
const [timerOwningStep, setTimerOwningStep] = useState(null)
|
const [timerOwningStep, setTimerOwningStep] = useState(null)
|
||||||
const [, setPulse] = useState(0)
|
const [, setPulse] = useState(0)
|
||||||
|
|
||||||
|
const [splitRejoinPrompt, setSplitRejoinPrompt] = useState(null)
|
||||||
|
|
||||||
const [trainerAppend, setTrainerAppend] = useState('')
|
const [trainerAppend, setTrainerAppend] = useState('')
|
||||||
const [saveMarkDone, setSaveMarkDone] = useState(true)
|
const [saveMarkDone, setSaveMarkDone] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
@ -398,14 +401,12 @@ export default function TrainingCoachPage() {
|
||||||
setStep(0)
|
setStep(0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (coachDebriefPhase) return
|
if (coachDebriefPhase) {
|
||||||
setStep((prev) => clampStep(prev, timeline.length))
|
|
||||||
}, [unit, timeline.length, coachDebriefPhase])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!coachDebriefPhase || !unit || timeline.length === 0) return
|
|
||||||
setStep(timeline.length - 1)
|
setStep(timeline.length - 1)
|
||||||
}, [coachDebriefPhase, unit, timeline.length])
|
} else {
|
||||||
|
setStep((prev) => clampStep(prev, timeline.length))
|
||||||
|
}
|
||||||
|
}, [unit, timeline.length, coachDebriefPhase])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!unit || Number.isNaN(idNum)) return
|
if (!unit || Number.isNaN(idNum)) return
|
||||||
|
|
@ -419,6 +420,7 @@ export default function TrainingCoachPage() {
|
||||||
} else if (prev !== navigationKey) {
|
} else if (prev !== navigationKey) {
|
||||||
coachFocusResetRef.current = navigationKey
|
coachFocusResetRef.current = navigationKey
|
||||||
setCoachDebriefPhase(false)
|
setCoachDebriefPhase(false)
|
||||||
|
setSplitRejoinPrompt(null)
|
||||||
timerReset()
|
timerReset()
|
||||||
} else {
|
} else {
|
||||||
return
|
return
|
||||||
|
|
@ -439,24 +441,36 @@ export default function TrainingCoachPage() {
|
||||||
|
|
||||||
const tickDisplaySec = Math.max(0, Math.floor(elapsedMs / 1000))
|
const tickDisplaySec = Math.max(0, Math.floor(elapsedMs / 1000))
|
||||||
|
|
||||||
const currentEntry = timeline[step]
|
const safeStep = useMemo(() => {
|
||||||
const nextEntry = timeline[step + 1] || null
|
if (!timeline.length) return 0
|
||||||
const next2Entry = timeline[step + 2] || null
|
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 clockStr = formatClock(tickDisplaySec)
|
||||||
const roundedMinApply = elapsedMs <= 650 ? null : Math.max(1, Math.round(elapsedMs / 60000))
|
const roundedMinApply = elapsedMs <= 650 ? null : Math.max(1, Math.round(elapsedMs / 60000))
|
||||||
const showJumpToTimerOwner =
|
const showJumpToTimerOwner =
|
||||||
timerOwningStep != null &&
|
timerOwningStep != null &&
|
||||||
step !== timerOwningStep &&
|
safeStep !== timerOwningStep &&
|
||||||
(runStartAt != null || pausedAccumMs > 0)
|
(runStartAt != null || pausedAccumMs > 0)
|
||||||
const isLastCoachStep =
|
const isLastCoachStep =
|
||||||
timeline.length > 0 &&
|
timeline.length > 0 &&
|
||||||
step >= timeline.length - 1 &&
|
safeStep >= timeline.length - 1 &&
|
||||||
currentEntry?.entryKind !== COACH_ENTRY_BRANCH_GATE
|
currentEntry?.entryKind !== COACH_ENTRY_BRANCH_GATE
|
||||||
|
|
||||||
const timerStart = () => {
|
const timerStart = () => {
|
||||||
setRunStartAt(Date.now())
|
setRunStartAt(Date.now())
|
||||||
setTimerOwningStep(step)
|
setTimerOwningStep(safeStep)
|
||||||
}
|
}
|
||||||
|
|
||||||
const timerPause = () => {
|
const timerPause = () => {
|
||||||
|
|
@ -467,7 +481,7 @@ export default function TrainingCoachPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const applySuggestedDuration = () => {
|
const applySuggestedDuration = () => {
|
||||||
const idx = timerOwningStep != null ? timerOwningStep : step
|
const idx = timerOwningStep != null ? timerOwningStep : safeStep
|
||||||
const ent = timeline[idx]
|
const ent = timeline[idx]
|
||||||
const item = ent?.item
|
const item = ent?.item
|
||||||
if (!item || item.item_type !== 'exercise') return
|
if (!item || item.item_type !== 'exercise') return
|
||||||
|
|
@ -482,6 +496,7 @@ export default function TrainingCoachPage() {
|
||||||
(phaseOrder, streamOrder) => {
|
(phaseOrder, streamOrder) => {
|
||||||
if (!Number.isFinite(phaseOrder) || !Number.isFinite(streamOrder)) return
|
if (!Number.isFinite(phaseOrder) || !Number.isFinite(streamOrder)) return
|
||||||
setStreamChoiceHint(null)
|
setStreamChoiceHint(null)
|
||||||
|
setSplitRejoinPrompt(null)
|
||||||
setBranchPicks((prev) => ({ ...normalizeCoachBranchPicks(prev), [phaseOrder]: streamOrder }))
|
setBranchPicks((prev) => ({ ...normalizeCoachBranchPicks(prev), [phaseOrder]: streamOrder }))
|
||||||
timerReset()
|
timerReset()
|
||||||
setCoachDebriefPhase(false)
|
setCoachDebriefPhase(false)
|
||||||
|
|
@ -496,7 +511,7 @@ export default function TrainingCoachPage() {
|
||||||
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 : safeStep
|
||||||
const ent = timeline[ownerIdx]
|
const ent = timeline[ownerIdx]
|
||||||
if (ent?.entryKind === COACH_ENTRY_BRANCH_GATE) return
|
if (ent?.entryKind === COACH_ENTRY_BRANCH_GATE) return
|
||||||
const item = ent?.item
|
const item = ent?.item
|
||||||
|
|
@ -508,7 +523,12 @@ export default function TrainingCoachPage() {
|
||||||
timerReset()
|
timerReset()
|
||||||
|
|
||||||
const lastIdx = timeline.length - 1
|
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)
|
setCoachDebriefPhase(true)
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem(storageDebriefKey(idNum), '1')
|
sessionStorage.setItem(storageDebriefKey(idNum), '1')
|
||||||
|
|
@ -575,10 +595,10 @@ export default function TrainingCoachPage() {
|
||||||
setSaveOk(null)
|
setSaveOk(null)
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const sectionsPayload = sectionsToPutPayload(unit, durationOverridesForApi)
|
const sectionsPayloadPart = buildCoachSavePlanPayload(unit, durationOverridesForApi)
|
||||||
const tn = trainerAppend.trim()
|
const tn = trainerAppend.trim()
|
||||||
const payload = {
|
const payload = {
|
||||||
sections: sectionsPayload,
|
...sectionsPayloadPart,
|
||||||
...(saveMarkDone ? { status: 'completed' } : {}),
|
...(saveMarkDone ? { status: 'completed' } : {}),
|
||||||
}
|
}
|
||||||
if (tn) {
|
if (tn) {
|
||||||
|
|
@ -667,12 +687,14 @@ export default function TrainingCoachPage() {
|
||||||
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={streamQuickSelectValue}
|
value={streamQuickSelectValue}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
setSplitRejoinPrompt(null)
|
||||||
setCoachDebriefPhase(false)
|
setCoachDebriefPhase(false)
|
||||||
timerReset()
|
timerReset()
|
||||||
const v = e.target.value
|
const v = e.target.value
|
||||||
if (!v) {
|
if (!v) {
|
||||||
setBranchPicks({})
|
setBranchPicks({})
|
||||||
setStreamChoiceHint(null)
|
setStreamChoiceHint(null)
|
||||||
|
setSplitRejoinPrompt(null)
|
||||||
setSearchParams({}, { replace: true })
|
setSearchParams({}, { replace: true })
|
||||||
} else {
|
} else {
|
||||||
const [ppo, sso] = v.split('-').map((x) => parseInt(x, 10))
|
const [ppo, sso] = v.split('-').map((x) => parseInt(x, 10))
|
||||||
|
|
@ -714,6 +736,80 @@ export default function TrainingCoachPage() {
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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
|
<header
|
||||||
className="card training-coach-hero training-coach-hero--compact"
|
className="card training-coach-hero training-coach-hero--compact"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -727,7 +823,7 @@ export default function TrainingCoachPage() {
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: '0.7rem', color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
<div style={{ fontSize: '0.7rem', color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||||
Coach ·{' '}
|
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>
|
</div>
|
||||||
<h1 style={{ fontSize: '1.1rem', margin: '4px 0 0', lineHeight: 1.28 }}>
|
<h1 style={{ fontSize: '1.1rem', margin: '4px 0 0', lineHeight: 1.28 }}>
|
||||||
{unit.planned_date}
|
{unit.planned_date}
|
||||||
|
|
@ -781,7 +877,7 @@ export default function TrainingCoachPage() {
|
||||||
{grp.entries.map(({ ix, ent }) => {
|
{grp.entries.map(({ ix, ent }) => {
|
||||||
const lbl = summarizeTimelineEntry(ent)
|
const lbl = summarizeTimelineEntry(ent)
|
||||||
const ctx = ent.coachContext || ''
|
const ctx = ent.coachContext || ''
|
||||||
const active = coachDebriefPhase ? ix === timeline.length - 1 : ix === step
|
const active = coachDebriefPhase ? ix === timeline.length - 1 : ix === safeStep
|
||||||
const rowKey =
|
const rowKey =
|
||||||
ent.entryKind === COACH_ENTRY_BRANCH_GATE
|
ent.entryKind === COACH_ENTRY_BRANCH_GATE
|
||||||
? `gate-${ix}`
|
? `gate-${ix}`
|
||||||
|
|
@ -904,6 +1000,8 @@ export default function TrainingCoachPage() {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
{!splitRejoinPrompt ? (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="training-coach-assist training-coach-assist--compact"
|
className="training-coach-assist training-coach-assist--compact"
|
||||||
|
|
@ -942,7 +1040,7 @@ export default function TrainingCoachPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CoachControlsBand
|
<CoachControlsBand
|
||||||
step={step}
|
step={safeStep}
|
||||||
timelineLength={timeline.length}
|
timelineLength={timeline.length}
|
||||||
onPrev={goPrev}
|
onPrev={goPrev}
|
||||||
onNext={goNext}
|
onNext={goNext}
|
||||||
|
|
@ -958,7 +1056,7 @@ export default function TrainingCoachPage() {
|
||||||
isLastCoachStep={isLastCoachStep}
|
isLastCoachStep={isLastCoachStep}
|
||||||
showJumpToTimerOwner={showJumpToTimerOwner}
|
showJumpToTimerOwner={showJumpToTimerOwner}
|
||||||
showJumpToTimerOwnerRow
|
showJumpToTimerOwnerRow
|
||||||
onJumpToTimerOwner={() => setStep(timerOwningStep ?? step)}
|
onJumpToTimerOwner={() => setStep(timerOwningStep ?? safeStep)}
|
||||||
timerOwnerLabelIndex={timerOwningStep ?? 0}
|
timerOwnerLabelIndex={timerOwningStep ?? 0}
|
||||||
branchGateMode={atBranchGate}
|
branchGateMode={atBranchGate}
|
||||||
/>
|
/>
|
||||||
|
|
@ -968,54 +1066,77 @@ export default function TrainingCoachPage() {
|
||||||
<div
|
<div
|
||||||
className="card"
|
className="card"
|
||||||
style={{
|
style={{
|
||||||
padding: '16px 14px',
|
padding: '18px 16px',
|
||||||
marginBottom: '12px',
|
marginBottom: '12px',
|
||||||
borderRadius: '12px',
|
borderRadius: '14px',
|
||||||
borderLeft: '4px solid var(--accent)',
|
border: '2px solid var(--accent)',
|
||||||
background: 'var(--surface)',
|
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' }}>
|
<div style={{ fontSize: '0.74rem', fontWeight: 800, color: 'var(--accent-dark)', marginBottom: '6px', letterSpacing: '0.04em' }}>
|
||||||
Parallele Phase · Coaching-Zweig
|
⑂ SPLIT — GRUPPE WÄHLEN
|
||||||
</div>
|
</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()
|
{currentEntry.branchMeta.phaseTitle != null && String(currentEntry.branchMeta.phaseTitle).trim()
|
||||||
? String(currentEntry.branchMeta.phaseTitle).trim()
|
? String(currentEntry.branchMeta.phaseTitle).trim()
|
||||||
: `Phase ${currentEntry.branchMeta.phaseOrderIndex}`}
|
: `Phase ${currentEntry.branchMeta.phaseOrderIndex}`}
|
||||||
</p>
|
</p>
|
||||||
<p style={{ fontSize: '0.86rem', color: 'var(--text2)', margin: '0 0 14px', lineHeight: 1.45 }}>
|
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', margin: '0 0 16px', lineHeight: 1.45 }}>
|
||||||
Welchen Stream coachen Sie jetzt? Jeder Trainer kann auf seinem Gerät eine andere Gruppe wählen. Sobald Sie
|
Tippen Sie auf eine Kachel, um <strong>diese Gruppe</strong> zu coachen. Andere Trainer:innen wählen auf
|
||||||
wählen, folgen nacheinander die Übungen genau dieser Spalte (ohne Verschränkung mit den anderen Streams).
|
ihrem Gerät parallel eine andere Kachel.
|
||||||
</p>
|
</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) => {
|
{(currentEntry.branchMeta.streams || []).map((st) => {
|
||||||
const hinted =
|
const hinted =
|
||||||
streamChoiceHint?.phaseOrder === currentEntry.branchMeta.phaseOrderIndex &&
|
streamChoiceHint?.phaseOrder === currentEntry.branchMeta.phaseOrderIndex &&
|
||||||
streamChoiceHint?.streamOrder === st.streamOrder
|
streamChoiceHint?.streamOrder === st.streamOrder
|
||||||
const label = st.streamTitle?.trim() ? String(st.streamTitle).trim() : `Gruppe ${st.streamOrder + 1}`
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={st.printStreamId || `s-${st.streamOrder}`}
|
key={st.printStreamId || `s-${st.streamOrder}`}
|
||||||
type="button"
|
type="button"
|
||||||
className={hinted ? 'btn btn-primary' : 'btn btn-secondary'}
|
className="btn"
|
||||||
style={{ textAlign: 'left', justifyContent: 'flex-start', padding: '12px 14px', fontWeight: 600 }}
|
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)}
|
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
|
<span
|
||||||
style={{
|
style={{
|
||||||
display: 'block',
|
display: 'block',
|
||||||
fontSize: '0.82rem',
|
fontSize: '0.82rem',
|
||||||
fontWeight: 500,
|
fontWeight: 600,
|
||||||
opacity: 0.9,
|
opacity: hinted ? 0.95 : 0.88,
|
||||||
marginTop: '4px',
|
marginTop: '8px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
ca. {st.minutes} Min. (Üb.) · Antippen zum Fortfahren
|
≈ {st.minutes} Min. Üb. · Jetzt aktivieren
|
||||||
</span>
|
</span>
|
||||||
{hinted ? (
|
{hinted ? (
|
||||||
<span style={{ display: 'block', fontSize: '0.75rem', marginTop: '6px', opacity: 0.95 }}>
|
<span style={{ display: 'block', fontSize: '0.74rem', fontWeight: 600, marginTop: '8px', opacity: 0.92 }}>
|
||||||
Hinweis aus Planansicht — bei Bedarf andere Gruppe wählen.
|
Vorschlag aus Planansicht — trotzdem andere Kachel möglich
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1026,8 +1147,8 @@ export default function TrainingCoachPage() {
|
||||||
) : currentEntry?.item?.item_type === 'note' ? (
|
) : 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{' '}
|
||||||
{step + 1}
|
{safeStep + 1}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginBottom: '6px' }}>Coach-Notiz</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>
|
<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 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' }}>
|
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', marginBottom: '6px' }}>
|
||||||
In diesem Training · {currentEntry.coachContext || currentEntry?.sec.title || 'Abschnitt'} · Teil{' '}
|
In diesem Training · {currentEntry?.coachContext || currentEntry?.sec?.title || 'Abschnitt'} · Teil{' '}
|
||||||
{step + 1}
|
{safeStep + 1}
|
||||||
</div>
|
</div>
|
||||||
{currentEntry?.item && (
|
{currentEntry?.item && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -1180,7 +1301,7 @@ export default function TrainingCoachPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CoachControlsBand
|
<CoachControlsBand
|
||||||
step={step}
|
step={safeStep}
|
||||||
timelineLength={timeline.length}
|
timelineLength={timeline.length}
|
||||||
onPrev={goPrev}
|
onPrev={goPrev}
|
||||||
onNext={goNext}
|
onNext={goNext}
|
||||||
|
|
@ -1196,11 +1317,13 @@ export default function TrainingCoachPage() {
|
||||||
isLastCoachStep={isLastCoachStep}
|
isLastCoachStep={isLastCoachStep}
|
||||||
showJumpToTimerOwner={showJumpToTimerOwner}
|
showJumpToTimerOwner={showJumpToTimerOwner}
|
||||||
showJumpToTimerOwnerRow={false}
|
showJumpToTimerOwnerRow={false}
|
||||||
onJumpToTimerOwner={() => setStep(timerOwningStep ?? step)}
|
onJumpToTimerOwner={() => setStep(timerOwningStep ?? safeStep)}
|
||||||
timerOwnerLabelIndex={timerOwningStep ?? 0}
|
timerOwnerLabelIndex={timerOwningStep ?? 0}
|
||||||
branchGateMode={atBranchGate}
|
branchGateMode={atBranchGate}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildPlanPayloadForSave,
|
||||||
cloneJsonSerializablePlanningProfile,
|
cloneJsonSerializablePlanningProfile,
|
||||||
inheritPlanLocForPhasedSave,
|
inheritPlanLocForPhasedSave,
|
||||||
phaseRunsFromSections,
|
phaseRunsFromSections,
|
||||||
|
|
@ -455,6 +456,40 @@ export function durationOverridesMapFromDeltas(unit, deltas) {
|
||||||
return out
|
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) {
|
export function summarizeTimelineEntry(ent) {
|
||||||
if (!ent) return ''
|
if (!ent) return ''
|
||||||
if (ent.entryKind === COACH_ENTRY_BRANCH_GATE) {
|
if (ent.entryKind === COACH_ENTRY_BRANCH_GATE) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user