diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py
index cc9a34c..6208193 100644
--- a/backend/routers/training_planning.py
+++ b/backend/routers/training_planning.py
@@ -679,6 +679,17 @@ def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth)
_template_access(cur, tid, profile_id, role)
tpl_id_val = tid
+ trainer_notes_val = None
+ if "trainer_notes" not in data:
+ cur.execute(
+ "SELECT trainer_notes FROM training_units WHERE id = %s",
+ (unit_id,),
+ )
+ row_tn = cur.fetchone()
+ trainer_notes_val = row_tn["trainer_notes"] if row_tn else None
+ else:
+ trainer_notes_val = data.get("trainer_notes")
+
cur.execute(
"""
UPDATE training_units SET
@@ -708,7 +719,7 @@ def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth)
data.get("attendance_count"),
data.get("status"),
data.get("notes"),
- data.get("trainer_notes"),
+ trainer_notes_val,
tpl_id_val,
unit_id,
),
diff --git a/frontend/src/pages/TrainingCoachPage.jsx b/frontend/src/pages/TrainingCoachPage.jsx
index b09d0d6..b5e1419 100644
--- a/frontend/src/pages/TrainingCoachPage.jsx
+++ b/frontend/src/pages/TrainingCoachPage.jsx
@@ -20,6 +20,10 @@ 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
@@ -30,22 +34,119 @@ function mergeDelta(setDeltas, itemKey, patch) {
setDeltas((prev) => ({ ...prev, [itemKey]: { ...prev[itemKey], ...patch } }))
}
-function CoachStepNavBar({ step, timelineLength, onPrev, onNext, onDone }) {
+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 (
-
-
)
}
@@ -64,12 +165,14 @@ export default function TrainingCoachPage() {
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('')
@@ -110,6 +213,11 @@ export default function TrainingCoachPage() {
} catch {
/* ignore */
}
+ try {
+ if (sessionStorage.getItem(storageDebriefKey(idNum)) === '1') setCoachDebriefPhase(true)
+ } catch {
+ /* ignore */
+ }
} catch (e) {
if (!cancelled) setLoadError(e.message || 'Laden fehlgeschlagen')
} finally {
@@ -131,9 +239,14 @@ export default function TrainingCoachPage() {
} catch {
/* quota */
}
- }, [idNum, deltas])
-
useEffect(() => {
+ try {
+ sessionStorage.setItem(storageDebriefKey(idNum), coachDebriefPhase ? '1' : '0')
+ } catch {
+ /* quota */
+ }
+ }, [idNum, coachDebriefPhase])
+
if (runStartAt == null) return undefined
const iv = setInterval(() => setPulse((p) => p + 1), 380)
return () => clearInterval(iv)
@@ -150,8 +263,14 @@ export default function TrainingCoachPage() {
setStep(0)
return
}
+ if (coachDebriefPhase) return
setStep((prev) => clampStep(prev, timeline.length))
- }, [unit, 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)
@@ -162,8 +281,17 @@ export default function TrainingCoachPage() {
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 = () => {
@@ -176,13 +304,18 @@ export default function TrainingCoachPage() {
const timerReset = () => {
setRunStartAt(null)
setPausedAccumMs(0)
+ setTimerOwningStep(null)
}
const applySuggestedDuration = () => {
- if (!currentEntry?.item || currentEntry.item.item_type !== 'exercise') return
- const key = itemStableKey(currentEntry.item, currentEntry.secOrder, currentEntry.ii)
- const min = Math.max(1, Math.round(elapsedMs / 60000)) || null
- if (min != null) mergeDelta(setDeltas, key, { actual_duration_min: min })
+ 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()
}
@@ -190,8 +323,27 @@ export default function TrainingCoachPage() {
const goNext = () => setStep((s) => clampStep(s + 1))
const markCurrentDoneAdvance = () => {
- timerPause()
- if (step < timeline.length - 1) setStep((s) => s + 1)
+ 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(() => {
@@ -253,23 +405,27 @@ export default function TrainingCoachPage() {
try {
const sectionsPayload = sectionsToPutPayload(unit, durationOverridesForApi)
const tn = trainerAppend.trim()
- const mergedTrainer = tn
- ? [unit.trainer_notes || '', `--- (${new Date().toLocaleString('de-DE')}) ---`, tn].filter(Boolean).join('\n')
- : unit.trainer_notes
-
- await api.updateTrainingUnit(idNum, {
- trainer_notes: mergedTrainer.trim() ? mergedTrainer.trim() : unit.trainer_notes,
- ...(saveMarkDone ? { status: 'completed' } : {}),
+ 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) {
@@ -341,7 +497,8 @@ export default function TrainingCoachPage() {
}}
>
- Coach · Schritt {(step || 0) + 1} / {Math.max(timeline.length, 1)}
+ Coach ·{' '}
+ {coachDebriefPhase ? 'Nachbereitung · abschließend speichern' : `Schritt ${(step || 0) + 1} / ${Math.max(timeline.length, 1)}`}
{unit.planned_date}
@@ -357,7 +514,7 @@ export default function TrainingCoachPage() {
{timeline.map((ent, ix) => {
const lbl = summarizeTimelineEntry(ent)
const secTitle = ent.sec.title || `Abschnitt ${ent.si + 1}`
- const active = ix === step
+ const active = coachDebriefPhase ? ix === timeline.length - 1 : ix === step
return (
setStep(ix)}
+ onClick={() => {
+ setCoachDebriefPhase(false)
+ setStep(ix)
+ }}
>
{secTitle.substring(0, 14)} ›
{lbl}
@@ -384,6 +544,84 @@ export default function TrainingCoachPage() {
Dieser Plan ist leer. Unter Planung ergänzen.
+ ) : coachDebriefPhase ? (
+
+
+
+ Nachbereitung
+
+
+ Ist‑Minuten prüfen, Trainer‑Ergänzung anlegen und Einheit sichern (zeigt bestehende Notizen weiter unten an).
+
+
+ {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 (
+
+ )
+ })}
+
+
+
+ {saveOk && (
+
+ {saveOk}
+
+ )}
+
+
+ {saving ? 'Speichert…' : 'Speichern'}
+
+ {
+ setCoachDebriefPhase(false)
+ if (timeline.length > 0) setStep(Math.max(0, timeline.length - 1))
+ }}
+ >
+ Zurück zur letzten Übungsposition
+
+
+
+ {unit.trainer_notes ? (
+
+
Einheit · Trainernotizen
+
{unit.trainer_notes}
+
+ ) : null}
+
) : (
<>
) : (
- Letzter Punkt — unten Zeit speichern / Nachbereitung öffnen.
+ Letzter Punkt — „Nachbereitung & Ist-Zeit“ öffnet die Abschlussseite zum Speichern.
)}
- setStep(timerOwningStep ?? step)}
+ timerOwnerLabelIndex={timerOwningStep ?? 0}
/>
@@ -565,34 +816,26 @@ export default function TrainingCoachPage() {
-
-
Zeitnahme
-
- {formatClock(tickDisplaySec)}
-
-
- {!runStartAt ? (
-
- Start
-
- ) : (
-
- Pause / Stopp
-
- )}
- timerReset()}>
- Zurücksetzen
-
-
-
- Übernimmt Ist‑Minuten für diesen Platz (gerundet).
-
-
- {`Übernehmen Ist-Zeit (${Math.max(1, Math.round(elapsedMs / 60000))} Min.)`}
-
-
-
-
+ setStep(timerOwningStep ?? step)}
+ timerOwnerLabelIndex={timerOwningStep ?? 0}
+ />
>
)}