shinkan-jinkendo/frontend/src/pages/TrainingCoachPage.jsx
Lars 5215a2adc5
Some checks failed
Deploy Development / deploy (push) Successful in 34s
Deploy Production / deploy (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 44s
Test Suite / playwright-tests (push) Failing after 1m54s
feat: update TrainingCoachPage with additional useEffect hooks for state management
- Added a new useEffect to handle updates for deltas and idNum, improving state synchronization.
- Introduced another useEffect to manage session storage for coach debrief phase, enhancing user experience during training sessions.
- Implemented a timer functionality with setInterval to track pulse updates, ensuring real-time feedback during training.
2026-04-29 08:16:33 +02:00

847 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Coach-Modus: eine Position nach der anderen mit Assistentenhinweisen, Zeitnahme und optionaler Nachbereitung.
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../utils/api'
import ExerciseFullContent from '../components/ExerciseFullContent'
import {
flattenPlanTimeline,
itemStableKey,
sectionsToPutPayload,
summarizeTimelineEntry,
} from '../utils/trainingPlanUtils'
function storageStepKey(unitId) {
return `sj_coach_step_${unitId}`
}
function storageDeltasKey(unitId) {
return `sj_coach_deltas_${unitId}`
}
function storageDebriefKey(unitId) {
return `sj_coach_debrief_${unitId}`
}
function formatClock(totalSec) {
const m = Math.floor(totalSec / 60)
const s = totalSec % 60
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
function mergeDelta(setDeltas, itemKey, patch) {
setDeltas((prev) => ({ ...prev, [itemKey]: { ...prev[itemKey], ...patch } }))
}
function CoachControlsBand({
step,
timelineLength,
onPrev,
onNext,
onDone,
clockStr,
runStartAt,
pausedAccumMs,
onTimerStart,
onTimerPause,
onTimerReset,
onApplyActual,
roundedMinForApply,
isLastCoachStep,
showJumpToTimerOwner,
showJumpToTimerOwnerRow = true,
onJumpToTimerOwner,
timerOwnerLabelIndex,
}) {
const disPrev = step <= 0
const disNext = step >= timelineLength - 1
const alive = runStartAt != null || pausedAccumMs > 0
const canApplyForOwner = roundedMinForApply != null && roundedMinForApply >= 1
const istLabelMin = roundedMinForApply == null ? '—' : String(roundedMinForApply)
const doneLabel =
timelineLength <= 1
? 'Nachbereitung öffnen'
: isLastCoachStep
? 'Nachbereitung & Ist-Zeit'
: 'Abgeschlossen & weiter'
return (
<div className="training-coach-stepbar" style={{ marginBottom: '10px' }}>
{showJumpToTimerOwner && showJumpToTimerOwnerRow && (
<div style={{ textAlign: 'center', marginBottom: '8px' }}>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '0.82rem', fontWeight: 600, padding: '8px 12px', lineHeight: 1.3 }}
onClick={onJumpToTimerOwner}
>
Zur laufenden Übung ({timerOwnerLabelIndex + 1}/{timelineLength})
</button>
</div>
)}
<div
style={{
display: 'grid',
gridTemplateColumns: 'minmax(92px,1fr) minmax(0,2fr) minmax(92px,1fr)',
gap: '8px',
alignItems: 'stretch',
}}
>
<button
type="button"
className="btn btn-secondary"
style={{ minHeight: '46px', fontWeight: 700, padding: '6px 8px' }}
disabled={disPrev}
onClick={onPrev}
>
zurück
</button>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', justifyContent: 'center' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', justifyContent: 'center', alignItems: 'center' }}>
{runStartAt == null ? (
<button
type="button"
className="btn btn-primary"
style={{ minHeight: '44px', flex: '1 1 auto', fontWeight: 700 }}
onClick={onTimerStart}
>
Start{alive ? ` · ${clockStr}` : ''}
</button>
) : (
<button type="button" className="btn btn-secondary" style={{ minHeight: '44px', flex: '1 1 auto', fontWeight: 700 }} onClick={onTimerPause}>
Pause · {clockStr}
</button>
)}
<button type="button" className="btn btn-secondary" style={{ minHeight: '44px', flex: '0 1 auto', fontWeight: 600 }} disabled={!canApplyForOwner} onClick={onApplyActual}>
Ist ({istLabelMin}&nbsp;Min)
</button>
{alive && (
<button
type="button"
className="btn btn-secondary"
title="Zeit löschen"
style={{ minHeight: '44px', flex: '0 0 auto', padding: '0 10px' }}
onClick={onTimerReset}
>
</button>
)}
</div>
<button
type="button"
className="btn btn-primary"
style={{ width: '100%', minHeight: '44px', fontWeight: 700, lineHeight: 1.2, padding: '6px 10px', whiteSpace: 'normal', textAlign: 'center' }}
onClick={onDone}
>
{doneLabel}
</button>
</div>
<button
type="button"
className="btn btn-secondary"
style={{ minHeight: '46px', fontWeight: 700, padding: '6px 8px' }}
disabled={disNext}
onClick={onNext}
>
weiter
</button>
</div>
</div>
)
}
export default function TrainingCoachPage() {
const { unitId } = useParams()
const navigate = useNavigate()
const idNum = unitId ? parseInt(unitId, 10) : NaN
const [unit, setUnit] = useState(null)
const [loadError, setLoadError] = useState(null)
const [loading, setLoading] = useState(true)
const [outlineOpen, setOutlineOpen] = useState(false)
const [catalogExercise, setCatalogExercise] = useState(null)
const [catalogLoading, setCatalogLoading] = useState(false)
const [catalogError, setCatalogError] = useState(null)
const [debriefOpen, setDebriefOpen] = useState(false)
const [coachDebriefPhase, setCoachDebriefPhase] = useState(false)
const [step, setStep] = useState(0)
const [deltas, setDeltas] = useState({})
const [runStartAt, setRunStartAt] = useState(null)
const [pausedAccumMs, setPausedAccumMs] = useState(0)
const [timerOwningStep, setTimerOwningStep] = useState(null)
const [, setPulse] = useState(0)
const [trainerAppend, setTrainerAppend] = useState('')
const [saveMarkDone, setSaveMarkDone] = useState(true)
const [saving, setSaving] = useState(false)
const [saveOk, setSaveOk] = useState(null)
const reloadUnit = useCallback(async () => {
const u = await api.getTrainingUnit(idNum)
setUnit(u)
}, [idNum])
useEffect(() => {
if (!unitId || Number.isNaN(idNum)) {
setLoadError('Ungültige Trainingseinheit')
setLoading(false)
return
}
let cancelled = false
;(async () => {
setLoading(true)
setLoadError(null)
try {
await reloadUnit()
if (cancelled) return
try {
const s = parseInt(sessionStorage.getItem(storageStepKey(idNum)), 10)
if (!Number.isNaN(s) && s >= 0) setStep(s)
} catch {
/* ignore */
}
try {
const raw = sessionStorage.getItem(storageDeltasKey(idNum))
if (raw) {
const o = JSON.parse(raw)
if (o && typeof o === 'object') setDeltas(o)
}
} catch {
/* ignore */
}
try {
if (sessionStorage.getItem(storageDebriefKey(idNum)) === '1') setCoachDebriefPhase(true)
} catch {
/* ignore */
}
} catch (e) {
if (!cancelled) setLoadError(e.message || 'Laden fehlgeschlagen')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [unitId, idNum, reloadUnit])
useEffect(() => {
sessionStorage.setItem(storageStepKey(idNum), String(step))
}, [idNum, step])
useEffect(() => {
try {
sessionStorage.setItem(storageDeltasKey(idNum), JSON.stringify(deltas))
} catch {
/* quota */
}
}, [idNum, deltas])
useEffect(() => {
try {
sessionStorage.setItem(storageDebriefKey(idNum), coachDebriefPhase ? '1' : '0')
} catch {
/* quota */
}
}, [idNum, coachDebriefPhase])
useEffect(() => {
if (runStartAt == null) return undefined
const iv = setInterval(() => setPulse((p) => p + 1), 380)
return () => clearInterval(iv)
}, [runStartAt])
const timeline = useMemo(() => flattenPlanTimeline(unit), [unit])
const clampStep = (s, len = timeline.length) =>
Math.max(0, Math.min(s, Math.max(len - 1, 0)))
useEffect(() => {
if (!unit) return
if (timeline.length === 0) {
setStep(0)
return
}
if (coachDebriefPhase) return
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])
const elapsedMs =
pausedAccumMs + (runStartAt != null ? Date.now() - runStartAt : 0)
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 clockStr = formatClock(tickDisplaySec)
const roundedMinApply = elapsedMs <= 650 ? null : Math.max(1, Math.round(elapsedMs / 60000))
const showJumpToTimerOwner =
timerOwningStep != null &&
step !== timerOwningStep &&
(runStartAt != null || pausedAccumMs > 0)
const isLastCoachStep = timeline.length > 0 && step >= timeline.length - 1
const timerStart = () => {
setRunStartAt(Date.now())
setTimerOwningStep(step)
}
const timerPause = () => {
if (runStartAt != null) {
setPausedAccumMs((a) => a + (Date.now() - runStartAt))
setRunStartAt(null)
}
}
const timerReset = () => {
setRunStartAt(null)
setPausedAccumMs(0)
setTimerOwningStep(null)
}
const applySuggestedDuration = () => {
const idx = timerOwningStep != null ? timerOwningStep : step
const ent = timeline[idx]
const item = ent?.item
if (!item || item.item_type !== 'exercise') return
if (elapsedMs <= 650) return
const min = Math.max(1, Math.round(elapsedMs / 60000))
const key = itemStableKey(item, ent.secOrder, ent.ii)
mergeDelta(setDeltas, key, { actual_duration_min: min })
timerPause()
}
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]
const item = ent?.item
if (item?.item_type === 'exercise' && elapsedMs > 650) {
const key = itemStableKey(item, ent.secOrder, ent.ii)
const min = Math.max(1, Math.round(elapsedMs / 60000))
mergeDelta(setDeltas, key, { actual_duration_min: min })
}
timerReset()
const lastIdx = timeline.length - 1
if (step >= lastIdx && lastIdx >= 0) {
setCoachDebriefPhase(true)
try {
sessionStorage.setItem(storageDebriefKey(idNum), '1')
} catch {
/* ignore */
}
return
}
setStep((s) => clampStep(s + 1, timeline.length))
}
const durationOverridesForApi = useMemo(() => {
const out = {}
for (let i = 0; i < timeline.length; i++) {
const ent = timeline[i]
const { item } = ent
if (item.item_type !== 'exercise' || item.id == null) continue
const k = itemStableKey(item, ent.secOrder, ent.ii)
const dv = deltas[k]?.actual_duration_min
if (dv !== undefined && dv !== '' && dv !== null && !Number.isNaN(Number(dv))) {
out[String(item.id)] = { actual_duration_min: Number(dv) }
}
}
return out
}, [timeline, deltas])
useEffect(() => {
const item = currentEntry?.item
if (!item || item.item_type === 'note') {
setCatalogExercise(null)
setCatalogError(null)
setCatalogLoading(false)
return
}
const eid = item.exercise_id
if (!eid) {
setCatalogExercise(null)
setCatalogError(null)
setCatalogLoading(false)
return
}
let cancelled = false
setCatalogLoading(true)
setCatalogError(null)
setCatalogExercise(null)
api.getExercise(eid)
.then((ex) => {
if (!cancelled) {
setCatalogExercise(ex)
setCatalogLoading(false)
}
})
.catch((err) => {
if (!cancelled) {
setCatalogError(err.message || String(err))
setCatalogExercise(null)
setCatalogLoading(false)
}
})
return () => {
cancelled = true
}
}, [step, currentEntry?.item?.exercise_id, currentEntry?.item?.item_type])
const handleSaveDebrief = async () => {
setSaveOk(null)
setSaving(true)
try {
const sectionsPayload = sectionsToPutPayload(unit, durationOverridesForApi)
const tn = trainerAppend.trim()
const payload = {
sections: sectionsPayload,
...(saveMarkDone ? { status: 'completed' } : {}),
}
if (tn) {
payload.trainer_notes = [unit.trainer_notes || '', `--- (${new Date().toLocaleString('de-DE')}) ---`, tn]
.filter(Boolean)
.join('\n')
.trim()
}
await api.updateTrainingUnit(idNum, payload)
await reloadUnit()
setTrainerAppend('')
try {
sessionStorage.removeItem(storageDeltasKey(idNum))
sessionStorage.removeItem(storageDebriefKey(idNum))
} catch {
/* ignore */
}
setDeltas({})
setCoachDebriefPhase(false)
setSaveOk('Gespeichert.')
setDebriefOpen(false)
} catch (e) {
setSaveOk(`Fehler: ${e.message || e}`)
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="spinner" />
<p>Coach laden</p>
</div>
)
}
if (loadError || !unit) {
return (
<div className="card" style={{ margin: '1rem', padding: '1.5rem' }}>
<p style={{ marginBottom: '1rem' }}>{loadError || 'Nicht gefunden.'}</p>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/planning')}>
Zur Planung
</button>
</div>
)
}
return (
<div className="training-coach-page training-coach-layout">
<nav
className="no-print training-coach-meta-nav"
style={{
flexShrink: 0,
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
alignItems: 'center',
marginBottom: '8px',
paddingBottom: '4px'
}}
>
<button type="button" className="btn btn-secondary" onClick={() => navigate(`/planning/run/${unitId}`)}>
Zur Planansicht
</button>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/planning')}>
Planung
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => setOutlineOpen((o) => !o)}
style={{ marginLeft: 'auto' }}
>
{outlineOpen ? 'Rahmen schließen' : 'Trainingsrahmen'}
</button>
</nav>
<header
className="card training-coach-hero training-coach-hero--compact"
style={{
flexShrink: 0,
padding: '12px 14px',
marginBottom: '8px',
background: 'var(--surface)',
borderRadius: '12px',
borderLeft: `4px solid var(--accent)`
}}
>
<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)}`}
</div>
<h1 style={{ fontSize: '1.1rem', margin: '4px 0 0', lineHeight: 1.28 }}>
{unit.planned_date}
{unit.planned_time_start && ` · ${String(unit.planned_time_start).slice(0, 5)}`}
{unit.planned_focus ? ` · ${unit.planned_focus}` : ''}
</h1>
</header>
{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 style={{ fontSize: '0.75rem', color: 'var(--text3)', marginBottom: '6px', flexShrink: 0 }}>Ablauf · Antippen zum Springen</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', overflowY: 'auto', minHeight: 0 }}>
{timeline.map((ent, ix) => {
const lbl = summarizeTimelineEntry(ent)
const secTitle = ent.sec.title || `Abschnitt ${ent.si + 1}`
const active = coachDebriefPhase ? ix === timeline.length - 1 : ix === step
return (
<button
key={`${itemStableKey(ent.item, ent.secOrder, ent.ii)}-${ix}`}
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)
}}
>
<span style={{ opacity: 0.75 }}>{secTitle.substring(0, 14)} </span>
<span>{lbl}</span>
</button>
)
})}
</div>
</div>
)}
{timeline.length === 0 ? (
<p className="card" style={{ padding: '1rem', flexShrink: 0 }}>
Dieser Plan ist leer. <Link to="/planning">Unter Planung ergänzen</Link>.
</p>
) : coachDebriefPhase ? (
<div className="training-coach-scroll" style={{ flex: 1, minHeight: 0 }}>
<div className="card" style={{ padding: '14px', marginBottom: '12px', borderRadius: '12px', borderLeft: `4px solid var(--accent)` }}>
<div style={{ fontSize: '0.88rem', fontWeight: 700, marginBottom: '8px', color: 'var(--accent-dark)' }}>
Nachbereitung
</div>
<p style={{ fontSize: '0.82rem', color: 'var(--text2)', margin: '0 0 14px', lineHeight: 1.5 }}>
<strong>IstMinuten</strong> prüfen, TrainerErgänzung anlegen und Einheit sichern (zeigt bestehende Notizen weiter unten an).
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '12px' }}>
{timeline
.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 ?? ''
return (
<label key={`db-${k}`} style={{ display: 'grid', gridTemplateColumns: '1fr 88px', gap: '10px', alignItems: 'center', fontSize: '0.88rem' }}>
<span style={{ wordBreak: 'break-word' }}>{summarizeTimelineEntry(ent)}</span>
<input
type="number"
min="0"
className="form-input"
style={{ margin: 0 }}
value={val === '' || val == null ? '' : String(val)}
placeholder="Min"
onChange={(e) => {
const raw = e.target.value
if (raw === '') mergeDelta(setDeltas, k, { actual_duration_min: null })
else mergeDelta(setDeltas, k, { actual_duration_min: parseInt(raw, 10) })
}}
/>
</label>
)
})}
</div>
<label style={{ display: 'block', marginBottom: '10px', fontSize: '0.88rem', color: 'var(--text2)' }}>
Ergänzung Trainer (wird angehängt · mit Zeitstempel)
<textarea className="form-input" rows={3} value={trainerAppend} onChange={(e) => setTrainerAppend(e.target.value)} style={{ marginTop: '6px' }} />
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px', fontSize: '0.88rem' }}>
<input type="checkbox" checked={saveMarkDone} onChange={(e) => setSaveMarkDone(e.target.checked)} />
Einheit als <strong>durchgeführt</strong> markieren
</label>
{saveOk && (
<p
style={{
fontSize: '0.88rem',
marginBottom: '8px',
color: saveOk.startsWith('Fehler') ? 'var(--danger)' : 'var(--accent-dark)'
}}
>
{saveOk}
</p>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<button type="button" className="btn btn-primary" style={{ width: '100%', minHeight: '48px' }} disabled={saving} onClick={handleSaveDebrief}>
{saving ? 'Speichert…' : 'Speichern'}
</button>
<button
type="button"
className="btn btn-secondary"
style={{ width: '100%', minHeight: '44px' }}
onClick={() => {
setCoachDebriefPhase(false)
if (timeline.length > 0) setStep(Math.max(0, timeline.length - 1))
}}
>
Zurück zur letzten Übungsposition
</button>
</div>
</div>
{unit.trainer_notes ? (
<div className="card" style={{ marginBottom: '12px', padding: '12px 14px', borderLeft: `4px solid var(--accent)` }}>
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: 'var(--text3)', textTransform: 'uppercase' }}>Einheit · Trainernotizen</div>
<p style={{ fontSize: '0.88rem', marginTop: '6px', whiteSpace: 'pre-wrap', color: 'var(--text2)' }}>{unit.trainer_notes}</p>
</div>
) : null}
</div>
) : (
<>
<div
className="training-coach-assist training-coach-assist--compact"
style={{
flexShrink: 0,
marginBottom: '8px',
padding: '12px 14px',
background: 'var(--accent-light)',
borderRadius: '12px'
}}
>
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--accent-dark)', marginBottom: '6px' }}>
Als Nächstes
</div>
{nextEntry ? (
<>
<p style={{ margin: '0', fontSize: '0.9rem', color: 'var(--text1)', lineHeight: 1.4 }}>
<strong>Nächste:</strong> {summarizeTimelineEntry(nextEntry)}
</p>
{next2Entry && (
<p style={{ margin: '6px 0 0', fontSize: '0.82rem', color: 'var(--text2)' }}>
<strong>Daraufhin:</strong> {summarizeTimelineEntry(next2Entry)}
</p>
)}
</>
) : (
<p style={{ margin: 0, fontSize: '0.88rem', color: 'var(--text2)' }}>
Letzter Punkt Nachbereitung &amp; Ist-Zeit öffnet die Abschlussseite zum Speichern.
</p>
)}
</div>
<CoachControlsBand
step={step}
timelineLength={timeline.length}
onPrev={goPrev}
onNext={goNext}
onDone={markCurrentDoneAdvance}
clockStr={clockStr}
runStartAt={runStartAt}
pausedAccumMs={pausedAccumMs}
onTimerStart={timerStart}
onTimerPause={timerPause}
onTimerReset={timerReset}
onApplyActual={applySuggestedDuration}
roundedMinForApply={roundedMinApply}
isLastCoachStep={isLastCoachStep}
showJumpToTimerOwner={showJumpToTimerOwner}
showJumpToTimerOwnerRow
onJumpToTimerOwner={() => setStep(timerOwningStep ?? step)}
timerOwnerLabelIndex={timerOwningStep ?? 0}
/>
<div className="training-coach-scroll">
{currentEntry?.item?.item_type === 'note' ? (
<div className="card" style={{ padding: '16px 14px' }}>
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', marginBottom: '8px' }}>
{currentEntry.sec.title || 'Abschnitt'} · Coach-Notiz · Teil {step + 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>
</div>
) : (
<>
<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?.sec.title || 'Abschnitt'} · Teil {step + 1}
</div>
{currentEntry?.item && (
<>
<p style={{ margin: '0 0 6px', fontSize: '0.95rem', fontWeight: 700 }}>
{currentEntry.item.exercise_title ||
(currentEntry.item.exercise_id ? `Übung #${currentEntry.item.exercise_id}` : 'Übung')}
{currentEntry.item.exercise_variant_name ? (
<span style={{ fontWeight: 600, color: 'var(--text2)' }}> ({currentEntry.item.exercise_variant_name})</span>
) : null}
</p>
<p style={{ fontSize: '0.83rem', color: 'var(--text3)', marginBottom: '8px' }}>
geplant <strong>{currentEntry.item.planned_duration_min ?? '—'}</strong> Min · Ist (Plan):{' '}
<strong>
{durationOverridesForApi[String(currentEntry.item.id)] != null
? durationOverridesForApi[String(currentEntry.item.id)].actual_duration_min
: currentEntry.item.actual_duration_min ?? '—'}{' '}
Min
</strong>{' '}
{durationOverridesForApi[String(currentEntry.item.id)] != null ? '(Entwurf)' : ''}
</p>
{currentEntry.item.exercise_focus_area ? (
<p style={{ fontSize: '0.82rem', color: 'var(--text2)', marginBottom: '10px' }}>{currentEntry.item.exercise_focus_area}</p>
) : null}
{currentEntry.item.notes ? (
<div style={{ marginBottom: '10px', padding: '10px', background: 'var(--surface2)', borderRadius: '8px' }}>
<div style={{ fontSize: '0.72rem', textTransform: 'uppercase', color: 'var(--text3)' }}>Zu dieser Platzierung</div>
<p style={{ marginTop: '4px', whiteSpace: 'pre-wrap', fontSize: '0.93rem' }}>{currentEntry.item.notes}</p>
</div>
) : null}
{currentEntry.item.modifications ? (
<div style={{ padding: '10px', borderRadius: '8px', border: `1px solid var(--accent)` }}>
<div style={{ fontSize: '0.72rem', textTransform: 'uppercase', color: 'var(--accent-dark)' }}>Anpassung</div>
<p style={{ marginTop: '4px', whiteSpace: 'pre-wrap', fontSize: '0.93rem' }}>{currentEntry.item.modifications}</p>
</div>
) : null}
</>
)}
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '14px 0 12px' }} />
<div style={{ fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--text3)', marginBottom: '6px' }}>
Übung aus dem Katalog (vollständig)
</div>
<ExerciseFullContent
loading={catalogLoading}
error={catalogError}
exercise={catalogExercise}
exerciseId={currentEntry?.item?.exercise_id ?? null}
/>
</div>
</>
)}
{unit.trainer_notes ? (
<div className="card" style={{ marginBottom: '12px', padding: '12px 14px', borderLeft: `4px solid var(--accent)` }}>
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: 'var(--text3)', textTransform: 'uppercase' }}>Einheit · Trainernotizen</div>
<p style={{ fontSize: '0.88rem', marginTop: '6px', whiteSpace: 'pre-wrap', color: 'var(--text2)' }}>{unit.trainer_notes}</p>
</div>
) : null}
<div className="card training-coach-debrief" style={{ padding: '14px', marginBottom: '8px', borderRadius: '10px' }}>
<button
type="button"
className="btn btn-secondary"
style={{ width: '100%', textAlign: 'left', justifyContent: 'space-between', display: 'flex', alignItems: 'center' }}
onClick={() => setDebriefOpen(!debriefOpen)}
>
<span style={{ fontWeight: 700 }}>Nachbereitung &amp; speichern</span>
<span>{debriefOpen ? '▼' : '▶'}</span>
</button>
{debriefOpen && (
<>
<p style={{ fontSize: '0.82rem', color: 'var(--text2)', margin: '14px 0 10px', lineHeight: 1.5 }}>
<strong>IstMinuten</strong> anpassen und Einheit speichern (inkl. Trainer-Ergänzung).
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '12px' }}>
{timeline
.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 ?? ''
return (
<label key={k} style={{ display: 'grid', gridTemplateColumns: '1fr 88px', gap: '10px', alignItems: 'center', fontSize: '0.88rem' }}>
<span style={{ wordBreak: 'break-word' }}>{summarizeTimelineEntry(ent)}</span>
<input
type="number"
min="0"
className="form-input"
style={{ margin: 0 }}
value={val === '' || val == null ? '' : String(val)}
placeholder="Min"
onChange={(e) => {
const raw = e.target.value
if (raw === '') mergeDelta(setDeltas, k, { actual_duration_min: null })
else mergeDelta(setDeltas, k, { actual_duration_min: parseInt(raw, 10) })
}}
/>
</label>
)
})}
</div>
<label style={{ display: 'block', marginBottom: '10px', fontSize: '0.88rem', color: 'var(--text2)' }}>
Ergänzung Trainer (wird angehängt · mit Zeitstempel)
<textarea className="form-input" rows={3} value={trainerAppend} onChange={(e) => setTrainerAppend(e.target.value)} style={{ marginTop: '6px' }} />
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px', fontSize: '0.88rem' }}>
<input type="checkbox" checked={saveMarkDone} onChange={(e) => setSaveMarkDone(e.target.checked)} />
Einheit als <strong>durchgeführt</strong> markieren
</label>
{saveOk && (
<p
style={{
fontSize: '0.88rem',
marginBottom: '8px',
color: saveOk.startsWith('Fehler') ? 'var(--danger)' : 'var(--accent-dark)'
}}
>
{saveOk}
</p>
)}
<button type="button" className="btn btn-primary" style={{ width: '100%', minHeight: '48px' }} disabled={saving} onClick={handleSaveDebrief}>
{saving ? 'Speichert…' : 'Speichern'}
</button>
</>
)}
</div>
</div>
<CoachControlsBand
step={step}
timelineLength={timeline.length}
onPrev={goPrev}
onNext={goNext}
onDone={markCurrentDoneAdvance}
clockStr={clockStr}
runStartAt={runStartAt}
pausedAccumMs={pausedAccumMs}
onTimerStart={timerStart}
onTimerPause={timerPause}
onTimerReset={timerReset}
onApplyActual={applySuggestedDuration}
roundedMinForApply={roundedMinApply}
isLastCoachStep={isLastCoachStep}
showJumpToTimerOwner={showJumpToTimerOwner}
showJumpToTimerOwnerRow={false}
onJumpToTimerOwner={() => setStep(timerOwningStep ?? step)}
timerOwnerLabelIndex={timerOwningStep ?? 0}
/>
</>
)}
</div>
)
}