shinkan-jinkendo/frontend/src/pages/TrainingCoachPage.jsx

601 lines
24 KiB
React
Raw Normal View History

/**
* 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 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 CoachStepNavBar({ step, timelineLength, onPrev, onNext, onDone }) {
const disPrev = step <= 0
const disNext = step >= timelineLength - 1
return (
<div className="training-coach-stepbar" style={{ marginBottom: '10px' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px', marginBottom: '8px' }}>
<button type="button" className="btn btn-secondary" style={{ minHeight: '50px', fontWeight: 700 }} disabled={disPrev} onClick={onPrev}>
← zurück
</button>
<button type="button" className="btn btn-secondary" style={{ minHeight: '50px', fontWeight: 700 }} disabled={disNext} onClick={onNext}>
weiter →
</button>
</div>
<button type="button" className="btn btn-primary" style={{ width: '100%', minHeight: '48px', fontWeight: 700 }} onClick={onDone}>
Abgeschlossen &amp; weiter
</button>
</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 [step, setStep] = useState(0)
const [deltas, setDeltas] = useState({})
const [runStartAt, setRunStartAt] = useState(null)
const [pausedAccumMs, setPausedAccumMs] = useState(0)
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 */
}
} 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(() => {
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
}
setStep((prev) => clampStep(prev, timeline.length))
}, [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 timerStart = () => {
setRunStartAt(Date.now())
}
const timerPause = () => {
if (runStartAt != null) {
setPausedAccumMs((a) => a + (Date.now() - runStartAt))
setRunStartAt(null)
}
}
const timerReset = () => {
setRunStartAt(null)
setPausedAccumMs(0)
}
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 })
timerPause()
}
const goPrev = () => setStep((s) => clampStep(s - 1))
const goNext = () => setStep((s) => clampStep(s + 1))
const markCurrentDoneAdvance = () => {
timerPause()
if (step < timeline.length - 1) setStep((s) => s + 1)
}
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 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' } : {}),
sections: sectionsPayload,
})
await reloadUnit()
setTrainerAppend('')
try {
sessionStorage.removeItem(storageDeltasKey(idNum))
} catch {
/* ignore */
}
setDeltas({})
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 · 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 = 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={() => 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>
) : (
<>
<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 — unten Zeit speichern / Nachbereitung öffnen.
</p>
)}
</div>
<CoachStepNavBar
step={step}
timelineLength={timeline.length}
onPrev={goPrev}
onNext={goNext}
onDone={markCurrentDoneAdvance}
/>
<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>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>
<div className="card training-coach-timer" style={{ flexShrink: 0, padding: '12px', textAlign: 'center', background: 'var(--surface2)', borderRadius: '12px', marginBottom: '8px' }}>
<div style={{ fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--text3)', marginBottom: '6px' }}>Zeitnahme</div>
<div style={{ fontSize: '2.2rem', fontWeight: 800, letterSpacing: '0.06em', fontVariantNumeric: 'tabular-nums', marginBottom: '10px' }}>
{formatClock(tickDisplaySec)}
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', justifyContent: 'center', marginBottom: '8px' }}>
{!runStartAt ? (
<button type="button" className="btn btn-primary" style={{ minWidth: '100px', minHeight: '46px' }} onClick={timerStart}>
Start
</button>
) : (
<button type="button" className="btn btn-secondary" style={{ minWidth: '100px', minHeight: '46px' }} onClick={timerPause}>
Pause / Stopp
</button>
)}
<button type="button" className="btn btn-secondary" style={{ minHeight: '46px' }} onClick={() => timerReset()}>
Zurücksetzen
</button>
</div>
<p style={{ fontSize: '0.76rem', color: 'var(--text2)', marginBottom: '10px' }}>
Übernimmt Ist‑Minuten für diesen Platz (gerundet).
</p>
<button type="button" className="btn btn-primary" style={{ width: '100%', maxWidth: '400px', minHeight: '46px' }} onClick={applySuggestedDuration}>
{`Übernehmen Ist-Zeit (${Math.max(1, Math.round(elapsedMs / 60000))} Min.)`}
</button>
</div>
<CoachStepNavBar step={step} timelineLength={timeline.length} onPrev={goPrev} onNext={goNext} onDone={markCurrentDoneAdvance} />
</>
)}
</div>
)
}