shinkan-jinkendo/frontend/src/pages/TrainingUnitRunPage.jsx
Lars 352237bbb9
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m8s
Refactor TrainingCoachPage and TrainingUnitRunPage to enhance coach branching functionality
- Updated TrainingCoachPage to implement branching logic for coach steps, allowing for improved navigation through training phases.
- Enhanced session storage handling to manage branch picks and streamline state management during training sessions.
- Modified TrainingUnitRunPage to update links for coaching views, reflecting the new branching structure and improving user experience.
- Introduced new utility functions in trainingPlanUtils for managing coach branch picks and timeline navigation, optimizing data handling across components.
2026-05-15 16:31:54 +02:00

751 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Trainingsablauf anzeigen, drucken und lokal auf der Matte abhaken (Fortschritt im Browser gespeichert).
* Phasen: Ganzgruppe vs. Split (planLoc); Druck mit optional getrennten Breakout-Seiten.
*/
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 CombinationPlanBracket from '../components/CombinationPlanBracket'
import {
buildPlanRunViewModelFromSections,
itemStableKey,
sectionsWithPlanLocForDisplay,
sortedItems,
} from '../utils/trainingPlanUtils'
import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
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())
const [peekCtx, setPeekCtx] = useState(null)
/** null | printStreamId z. B. p0-s1 — nur dieser Split-Stream in @media print */
const [printOnlyStreamId, setPrintOnlyStreamId] = useState(null)
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(() => sectionsWithPlanLocForDisplay(unit), [unit])
const planModel = useMemo(() => buildPlanRunViewModelFromSections(sections), [sections])
const printStreamOptions = useMemo(() => {
const opts = []
for (const run of planModel.runs) {
if (run.kind !== 'parallel' || !run.streams) continue
for (const st of run.streams) {
opts.push({
id: st.printStreamId,
label: `${run.phaseTitle ? String(run.phaseTitle).trim().slice(0, 28) : `Phase ${run.phaseOrderIndex}`} · ${st.streamTitle ? String(st.streamTitle).trim() : `Gruppe ${st.streamOrder + 1}`}`,
})
}
}
return opts
}, [planModel.runs])
const totalPlannedMin = planModel.totalMin
const showWholeGroupInView = !printOnlyStreamId
const showStreamColumn = (streamPrintId) => !printOnlyStreamId || streamPrintId === printOnlyStreamId
const renderSectionCard = (sec, siInUnit) => {
const secOrder = sec.order_index ?? siInUnit
const items = sortedItems(sec)
return (
<section
key={sec.id ?? `sec-${secOrder}-${siInUnit}`}
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 ${siInUnit + 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 training-run-checkbox--printable"
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 exKind = String(it.exercise_kind || 'simple').toLowerCase().trim()
const isComboRow = exKind === 'combination'
const metaParts = [...extras, isComboRow ? 'Kombination' : null, plan].filter(Boolean)
const comboEffectiveProfile = isComboRow
? effectiveComboMethodProfile(
it.catalog_method_profile || {},
it.planning_method_profile ?? null
)
: null
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 training-run-checkbox--printable"
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>
)}
{isComboRow && it.exercise_id ? (
<div className="training-run-combo-embed">
<CombinationPlanBracket
methodArchetype={String(it.catalog_method_archetype || '').trim()}
methodProfile={comboEffectiveProfile || {}}
combinationSlots={
Array.isArray(it.combination_slots) ? it.combination_slots : []
}
planningAdjusted={
it.planning_method_profile != null &&
typeof it.planning_method_profile === 'object' &&
!Array.isArray(it.planning_method_profile)
}
/>
</div>
) : null}
{it.exercise_id && (
<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={() =>
setPeekCtx({
exerciseId: it.exercise_id,
variantId:
it.exercise_variant_id != null
? Number(it.exercise_variant_id)
: null,
peekExtras: isComboRow
? {
catalog_method_profile: it.catalog_method_profile,
planning_method_profile: it.planning_method_profile,
}
: undefined,
})
}
>
Katalog (Popup)
</button>
<Link
to={`/exercises/${it.exercise_id}`}
style={{ fontSize: '0.82rem', color: 'var(--accent)' }}
>
Vollständige Seite öffnen
</Link>
</div>
)}
</span>
</label>
</li>
)
})}
</ul>
</section>
)
}
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 (
<div className="training-run-page app-page" style={{ paddingBottom: '2rem' }}>
<ExercisePeekModal
open={peekCtx != null}
exerciseId={peekCtx?.exerciseId}
variantId={peekCtx?.variantId ?? undefined}
peekExtras={peekCtx?.peekExtras ?? undefined}
onClose={() => setPeekCtx(null)}
/>
<nav
className="training-run-toolbar no-print"
style={{
marginBottom: '1rem',
display: 'flex',
gap: '0.5rem',
flexWrap: 'wrap',
alignItems: 'center',
}}
>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/planning')}>
Zur Planung
</button>
<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()}>
Drucken / PDF
</button>
{printStreamOptions.length > 0 ? (
<label
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
fontSize: '0.86rem',
color: 'var(--text2)',
}}
>
Druck / Vorschau
<select
className="form-input"
style={{ marginBottom: 0, minWidth: '200px', fontSize: '0.86rem' }}
value={printOnlyStreamId || ''}
onChange={(e) => setPrintOnlyStreamId(e.target.value || null)}
>
<option value="">Gesamter Plan (empfohlen)</option>
{printStreamOptions.map((o) => (
<option key={o.id} value={o.id}>
Nur: {o.label}
</option>
))}
</select>
</label>
) : null}
<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, gesamt):</strong> ca. {totalPlannedMin} Min.
</span>
)}
</div>
{printOnlyStreamId ? (
<div
className="no-print"
style={{
marginTop: '0.75rem',
padding: '0.5rem 0.75rem',
background: 'hsl(200 40% 94%)',
borderRadius: '8px',
fontSize: '0.82rem',
color: 'hsl(200 25% 28%)',
}}
>
Vorschau wie fürs Drucken: nur die gewählte Split-Gruppe. Ganzgruppen-Blöcke sind ausgeblendet.
</div>
) : null}
{planModel.mode === 'phased' && planModel.runs.length > 0 && showWholeGroupInView && (
<div
className="training-run-phase-schedule training-run-notes-print"
style={{
marginTop: '1rem',
padding: '0.75rem 0.9rem',
background: 'var(--surface2)',
borderRadius: '8px',
fontSize: '0.86rem',
overflowX: 'auto',
}}
>
<strong style={{ display: 'block', marginBottom: '0.5rem', color: 'var(--text1)' }}>
Zeitplanung (Summe Übungsminuten)
</strong>
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: '280px' }}>
<thead>
<tr style={{ textAlign: 'left', color: 'var(--text3)', fontSize: '0.78rem' }}>
<th style={{ padding: '4px 8px 4px 0', fontWeight: 600 }}>Block</th>
<th style={{ padding: '4px 8px', fontWeight: 600 }}>Art</th>
<th style={{ padding: '4px 8px', fontWeight: 600 }}> Min</th>
<th style={{ padding: '4px 0 4px 8px', fontWeight: 600 }}> bis hier</th>
</tr>
</thead>
<tbody>
{planModel.runs.map((run, ri) => {
const cum = planModel.runCumulativeEnds[ri]
const label =
run.phaseTitle != null && String(run.phaseTitle).trim()
? String(run.phaseTitle).trim()
: run.kind === 'legacy'
? 'Ablauf'
: run.kind === 'whole_group'
? `Ganzgruppe (Phase ${run.phaseOrderIndex})`
: `Parallel (Phase ${run.phaseOrderIndex})`
return (
<tr key={`sched-${ri}-${run.kind}`} style={{ borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '6px 8px 6px 0', verticalAlign: 'top' }}>{label}</td>
<td style={{ padding: '6px 8px', color: 'var(--text2)', whiteSpace: 'nowrap' }}>
{run.kind === 'parallel' ? 'Split' : run.kind === 'legacy' ? '—' : 'Ganzgruppe'}
</td>
<td style={{ padding: '6px 8px' }}>{run.minutes || '—'}</td>
<td style={{ padding: '6px 0 6px 8px', fontWeight: 600 }}>{cum}</td>
</tr>
)
})}
</tbody>
</table>
{unit.planned_time_start && (
<p style={{ margin: '0.55rem 0 0', fontSize: '0.78rem', color: 'var(--text3)', lineHeight: 1.4 }}>
Hinweis: Startzeit oben im Kopf; Minuten sind aus den Übungseinplanungen ohne Pausen und ohne
automatische Uhrzeitliste.
</p>
)}
</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 className="training-run-body" style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
{planModel.runs.map((run, runIdx) => {
if (run.kind === 'parallel') {
const visibleStreams = run.streams.filter((st) => showStreamColumn(st.printStreamId))
if (!visibleStreams.length) return null
return (
<div
key={`run-p-${run.phaseOrderIndex}-${runIdx}`}
className="training-run-phase training-run-phase--parallel"
>
<div
className="card training-run-phase-banner"
style={{
padding: '0.85rem 1rem',
background: 'linear-gradient(135deg, hsl(200 35% 94%), var(--surface2))',
border: '1px solid hsl(200 40% 80%)',
borderRadius: '10px',
}}
>
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text3)', letterSpacing: '0.04em' }}>
PARALLELE PHASE
</div>
<div style={{ fontSize: '1.02rem', fontWeight: 700, color: 'var(--accent-dark)', marginTop: '4px' }}>
{run.phaseTitle ? String(run.phaseTitle).trim() : `Phase ${run.phaseOrderIndex}`}
</div>
<div style={{ fontSize: '0.82rem', color: 'var(--text2)', marginTop: '4px' }}>
{printOnlyStreamId
? `Auszug eine Gruppe · ca. ${visibleStreams[0]?.minutes ?? run.minutes} Min. (Üb.)`
: `Geplante Übungszeit (gesamt): ca. ${run.minutes} Min. · Jede Spalte kann separat gedruckt werden (Dropdown oder Seitenumbruch).`}
</div>
</div>
<div
className="training-run-parallel-columns"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
gap: '1rem',
alignItems: 'start',
}}
>
{visibleStreams.map((st, streamIdx) => {
const siUnit = (sec) => Math.max(0, sections.indexOf(sec))
return (
<div
key={st.printStreamId}
className={
'training-run-breakout-stream' +
(!printOnlyStreamId && streamIdx > 0
? ' training-run-breakout-stream--page-break'
: '')
}
data-print-id={st.printStreamId}
>
<div
className="training-run-stream-ribbon"
style={{
marginBottom: '10px',
padding: '8px 12px',
borderRadius: '8px',
background: 'hsl(200 38% 92%)',
border: '1px dashed hsl(200 42% 58%)',
fontSize: '0.88rem',
fontWeight: 700,
color: 'hsl(200 30% 28%)',
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
}}
>
<span>
{st.streamTitle ? String(st.streamTitle).trim() : `Gruppe ${st.streamOrder + 1}`}
<span style={{ fontWeight: 500, marginLeft: '8px', opacity: 0.85 }}>
· ca. {st.minutes} Min. (Üb.)
</span>
</span>
<Link
className="no-print"
to={`/planning/run/${unitId}/coach?atBranch=${run.phaseOrderIndex}&preferSo=${st.streamOrder}`}
style={{
fontSize: '0.78rem',
fontWeight: 600,
color: 'var(--accent-dark)',
textDecoration: 'underline',
textUnderlineOffset: '2px',
whiteSpace: 'nowrap',
}}
>
Coach · Split-Punkt (Vorschlag)
</Link>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{st.sections.map((sec) => renderSectionCard(sec, siUnit(sec)))}
</div>
</div>
)
})}
</div>
</div>
)
}
if (!showWholeGroupInView) return null
return (
<div
key={`run-wg-${run.phaseOrderIndex}-${runIdx}`}
className="training-run-phase training-run-phase--whole training-run-wg-block"
>
{run.kind !== 'legacy' ? (
<div
className="card training-run-phase-banner"
style={{
padding: '0.85rem 1rem',
marginBottom: '0.25rem',
background: 'color-mix(in srgb, var(--accent) 12%, var(--surface2))',
border: '1px solid color-mix(in srgb, var(--accent) 28%, var(--border))',
borderRadius: '10px',
}}
>
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text3)', letterSpacing: '0.04em' }}>
GANZGRUPPE
</div>
<div style={{ fontSize: '1.02rem', fontWeight: 700, color: 'var(--accent-dark)', marginTop: '4px' }}>
{run.phaseTitle ? String(run.phaseTitle).trim() : `Phase ${run.phaseOrderIndex}`}
</div>
<div style={{ fontSize: '0.82rem', color: 'var(--text2)', marginTop: '4px' }}>
Geplante Übungszeit: ca. {run.minutes} Min.
</div>
</div>
) : null}
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
{run.sections.map((sec) => renderSectionCard(sec, Math.max(0, sections.indexOf(sec))))}
</div>
</div>
)
})}
</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>
)
}