All checks were successful
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 10s
Test Suite / playwright-tests (push) Successful in 55s
- Updated ExerciseFullContent to accept a new `variantId` prop and display variant-specific information. - Enhanced TrainingCoachPage to pass `variantId` when rendering ExerciseFullContent. - Refactored TrainingUnitRunPage to manage exercise context with variant support, including updates to modal handling. - Improved UI to show variant details, including name, description, and execution changes where applicable.
848 lines
33 KiB
JavaScript
848 lines
33 KiB
JavaScript
/**
|
||
* 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} 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>Ist‑Minuten</strong> prüfen, Trainer‑Ergä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 & 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}
|
||
/>
|
||
</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 & speichern</span>
|
||
<span>{debriefOpen ? '▼' : '▶'}</span>
|
||
</button>
|
||
{debriefOpen && (
|
||
<>
|
||
<p style={{ fontSize: '0.82rem', color: 'var(--text2)', margin: '14px 0 10px', lineHeight: 1.5 }}>
|
||
<strong>Ist‑Minuten</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>
|
||
)
|
||
}
|