shinkan-jinkendo/frontend/src/pages/TrainingCoachPage.jsx
Lars d3ddc52118
All checks were successful
Deploy Development / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 59s
feat(combo-planning): enhance combination profile handling and UI improvements
- Updated CombinationCoachSlots to integrate a new function for formatting inline profile values, improving data display consistency.
- Refactored CombinationMethodProfileEditor to streamline slot index handling and enhance title clarity.
- Improved CombinationPlanBracket by removing unnecessary elements for a cleaner UI.
- Enhanced ExerciseFullContent to support additional catalog method profile snapshots, improving exercise detail accuracy.
- Updated CSS for combo plan brackets to enhance visual presentation and alignment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 14:42:01 +02:00

858 lines
34 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?.exercise_variant_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}
variantId={currentEntry?.item?.exercise_variant_id ?? null}
catalogMethodProfileSnapshot={
String(currentEntry?.item?.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
? currentEntry?.item?.catalog_method_profile ?? null
: null
}
planningComboMethodProfile={
String(currentEntry?.item?.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
? currentEntry?.item?.planning_method_profile ?? null
: 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>
)
}