Enhance TrainingCoachPage and TrainingUnitRunPage with improved context display and print functionality
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
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

- Updated TrainingCoachPage to include coach context in timeline entries and section titles, enhancing clarity for users.
- Refactored TrainingUnitRunPage to support printing options for parallel streams, allowing for better organization during printouts.
- Introduced new CSS styles for page breaks and layout adjustments in app.css, improving print formatting for training plans.
- Enhanced utility functions in trainingPlanUtils.js to support new phase and stream management features, streamlining data handling.
This commit is contained in:
Lars 2026-05-15 12:21:08 +02:00
parent 3005f1cb3e
commit 5338871f36
4 changed files with 657 additions and 174 deletions

View File

@ -6658,6 +6658,21 @@ button.combo-coach-cand-link:hover {
max-width: none !important;
padding: 0 !important;
}
/* Split: jede weitere Breakout-Spalte auf neuer Seite (Gesamtplan-Druck) */
.training-run-breakout-stream--page-break {
break-before: page;
page-break-before: always;
}
.training-run-parallel-columns {
display: block !important;
}
.training-run-breakout-stream {
margin-bottom: 14px;
}
.training-run-phase-schedule {
break-inside: avoid;
page-break-inside: avoid;
}
.training-run-section,
.training-run-header {
break-inside: avoid;

View File

@ -1,6 +1,6 @@
/**
* Coach-Modus: eine Position nach der anderen mit Assistentenhinweisen, Zeitnahme und optionaler Nachbereitung.
* Parallele Streams: Schritte aus flattenPlanTimeline (linear); Stream-/Phasenwahl später einzubinden.
* Timeline: flach in Phasen-/Stream-Reihenfolge (flattenPlanTimeline).
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
@ -525,7 +525,7 @@ export default function TrainingCoachPage() {
<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 ctx = ent.coachContext || ''
const active = coachDebriefPhase ? ix === timeline.length - 1 : ix === step
return (
<button
@ -543,7 +543,11 @@ export default function TrainingCoachPage() {
setStep(ix)
}}
>
<span style={{ opacity: 0.75 }}>{secTitle.substring(0, 14)} </span>
{ctx ? (
<span style={{ opacity: 0.8, display: 'block', fontSize: '0.72rem', marginBottom: '2px' }}>
{ctx.length > 42 ? `${ctx.slice(0, 40)}` : ctx}
</span>
) : null}
<span>{lbl}</span>
</button>
)
@ -573,7 +577,10 @@ export default function TrainingCoachPage() {
const val = deltas[k]?.actual_duration_min ?? ent.item.actual_duration_min ?? ''
return (
<label key={`db-${k}`} style={{ display: 'grid', gridTemplateColumns: '1fr 88px', gap: '10px', alignItems: 'center', fontSize: '0.88rem' }}>
<span style={{ wordBreak: 'break-word' }}>{summarizeTimelineEntry(ent)}</span>
<span style={{ wordBreak: 'break-word' }}>
{ent.coachContext ? <span style={{ color: 'var(--text3)' }}>{ent.coachContext} · </span> : null}
{summarizeTimelineEntry(ent)}
</span>
<input
type="number"
min="0"
@ -692,7 +699,8 @@ export default function TrainingCoachPage() {
{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}
{currentEntry.coachContext || 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>
@ -701,7 +709,8 @@ export default function TrainingCoachPage() {
<>
<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}
In diesem Training · {currentEntry.coachContext || currentEntry?.sec.title || 'Abschnitt'} · Teil{' '}
{step + 1}
</div>
{currentEntry?.item && (
<>
@ -795,7 +804,10 @@ export default function TrainingCoachPage() {
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>
<span style={{ wordBreak: 'break-word' }}>
{ent.coachContext ? <span style={{ color: 'var(--text3)' }}>{ent.coachContext} · </span> : null}
{summarizeTimelineEntry(ent)}
</span>
<input
type="number"
min="0"

View File

@ -1,13 +1,18 @@
/**
* Trainingsablauf anzeigen, drucken und lokal auf der Matte abhaken (Fortschritt im Browser gespeichert).
* Parallele Streams: rendering nutzt sortedSections/sortedItems (trainingPlanUtils); Fortschritt pro unitId später ggf. pro Stream.
* 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 { itemStableKey, sortedSections, sortedItems } from '../utils/trainingPlanUtils'
import {
buildPlanRunViewModel,
itemStableKey,
sortedSections,
sortedItems,
} from '../utils/trainingPlanUtils'
import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
function storageKey(unitId) {
@ -37,6 +42,8 @@ export default function TrainingUnitRunPage() {
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 {
@ -109,19 +116,244 @@ export default function TrainingUnitRunPage() {
}, [idNum, persistChecked])
const sections = useMemo(() => sortedSections(unit), [unit])
const planModel = useMemo(() => buildPlanRunViewModel(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
}
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 t
}, [sections])
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 (
@ -153,21 +385,66 @@ export default function TrainingUnitRunPage() {
onClose={() => setPeekCtx(null)}
/>
<nav className="training-run-toolbar no-print" style={{ marginBottom: '1rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<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' }}
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>
<button type="button" className="btn btn-secondary" title="Alle Häkchen auf dieser Matte zurücksetzen" onClick={() => confirm('Fortschritt wirklich zurücksetzen?') && clearProgress()}>
{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>
@ -201,10 +478,81 @@ export default function TrainingUnitRunPage() {
</span>
{totalPlannedMin > 0 && (
<span>
<strong>Geplante Zeit (Übungen):</strong> ca. {totalPlannedMin} Min.
<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"
@ -213,7 +561,7 @@ export default function TrainingUnitRunPage() {
padding: '0.65rem 0.85rem',
background: 'var(--accent-light)',
borderRadius: '8px',
fontSize: '0.92rem'
fontSize: '0.92rem',
}}
>
<strong>Hinweis Teilnehmer:</strong> {unit.notes}
@ -226,171 +574,154 @@ export default function TrainingUnitRunPage() {
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') {
<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 (
<li key={ck} className={`training-run-item training-run-item--note${done ? ' training-run-item--done' : ''}`}>
<label
<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={{
display: 'flex',
gap: '0.75rem',
alignItems: 'flex-start',
cursor: 'pointer',
fontSize: '0.92rem'
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%)',
}}
>
<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 || ''}
{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>
</label>
</li>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{st.sections.map((sec) => renderSectionCard(sec, siUnit(sec)))}
</div>
</div>
)
}
})}
</div>
</div>
)
}
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
if (!showWholeGroupInView) return 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" 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>
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' }}>
<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)' }}>
<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>

View File

@ -2,7 +2,12 @@
* Hilfen für Trainingsplan-Ansicht, Coach-Modus und API-Payload für PUT /training-units/:id.
*/
import { cloneJsonSerializablePlanningProfile } from './trainingUnitSectionsForm'
import {
cloneJsonSerializablePlanningProfile,
phaseRunsFromSections,
sectionIndicesForParallelStream,
streamsForParallelPhaseOrders,
} from './trainingUnitSectionsForm'
export function sortedSections(unit) {
const raw = unit?.sections
if (!Array.isArray(raw)) return []
@ -20,11 +25,123 @@ export function itemStableKey(it, secOrder, ix) {
return `${secOrder}-${it?.item_type || 'row'}-${ix}`
}
/** Flache Reihenfolge wie auf der Matte: alle Notizen und Übungen nacheinander.
* Parallele Streams: aktuell strikt linear (sortedSections × sortedItems); für Breakout phase/streamaware erweitern. */
export function sumExerciseMinutesInSection(sec) {
let t = 0
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
}
/**
* Lesemodell für Plan & Ablauf / Druck / Coach: Phasenläufe mit Ganzgruppe vs. Split.
* Legacy ohne planLoc: ein Block.
*/
export function buildPlanRunViewModel(unit) {
const sections = sortedSections(unit)
if (!sections.length) {
return { mode: 'empty', runs: [], totalMin: 0, runCumulativeEnds: [] }
}
const runsMeta = phaseRunsFromSections(sections)
const runs = []
let cum = 0
const runCumulativeEnds = []
if (runsMeta.length === 0) {
const minutes = sections.reduce((s, sec) => s + sumExerciseMinutesInSection(sec), 0)
cum = minutes
runCumulativeEnds.push(cum)
runs.push({
kind: 'legacy',
phaseOrderIndex: 0,
phaseTitle: null,
minutes,
sections,
streams: null,
globalOrderSections: sections,
})
return { mode: 'legacy', runs, totalMin: minutes, runCumulativeEnds }
}
for (const r of runsMeta) {
const slice = sections.slice(r.start, r.end)
if (r.phaseKind === 'whole_group') {
const minutes = slice.reduce((s, sec) => s + sumExerciseMinutesInSection(sec), 0)
cum += minutes
runCumulativeEnds.push(cum)
const phaseTitle = slice[0]?.planLoc?.phaseTitle ?? null
runs.push({
kind: 'whole_group',
phaseOrderIndex: r.phaseOrderIndex,
phaseTitle,
minutes,
sections: slice,
streams: null,
globalOrderSections: slice,
})
} else {
const po = r.phaseOrderIndex
const streamOrders = streamsForParallelPhaseOrders(slice, po)
const streams = streamOrders.map((so) => {
const idxs = sectionIndicesForParallelStream(slice, po, so)
const streamSecs = idxs.map((i) => slice[i])
const first = streamSecs[0]
const streamTitle = first?.planLoc?.streamTitle ?? null
const minutes = streamSecs.reduce((s, sec) => s + sumExerciseMinutesInSection(sec), 0)
return {
streamOrder: so,
streamTitle,
minutes,
sections: streamSecs,
printStreamId: `p${po}-s${so}`,
}
})
const minutes = slice.reduce((s, sec) => s + sumExerciseMinutesInSection(sec), 0)
cum += minutes
runCumulativeEnds.push(cum)
const phaseTitle = slice[0]?.planLoc?.phaseTitle ?? null
runs.push({
kind: 'parallel',
phaseOrderIndex: po,
phaseTitle,
minutes,
streams,
sections: null,
globalOrderSections: slice,
})
}
}
return { mode: 'phased', runs, totalMin: cum, runCumulativeEnds }
}
function coachContextLabelForSection(sec, sectionsList) {
const pl = sec?.planLoc
if (!pl?.phaseKind) return 'Ablauf'
if (pl.phaseKind === 'whole_group') {
const pt = pl.phaseTitle != null && String(pl.phaseTitle).trim() ? String(pl.phaseTitle).trim() : null
return pt ? `Ganzgruppe · ${pt}` : 'Ganzgruppe'
}
const pt = pl.phaseTitle != null && String(pl.phaseTitle).trim() ? String(pl.phaseTitle).trim() : `Phase ${pl.phaseOrderIndex ?? 0}`
const so = pl.parallelStreamOrderIndex ?? 0
const st = pl.streamTitle != null && String(pl.streamTitle).trim() ? String(pl.streamTitle).trim() : `Gruppe ${so + 1}`
return `Parallel · ${pt} · ${st}`
}
/** Flache Reihenfolge für Coach-Timeline (global wie im Editor, inkl. gemischter Split-Abschnitte). */
export function flattenPlanTimeline(unit) {
const model = buildPlanRunViewModel(unit)
if (model.mode === 'empty') return []
const sections = sortedSections(unit)
const list = []
sortedSections(unit).forEach((sec, si) => {
const pushSectionItems = (sec, coachCtx) => {
const si = Math.max(0, sections.indexOf(sec))
const secOrder = sec.order_index ?? si
sortedItems(sec).forEach((item, ii) => {
list.push({
@ -34,9 +151,17 @@ export function flattenPlanTimeline(unit) {
flatIndex: list.length,
sec,
item,
coachContext: coachCtx,
})
})
})
}
for (const run of model.runs) {
for (const sec of run.globalOrderSections) {
pushSectionItems(sec, coachContextLabelForSection(sec, sections))
}
}
return list
}