2026-04-29 06:49:16 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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 ExercisePeekModal from '../components/ExercisePeekModal'
|
|
|
|
|
|
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 statusLabel(s) {
|
|
|
|
|
|
if (s === 'completed') return 'Durchgeführt'
|
|
|
|
|
|
if (s === 'cancelled') return 'Abgesagt'
|
|
|
|
|
|
return 'Geplant'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function mergeDelta(setDeltas, itemKey, patch) {
|
|
|
|
|
|
setDeltas((prev) => ({ ...prev, [itemKey]: { ...prev[itemKey], ...patch } }))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 [peekId, setPeekId] = useState(null)
|
|
|
|
|
|
const [outlineOpen, setOutlineOpen] = useState(false)
|
|
|
|
|
|
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])
|
|
|
|
|
|
|
2026-04-29 06:54:41 +02:00
|
|
|
|
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 =
|
2026-04-29 06:49:16 +02:00
|
|
|
|
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])
|
|
|
|
|
|
|
|
|
|
|
|
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" style={{ maxWidth: '720px', margin: '0 auto', paddingBottom: 'calc(env(safe-area-inset-bottom) + 2rem)' }}>
|
|
|
|
|
|
<ExercisePeekModal open={peekId != null} exerciseId={peekId || null} onClose={() => setPeekId(null)} titleFallback={null} />
|
|
|
|
|
|
|
|
|
|
|
|
<nav
|
|
|
|
|
|
className="no-print training-coach-top"
|
|
|
|
|
|
style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center', marginBottom: '12px' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<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" style={{ padding: '14px 16px', marginBottom: '12px', background: 'var(--surface)', borderRadius: '12px', borderLeft: `4px solid var(--accent)` }}>
|
|
|
|
|
|
<div style={{ fontSize: '0.75rem', color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Im Training · Coach</div>
|
|
|
|
|
|
<h1 style={{ fontSize: '1.28rem', margin: '8px 0 6px', 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>
|
|
|
|
|
|
<div style={{ fontSize: '0.86rem', color: 'var(--text2)', display: 'flex', gap: '10px', flexWrap: 'wrap', alignItems: 'center' }}>
|
|
|
|
|
|
{unit.group_name && (
|
|
|
|
|
|
<span>
|
|
|
|
|
|
<strong>Gruppe</strong>: {unit.group_name}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<span>
|
|
|
|
|
|
<strong>Status:</strong> {statusLabel(unit.status)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span style={{ opacity: 0.85 }}>
|
|
|
|
|
|
Schritt {(step || 0) + 1} / {Math.max(timeline.length, 1)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
{outlineOpen && (
|
|
|
|
|
|
<div className="card training-coach-outline" style={{ marginBottom: '12px', padding: '12px 14px' }}>
|
|
|
|
|
|
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginBottom: '8px' }}>Ablauf (Antippen springt zum Schritt)</div>
|
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', maxHeight: '38vh', overflowY: 'auto' }}>
|
|
|
|
|
|
{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' }}>
|
|
|
|
|
|
Dieser Plan ist leer.{' '}
|
|
|
|
|
|
<Link to="/planning">Unter Planung ergänzen</Link>.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="card training-coach-assist" style={{ marginBottom: '12px', padding: '14px 16px', background: 'var(--accent-light)' }}>
|
|
|
|
|
|
<div style={{ fontSize: '0.75rem', fontWeight: 700, color: 'var(--accent-dark)', marginBottom: '8px' }}>Assistenz · als Nächstes</div>
|
|
|
|
|
|
{nextEntry ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<p style={{ margin: '0 0 6px', fontSize: '0.94rem', color: 'var(--text1)' }}>
|
|
|
|
|
|
<strong>Nächste:</strong> {summarizeTimelineEntry(nextEntry)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{next2Entry && (
|
|
|
|
|
|
<p style={{ margin: 0, fontSize: '0.85rem', color: 'var(--text2)' }}>
|
|
|
|
|
|
<strong>Daraufhin:</strong> {summarizeTimelineEntry(next2Entry)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p style={{ margin: 0, fontSize: '0.95rem', color: 'var(--text2)' }}>
|
|
|
|
|
|
Dies war der letzte Eintrag.
|
|
|
|
|
|
<br />
|
|
|
|
|
|
Gute Arbeit — unten kannst du notieren und Ist-Zeiten speichern.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{currentEntry && (
|
|
|
|
|
|
<div className="card training-coach-current" style={{ marginBottom: '14px', padding: '18px 16px' }}>
|
|
|
|
|
|
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginBottom: '6px' }}>
|
|
|
|
|
|
{currentEntry.sec.title || 'Abschnitt'} · Teil {step + 1}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{currentEntry.item.item_type === 'note' && (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div style={{ fontSize: '0.8rem', color: 'var(--text3)' }}>Coach-Notiz</div>
|
|
|
|
|
|
<p style={{ fontSize: '1.06rem', lineHeight: 1.52, whiteSpace: 'pre-wrap', marginTop: '8px' }}>{currentEntry.item.note_body || ''}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{currentEntry.item.item_type !== 'note' && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<h2 style={{ fontSize: '1.32rem', lineHeight: 1.35, marginBottom: '10px', fontWeight: 800 }}>
|
|
|
|
|
|
{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}
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
<p style={{ fontSize: '0.9rem', color: 'var(--text3)', marginBottom: '10px' }}>
|
|
|
|
|
|
geplant <strong>{currentEntry.item.planned_duration_min ?? '—'}</strong> Min · Ist (Plan/Bearbeitung):{' '}
|
|
|
|
|
|
<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.85rem', color: 'var(--text2)', marginBottom: '10px' }}>
|
|
|
|
|
|
Bereich: {currentEntry.item.exercise_focus_area}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{currentEntry.item.notes ? (
|
|
|
|
|
|
<div style={{ marginBottom: '12px', padding: '10px', background: 'var(--surface2)', borderRadius: '8px' }}>
|
|
|
|
|
|
<div style={{ fontSize: '0.72rem', textTransform: 'uppercase', color: 'var(--text3)' }}>Zu dieser Platzierung</div>
|
|
|
|
|
|
<p style={{ fontSize: '0.96rem', marginTop: '4px', whiteSpace: 'pre-wrap' }}>{currentEntry.item.notes}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{currentEntry.item.modifications ? (
|
|
|
|
|
|
<div style={{ marginBottom: '12px', padding: '10px', borderRadius: '8px', border: `1px solid var(--accent)` }}>
|
|
|
|
|
|
<div style={{ fontSize: '0.72rem', textTransform: 'uppercase', color: 'var(--accent-dark)' }}>Anpassung / Variante</div>
|
|
|
|
|
|
<p style={{ fontSize: '0.96rem', marginTop: '4px', whiteSpace: 'pre-wrap' }}>{currentEntry.item.modifications}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{currentEntry.item.exercise_id ? (
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={() => setPeekId(currentEntry.item.exercise_id)} style={{ marginTop: '4px' }}>
|
|
|
|
|
|
Übung aus Katalog (Popup)
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="card training-coach-timer" style={{ marginBottom: '14px', padding: '16px', textAlign: 'center', background: 'var(--surface2)' }}>
|
|
|
|
|
|
<div style={{ fontSize: '0.74rem', textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--text3)', marginBottom: '8px' }}>Zeitnahme für diesen Punkt</div>
|
|
|
|
|
|
<div style={{ fontSize: '2.65rem', fontWeight: 800, letterSpacing: '0.06em', fontVariantNumeric: 'tabular-nums', marginBottom: '14px' }}>
|
|
|
|
|
|
{formatClock(tickDisplaySec)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap', justifyContent: 'center', marginBottom: '10px' }}>
|
|
|
|
|
|
{!runStartAt ? (
|
|
|
|
|
|
<button type="button" className="btn btn-primary" style={{ minWidth: '112px', minHeight: '48px' }} onClick={timerStart}>
|
|
|
|
|
|
Start
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" style={{ minWidth: '112px', minHeight: '48px' }} onClick={timerPause}>
|
|
|
|
|
|
Pause / Stopp
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" style={{ minHeight: '48px' }} onClick={() => timerReset()}>
|
|
|
|
|
|
Zurücksetzen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p style={{ fontSize: '0.82rem', color: 'var(--text2)', marginBottom: '10px' }}>
|
|
|
|
|
|
Übernimmt die gemessene Zeit (auf volle Minuten gerundet) als <strong>Ist‑Minuten</strong> für dieses Element und kann später mit „Nachbereitung“ auf dem Server gespeichert werden.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<button type="button" className="btn btn-primary" style={{ width: '100%', maxWidth: '400px', minHeight: '48px' }} onClick={applySuggestedDuration}>
|
|
|
|
|
|
{`Übernehmen für Ist-Zeit (${Math.max(1, Math.round(elapsedMs / 60000))} Min.)`}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="training-coach-nav" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '14px' }}>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" style={{ minHeight: '52px', fontWeight: 700 }} disabled={step <= 0} onClick={goPrev}>
|
|
|
|
|
|
← zurück
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" style={{ minHeight: '52px', fontWeight: 700 }} disabled={step >= timeline.length - 1} onClick={goNext}>
|
|
|
|
|
|
weiter →
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ marginBottom: '16px', display: 'flex', gap: '10px', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'center' }}>
|
|
|
|
|
|
<button type="button" className="btn btn-primary" style={{ flex: '1 1 200px', minHeight: '50px', fontWeight: 700 }} onClick={markCurrentDoneAdvance}>
|
|
|
|
|
|
Abgeschlossen & weiter
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{unit.trainer_notes && (
|
|
|
|
|
|
<div className="card" style={{ marginBottom: '14px', padding: '14px', borderLeft: '4px solid var(--accent)' }}>
|
|
|
|
|
|
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text3)', textTransform: 'uppercase' }}>Trainernotiz (dieser Einheit)</div>
|
|
|
|
|
|
<p style={{ fontSize: '0.92rem', marginTop: '6px', whiteSpace: 'pre-wrap', color: 'var(--text2)' }}>{unit.trainer_notes}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="card training-coach-debrief" style={{ padding: '14px', marginBottom: '12px', borderRadius: '10px', borderColor: 'var(--border)' }}>
|
|
|
|
|
|
<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 }}>
|
|
|
|
|
|
Übertragene <strong>Ist‑Minuten</strong> (Entwürfe aus dem Timer oder hier anpassen). Beim Speichern werden sie mit dem Plan an den Server geschickt.
|
|
|
|
|
|
</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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|