2026-04-29 06:37:52 +02:00
/ * *
* Trainingsablauf anzeigen , drucken und lokal auf der Matte abhaken ( Fortschritt im Browser gespeichert ) .
* /
import React , { useCallback , useEffect , useMemo , useState } from 'react'
import { Link , useNavigate , useParams } from 'react-router-dom'
import api from '../utils/api'
2026-04-29 06:49:16 +02:00
import ExercisePeekModal from '../components/ExercisePeekModal'
import { itemStableKey , sortedSections , sortedItems } from '../utils/trainingPlanUtils'
2026-04-29 06:37:52 +02:00
function storageKey ( unitId ) {
return ` sj_training_run_checked_ ${ unitId } `
}
function formatMin ( m ) {
if ( m === null || m === undefined || m === '' ) return null
const n = Number ( m )
if ( ! Number . isFinite ( n ) ) return null
return ` ${ n } Min. `
}
function statusLabel ( s ) {
if ( s === 'completed' ) return 'Durchgeführt'
if ( s === 'cancelled' ) return 'Abgesagt'
return 'Geplant'
}
export default function TrainingUnitRunPage ( ) {
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 [ checked , setChecked ] = useState ( ( ) => new Set ( ) )
2026-04-29 06:49:16 +02:00
const [ peekExerciseId , setPeekExerciseId ] = useState ( null )
2026-04-29 06:37:52 +02:00
const loadChecked = useCallback ( ( uid ) => {
try {
const raw = sessionStorage . getItem ( storageKey ( uid ) )
if ( ! raw ) return new Set ( )
const arr = JSON . parse ( raw )
if ( ! Array . isArray ( arr ) ) return new Set ( )
return new Set ( arr . map ( String ) )
} catch {
return new Set ( )
}
} , [ ] )
useEffect ( ( ) => {
if ( ! unitId || Number . isNaN ( idNum ) ) {
setLoadError ( 'Ungültige Trainingseinheit' )
setLoading ( false )
return
}
let cancelled = false
; ( async ( ) => {
setLoading ( true )
setLoadError ( null )
try {
const u = await api . getTrainingUnit ( idNum )
if ( ! cancelled ) {
setUnit ( u )
setChecked ( loadChecked ( idNum ) )
}
} catch ( e ) {
if ( ! cancelled ) setLoadError ( e . message || 'Laden fehlgeschlagen' )
} finally {
if ( ! cancelled ) setLoading ( false )
}
} ) ( )
return ( ) => {
cancelled = true
}
} , [ unitId , idNum , loadChecked ] )
const persistChecked = useCallback (
( next ) => {
setChecked ( next )
try {
sessionStorage . setItem ( storageKey ( idNum ) , JSON . stringify ( [ ... next ] ) )
} catch {
/* ignore quota */
}
} ,
[ idNum ]
)
const toggle = useCallback (
( key ) => {
const next = new Set ( checked )
if ( next . has ( key ) ) next . delete ( key )
else next . add ( key )
persistChecked ( next )
} ,
[ checked , persistChecked ]
)
const clearProgress = useCallback ( ( ) => {
persistChecked ( new Set ( ) )
try {
sessionStorage . removeItem ( storageKey ( idNum ) )
} catch {
/* ignore */
}
} , [ idNum , persistChecked ] )
const sections = useMemo ( ( ) => sortedSections ( unit ) , [ unit ] )
const totalPlannedMin = useMemo ( ( ) => {
let t = 0
for ( const sec of sections ) {
for ( const it of sortedItems ( sec ) ) {
if ( it . item _type === 'exercise' && it . planned _duration _min != null ) {
const n = Number ( it . planned _duration _min )
if ( Number . isFinite ( n ) ) t += n
}
}
}
return t
} , [ sections ] )
if ( loading ) {
return (
< div style = { { padding : '2rem' , textAlign : 'center' } } >
< div className = "spinner" / >
< p > Plan wird geladen … < / p >
< / div >
)
}
if ( loadError || ! unit ) {
return (
< div className = "card" style = { { margin : '1rem' , padding : '1.5rem' } } >
< p style = { { marginBottom : '1rem' } } > { loadError || 'Trainingseinheit nicht gefunden.' } < / p >
< button type = "button" className = "btn btn-secondary" onClick = { ( ) => navigate ( '/planning' ) } >
Zur Planung
< / button >
< / div >
)
}
return (
2026-05-05 12:39:15 +02:00
< div className = "training-run-page app-page app-page--constrained-sm" style = { { paddingBottom : '2rem' } } >
2026-04-29 06:49:16 +02:00
< ExercisePeekModal
open = { peekExerciseId != null }
exerciseId = { peekExerciseId }
onClose = { ( ) => setPeekExerciseId ( null ) }
/ >
< nav className = "training-run-toolbar no-print" style = { { marginBottom : '1rem' , display : 'flex' , gap : '0.5rem' , flexWrap : 'wrap' , alignItems : 'center' } } >
2026-04-29 06:37:52 +02:00
< button type = "button" className = "btn btn-secondary" onClick = { ( ) => navigate ( '/planning' ) } >
Zur Planung
< / button >
2026-04-29 06:49:16 +02:00
< Link
to = { ` /planning/run/ ${ unitId } /coach ` }
className = "btn btn-primary"
style = { { textDecoration : 'none' , display : 'inline-flex' , alignItems : 'center' , justifyContent : 'center' } }
>
Im Training ( Coach )
< / Link >
< button type = "button" className = "btn btn-secondary" onClick = { ( ) => window . print ( ) } >
2026-04-29 06:37:52 +02:00
Drucken / PDF
< / button >
< button type = "button" className = "btn btn-secondary" title = "Alle Häkchen auf dieser Matte zurücksetzen" onClick = { ( ) => confirm ( 'Fortschritt wirklich zurücksetzen?' ) && clearProgress ( ) } >
Fortschritt leeren
< / button >
< / nav >
< header className = "training-run-header card" style = { { marginBottom : '1rem' , padding : '1.25rem' } } >
< h1 style = { { fontSize : '1.35rem' , marginBottom : '0.75rem' , lineHeight : 1.3 } } >
Training
{ unit . planned _date && ` · ${ unit . planned _date } ` }
{ unit . planned _time _start && ` · ${ String ( unit . planned _time _start ) . slice ( 0 , 5 ) } ` }
{ unit . planned _time _end && ` – ${ String ( unit . planned _time _end ) . slice ( 0 , 5 ) } ` }
< / h1 >
< div style = { { fontSize : '0.9rem' , color : 'var(--text2)' , display : 'grid' , gap : '0.35rem' } } >
{ unit . group _name && (
< span >
< strong > Gruppe : < / strong > { unit . group _name }
{ unit . club _name && ` ( ${ unit . club _name } ) ` }
< / span >
) }
{ unit . group _location && (
< span >
< strong > Ort : < / strong > { unit . group _location }
< / span >
) }
{ unit . planned _focus && (
< span >
< strong > Fokus : < / strong > { unit . planned _focus }
< / span >
) }
< span >
< strong > Status : < / strong > { statusLabel ( unit . status ) }
< / span >
{ totalPlannedMin > 0 && (
< span >
< strong > Geplante Zeit ( Übungen ) : < / strong > ca . { totalPlannedMin } Min .
< / span >
) }
< / div >
{ unit . notes && (
< div
className = "training-run-notes-print"
style = { {
marginTop : '1rem' ,
padding : '0.65rem 0.85rem' ,
background : 'var(--accent-light)' ,
borderRadius : '8px' ,
fontSize : '0.92rem'
} }
>
< strong > Hinweis Teilnehmer : < / strong > { unit . notes }
< / div >
) }
< / header >
{ sections . length === 0 ? (
< p className = "card" style = { { padding : '1.25rem' , color : 'var(--text2)' } } >
Noch keine Abschnitte in diesem Plan . Unter < Link to = "/planning" > Planung < / Link > bearbeiten .
< / p >
) : (
< div style = { { display : 'flex' , flexDirection : 'column' , gap : '1.25rem' } } >
{ sections . map ( ( sec , si ) => {
const secOrder = sec . order _index ? ? si
const items = sortedItems ( sec )
return (
< section key = { sec . id ? ? ` sec- ${ si } ` } className = "card training-run-section" style = { { padding : '1.15rem 1.25rem' } } >
< h2 style = { { fontSize : '1.05rem' , marginBottom : '0.65rem' , color : 'var(--accent-dark)' } } >
{ sec . title || ` Abschnitt ${ si + 1 } ` }
< / h2 >
{ sec . guidance _notes && (
< p style = { { fontSize : '0.88rem' , color : 'var(--text2)' , marginBottom : '0.85rem' , whiteSpace : 'pre-wrap' } } >
{ sec . guidance _notes }
< / p >
) }
< ul style = { { listStyle : 'none' , padding : 0 , margin : 0 , display : 'flex' , flexDirection : 'column' , gap : '0.65rem' } } >
{ items . map ( ( it , ii ) => {
const ck = itemStableKey ( it , secOrder , ii )
const done = checked . has ( ck )
if ( it . item _type === 'note' ) {
return (
< li key = { ck } className = { ` training-run-item training-run-item--note ${ done ? ' training-run-item--done' : '' } ` } >
< label
style = { {
display : 'flex' ,
gap : '0.75rem' ,
alignItems : 'flex-start' ,
cursor : 'pointer' ,
fontSize : '0.92rem'
} }
>
< input type = "checkbox" className = "training-run-checkbox" checked = { done } onChange = { ( ) => toggle ( ck ) } style = { { marginTop : '4px' , width : '20px' , height : '20px' } } / >
< span style = { { whiteSpace : 'pre-wrap' , color : 'var(--text2)' } } >
< em style = { { fontStyle : 'normal' , color : 'var(--text3)' , fontSize : '0.8rem' } } > Notiz < / em >
< br / >
{ it . note _body || '' }
< / span >
< / label >
< / li >
)
}
const title =
it . exercise _title ||
( it . exercise _id ? ` Übung # ${ it . exercise _id } ` : 'Übung' )
const variant = it . exercise _variant _name ? ` ( ${ it . exercise _variant _name } ) ` : ''
const plan = formatMin ( it . planned _duration _min )
const extras = [ ]
if ( it . exercise _focus _area ) extras . push ( it . exercise _focus _area )
const metaParts = [ ... extras , plan ] . filter ( Boolean )
return (
< li key = { ck } className = { ` training-run-item training-run-item--exercise ${ done ? ' training-run-item--done' : '' } ` } >
< label
style = { {
display : 'flex' ,
gap : '0.75rem' ,
alignItems : 'flex-start' ,
cursor : 'pointer'
} }
>
< input type = "checkbox" className = "training-run-checkbox" checked = { done } onChange = { ( ) => toggle ( ck ) } style = { { marginTop : '6px' , width : '22px' , height : '22px' , flexShrink : 0 } } / >
< span style = { { flex : 1 , minWidth : 0 } } >
< span style = { { fontSize : '1.02rem' , fontWeight : 600 } } >
{ title }
{ variant }
< / span >
{ metaParts . length > 0 && (
< div style = { { fontSize : '0.85rem' , color : 'var(--text3)' , marginTop : '3px' , lineHeight : 1.35 } } >
{ metaParts . join ( ' · ' ) }
< / div >
) }
{ ( it . notes || it . modifications ) && (
< div style = { { marginTop : '0.35rem' , fontSize : '0.88rem' , color : 'var(--text2)' , whiteSpace : 'pre-wrap' } } >
{ it . notes && (
< >
< strong style = { { fontWeight : 600 } } > Coach : < / strong > { it . notes }
< / >
) }
{ it . modifications && (
< >
{ it . notes ? < br / > : null }
< strong style = { { fontWeight : 600 } } > Anpassung : < / strong > { it . modifications }
< / >
) }
< / div >
) }
{ it . exercise _id && (
2026-04-29 06:49:16 +02:00
< div className = "no-print" style = { { display : 'flex' , flexWrap : 'wrap' , gap : '10px' , marginTop : '0.55rem' , alignItems : 'center' } } >
< button
type = "button"
className = "btn btn-secondary"
style = { { fontSize : '0.82rem' , margin : 0 } }
onClick = { ( ) => setPeekExerciseId ( it . exercise _id ) }
>
Katalog ( Popup )
< / button >
< Link
to = { ` /exercises/ ${ it . exercise _id } ` }
style = { { fontSize : '0.82rem' , color : 'var(--accent)' } }
>
Vollständige Seite öffnen
2026-04-29 06:37:52 +02:00
< / Link >
< / div >
) }
< / span >
< / label >
< / li >
)
} ) }
< / ul >
< / section >
)
} ) }
< / div >
) }
{ unit . trainer _notes && (
< div className = "card training-run-trainer-note no-print" style = { { marginTop : '1.25rem' , padding : '1rem' , borderLeft : ` 4px solid var(--accent) ` } } >
< div style = { { fontSize : '0.75rem' , fontWeight : 700 , color : 'var(--text3)' , marginBottom : '0.35rem' , textTransform : 'uppercase' } } >
Nur Trainer
< / div >
< p style = { { fontSize : '0.92rem' , whiteSpace : 'pre-wrap' } } > { unit . trainer _notes } < / p >
< / div >
) }
< footer className = "no-print training-run-footer" style = { { marginTop : '1.75rem' , textAlign : 'center' , fontSize : '0.82rem' , color : 'var(--text3)' } } >
Haken werden nur auf diesem Gerät gespeichert ( Session — Tab schließen kann sie löschen ) .
< / footer >
< / div >
)
}