feat(training-unit-editor): integrate new summary function and enhance combination exercise display
All checks were successful
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / playwright-tests (push) Successful in 56s

- Introduced `summarizeSlotProfileBrief` utility for concise slot profile summaries, improving the display of combination exercises.
- Updated `CombinationCoachSlots` and `ExercisePeekModal` components to utilize the new summary function for better user experience.
- Enhanced `TrainingUnitSectionsEditor` to manage combination slots more effectively, including improved title handling and display options.
- Adjusted `TrainingPlanningPage` to support additional peek context for combination exercises, streamlining the planning process.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-05-13 14:11:53 +02:00
parent 805ad3c5a5
commit 5dc93d9a8c
6 changed files with 298 additions and 59 deletions

View File

@ -10,47 +10,7 @@ import {
combinationArchetypeLabel,
sortCombinationSlotsForDisplay,
} from '../constants/combinationArchetypes'
import { readSlotProfilesV1 } from '../utils/combinationMethodProfileUi'
function summarizeSlotProfilesRow(r) {
if (!r) return null
const adv = r.advance_mode || 'timed'
const bits = []
if (adv === 'timed') {
bits.push('Zeit')
if (r.load_sec != null) bits.push(`${r.load_sec}s Arbeit`)
} else if (adv === 'rep') {
bits.push('ZielWdh.')
const nSer = r.rep_series_count != null && r.rep_series_count >= 1 ? r.rep_series_count : 1
if (r.consecutive_reps != null) {
if (nSer >= 2) bits.push(`${nSer} Serien à ${r.consecutive_reps}×`)
else bits.push(`${r.consecutive_reps}×`)
}
} else {
bits.push('Coach')
const nSerMan = r.rep_series_count != null && r.rep_series_count >= 2 ? r.rep_series_count : 1
if (r.consecutive_reps != null) {
if (nSerMan >= 2) bits.push(`${nSerMan} Serien à Richtwert ${r.consecutive_reps}×`)
else bits.push(`Richtwert ${r.consecutive_reps}×`)
} else if (r.rep_series_count != null && r.rep_series_count >= 2) {
bits.push(`${r.rep_series_count} Serien`)
}
}
if (r.intra_rep_rest_sec != null) {
if (adv === 'timed') bits.push(`Pause ${r.intra_rep_rest_sec}s`)
else if (adv === 'rep' && r.rep_series_count != null && r.rep_series_count >= 2)
bits.push(`Pause zw. Serien ${r.intra_rep_rest_sec}s`)
else if (
adv === 'manual' &&
r.rep_series_count != null &&
r.rep_series_count >= 2
) {
bits.push(`Pause zw. Serien ${r.intra_rep_rest_sec}s`)
}
}
if (r.transition_after_sec != null) bits.push(`Wechsel ${r.transition_after_sec}s`)
return bits.join(' · ')
}
import { readSlotProfilesV1, summarizeSlotProfileBrief } from '../utils/combinationMethodProfileUi'
export default function CombinationCoachSlots({
combinationSlots,
@ -233,7 +193,7 @@ export default function CombinationCoachSlots({
`Station ${slot.slot_index != null ? Number(slot.slot_index) + 1 : si + 1}`
const ix = slot.slot_index != null ? Number(slot.slot_index) : si
const timingSummary = summarizeSlotProfilesRow(slotTimingByIx.get(ix))
const timingSummary = summarizeSlotProfileBrief(slotTimingByIx.get(ix))
return (
<li key={`${slot.slot_index ?? si}-${slotTitle}`} style={{ lineHeight: 1.45 }}>

View File

@ -1,10 +1,13 @@
/**
* Schnellansicht einer Übung aus dem Katalog (ohne die Planungsseite zu verlassen).
*/
import React, { useEffect, useState } from 'react'
import React, { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
import CombinationCoachSlots from './CombinationCoachSlots'
import { combinationArchetypeLabel } from '../constants/combinationArchetypes'
import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
function TagMini({ exercise }) {
const parts = []
@ -29,6 +32,8 @@ export default function ExercisePeekModal({
variantId,
onClose,
titleFallback,
/** Nur Planung: effektives method_profile aus Zeilen-Katalog + Planungs-Override */
peekExtras,
}) {
const [loading, setLoading] = useState(false)
const [err, setErr] = useState(null)
@ -39,6 +44,22 @@ export default function ExercisePeekModal({
? exercise.variants.find((v) => String(v.id) === String(variantId)) || null
: null
const isCombination =
exercise &&
String(exercise.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
const comboMethodProfileEffective = useMemo(() => {
if (!exercise || !isCombination) return {}
const fromPeek =
peekExtras?.catalog_method_profile &&
typeof peekExtras.catalog_method_profile === 'object' &&
!Array.isArray(peekExtras.catalog_method_profile) &&
Object.keys(peekExtras.catalog_method_profile).length > 0
? peekExtras.catalog_method_profile
: exercise.method_profile || {}
return effectiveComboMethodProfile(fromPeek, peekExtras?.planning_method_profile ?? null)
}, [exercise, isCombination, peekExtras])
useEffect(() => {
if (!open) {
setExercise(null)
@ -69,6 +90,8 @@ export default function ExercisePeekModal({
if (!open) return null
const sheetWide = Boolean(isCombination && exercise && !loading)
return (
<div className="admin-modal-backdrop" role="presentation" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div
@ -77,7 +100,7 @@ export default function ExercisePeekModal({
aria-modal="true"
aria-labelledby="exercise-peek-title"
style={{
maxWidth: '620px',
maxWidth: sheetWide ? 'min(760px, 96vw)' : '620px',
width: '100%',
maxHeight: '88vh',
display: 'flex',
@ -103,6 +126,49 @@ export default function ExercisePeekModal({
{!loading && err && <p style={{ color: 'var(--danger)' }}>{err}</p>}
{!loading && exercise && (
<>
{isCombination ? (
<>
<div
style={{
marginBottom: '12px',
padding: '8px 10px',
borderRadius: '8px',
background: 'var(--surface2)',
border: '1px solid var(--border)',
fontSize: '0.82rem',
color: 'var(--text2)',
lineHeight: 1.45,
}}
>
<strong style={{ color: 'var(--text1)' }}>Kombination</strong>
<span style={{ marginLeft: 8 }}>
{(() => {
const ak = String(exercise.method_archetype || '').trim()
const lbl = ak ? combinationArchetypeLabel(ak) : null
return lbl || ak || 'Archetyp nicht gesetzt'
})()}
</span>
{peekExtras?.planning_method_profile != null &&
typeof peekExtras.planning_method_profile === 'object' &&
!Array.isArray(peekExtras.planning_method_profile) ? (
<span style={{ marginLeft: 8, color: 'var(--accent-dark)', fontWeight: 600 }}>
· Planung angepasst
</span>
) : null}
</div>
<CombinationCoachSlots
combinationSlots={
Array.isArray(exercise.combination_slots) ? exercise.combination_slots : []
}
methodArchetype={exercise.method_archetype || ''}
methodProfile={comboMethodProfileEffective}
compactPlanningView
omitGlobalKeyValueBlock={false}
/>
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '1rem 0' }} />
</>
) : null}
{variant ? (
<div
style={{

View File

@ -3,7 +3,7 @@ import { GripVertical, Pencil } from 'lucide-react'
import CombinationMethodProfileEditor from './CombinationMethodProfileEditor'
import CombinationCoachSlots from './CombinationCoachSlots'
import { comboPlanningProfileJsonForEditor, effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
import { combinationArchetypeLabel } from '../constants/combinationArchetypes'
import { combinationArchetypeLabel, sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
import {
comboSlotsOutlineForProfileEditor,
defaultSection,
@ -12,6 +12,7 @@ import {
sectionPlannedMinutes,
} from '../utils/trainingUnitSectionsForm'
import api from '../utils/api'
import { readSlotProfilesV1, summarizeSlotProfileBrief } from '../utils/combinationMethodProfileUi'
import { isCompactTagLegendMode } from '../config/planningModuleUx'
import { useAuth } from '../context/AuthContext'
@ -77,6 +78,59 @@ function compactComboPlanningCaption(it) {
return archLbl ? `${archLbl} · wie Katalog` : 'wie im Katalog'
}
/** Globale Eckdaten aus effective profile (optional unter Stationenliste). */
function comboRoughGlobalTimingHint(profileObj, archetypeKey) {
if (!profileObj || typeof profileObj !== 'object' || Array.isArray(profileObj)) return null
const bits = []
const rounds = profileObj.rounds
const ws = profileObj.work_seconds
const rb = profileObj.rest_between_rounds_sec
const hint = profileObj.hint_step_duration_sec
const globRest = profileObj.rest_between_sets_sec
if (rounds != null && rounds !== '') bits.push(`${rounds} Runden`)
if (ws != null && ws !== '') bits.push(`${ws}s Arbeit`)
if (rb != null && rb !== '') bits.push(`Pause ${rb}s`)
if (globRest != null && globRest !== '') bits.push(`Sets-Pause ${globRest}s`)
if (hint != null && hint !== '') bits.push(`Orientierung ~${hint}s`)
const arch = (archetypeKey || '').trim()
if (arch === 'time_domain_interval') {
const iw = profileObj.interval_work_sec
const ir = profileObj.interval_rest_sec
const ig = profileObj.interval_groups
if (iw != null && iw !== '') bits.push(`${iw}s Intervall`)
if (ir != null && ir !== '') bits.push(`${ir}s Erholung`)
if (ig != null && ig !== '') bits.push(`${ig} Gruppen`)
}
return bits.length ? bits.join(' · ') : null
}
/** Pro Station eine kompakte Textzeile für die Planungsliste. */
function comboPlanningStripBulletTexts(it) {
const slots = sortCombinationSlotsForDisplay(it.combination_slots || [])
if (!slots.length) return []
const mp = effectiveComboMethodProfile(it.catalog_method_profile || {}, it.planning_method_profile)
const byIx = new Map(readSlotProfilesV1(mp).map((r) => [Number(r.slot_index), r]))
const titles = it.combo_member_title_by_id || {}
return slots.map((slot, idx) => {
const siRaw = slot.slot_index
const siParsed =
siRaw === '' || siRaw == null ? idx : typeof siRaw === 'number' ? siRaw : parseInt(String(siRaw), 10)
const ix = Number.isFinite(siParsed) ? siParsed : idx
const stationLbl = ((slot.title || '').trim() || `Station ${ix}`)
const candIds = (slot.candidate_exercise_ids || [])
.map((raw) => (typeof raw === 'number' ? raw : parseInt(String(raw), 10)))
.filter((n) => Number.isFinite(n))
const namesJoined =
candIds.length === 0
? '(keine Übung)'
: candIds.map((id) => titles[String(id)] || `Übung ${id}`).join(' ↔ ')
const timing = summarizeSlotProfileBrief(byIx.get(ix))
let line = `${stationLbl}: ${namesJoined}`
if (timing) line += ` · ${timing}`
return line
})
}
/** Stabile Farbzurodnung aus Modul-ID (nur Darstellung). */
function planningModulePalette(moduleId) {
const id = normalizedPlanningModuleChainId(moduleId)
@ -964,6 +1018,24 @@ export default function TrainingUnitSectionsEditor({
? undefined
: Number(it.exercise_variant_id)
const stripArchRaw =
isCombination && it.exercise_id ? String(it.catalog_method_archetype || '').trim() : ''
const stripArchLbl =
stripArchRaw && isCombination ? combinationArchetypeLabel(stripArchRaw) : null
const stripBullets =
isCombination && it.exercise_id ? comboPlanningStripBulletTexts(it) : []
const stripMpEff =
isCombination && it.exercise_id
? effectiveComboMethodProfile(
it.catalog_method_profile || {},
it.planning_method_profile,
)
: null
const stripGlobalRough =
isCombination && it.exercise_id && stripMpEff
? comboRoughGlobalTimingHint(stripMpEff, stripArchRaw)
: null
return (
<Fragment key={`${insertSlotKeyPrefix}sec-${sIdx}-blk-${iIdx}`}>
{!planningCompactLegend &&
@ -1047,7 +1119,16 @@ export default function TrainingUnitSectionsEditor({
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() =>
onPeekExercise(Number(it.exercise_id), variantIdPeek)
onPeekExercise(
Number(it.exercise_id),
variantIdPeek,
isCombination
? {
catalog_method_profile: it.catalog_method_profile,
planning_method_profile: it.planning_method_profile,
}
: undefined,
)
}
>
Vorschau
@ -1142,29 +1223,73 @@ export default function TrainingUnitSectionsEditor({
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: '8px 10px',
padding: '6px 12px 8px',
alignItems: 'flex-start',
gap: '10px',
padding: '8px 12px 10px',
paddingLeft: enableItemDragReorder ? 44 : 12,
borderTop: '1px solid var(--border)',
background: 'var(--surface2)',
}}
>
<span
<div
style={{
flex: '1 1 200px',
minWidth: 0,
fontSize: '0.78rem',
color: 'var(--text2)',
flex: '1 1 160px',
minWidth: 0,
lineHeight: 1.45,
}}
title="Ablaufprofil für diese Kombi in diesem Termin — Editor öffnen zum Anpassen"
title="Stationen und grobe Zeiten aus Katalog bzw. Planungs-Anpassung — Details unter „Ablauf bearbeiten“ oder „Vorschau“"
>
<strong style={{ color: 'var(--text1)', fontWeight: 600 }}>Ablauf:&nbsp;</strong>
<span>{compactComboPlanningCaption(it)}</span>
</span>
<div style={{ marginBottom: stripBullets.length || stripGlobalRough ? 6 : 0 }}>
<strong style={{ color: 'var(--text1)', fontWeight: 600 }}>Archetyp:&nbsp;</strong>
<span style={{ color: 'var(--text1)' }}>
{stripArchLbl || stripArchRaw || '—'}
{stripArchRaw && stripArchLbl && stripArchLbl !== stripArchRaw ? (
<span style={{ marginLeft: 6, fontWeight: 400, color: 'var(--text3)', fontSize: '0.72rem' }}>
({stripArchRaw})
</span>
) : null}
</span>
<span style={{ marginLeft: 10, fontWeight: 500 }}>{compactComboPlanningCaption(it)}</span>
</div>
{stripGlobalRough ? (
<div
style={{
marginBottom: stripBullets.length ? 6 : 0,
fontSize: '0.74rem',
color: 'var(--text3)',
}}
>
<strong style={{ color: 'var(--text2)', fontWeight: 600 }}>Block:&nbsp;</strong>
{stripGlobalRough}
</div>
) : null}
{stripBullets.length > 0 ? (
<ul
style={{
margin: 0,
paddingLeft: '1.05rem',
fontSize: '0.74rem',
color: 'var(--text2)',
}}
>
{stripBullets.map((line, bi) => (
<li key={`combo-strip-${sIdx}-${iIdx}-${bi}`} style={{ marginBottom: 2 }}>
{line}
</li>
))}
</ul>
) : (
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', fontStyle: 'italic' }}>
Stationen laden oder noch keine Kombi-Stationen im Katalog
</div>
)}
</div>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
style={{ flexShrink: 0 }}
aria-haspopup="dialog"
aria-label="Ablaufprofil Kombination für diese Planung bearbeiten"
onClick={() => setComboPlanningModal({ sIdx, iIdx })}

View File

@ -2892,8 +2892,12 @@ function TrainingPlanningPage() {
})
setExercisePickerOpen(true)
}}
onPeekExercise={(id, variantId) =>
setPlanningPeekCtx({ exerciseId: id, variantId: variantId ?? null })
onPeekExercise={(id, variantId, peekExtras) =>
setPlanningPeekCtx({
exerciseId: id,
variantId: variantId ?? null,
peekExtras: peekExtras ?? null,
})
}
showExecutionExtras={Boolean(editingUnit) && sectionsEditMode === 'debrief'}
/>
@ -3078,6 +3082,7 @@ function TrainingPlanningPage() {
open={planningPeekCtx != null}
exerciseId={planningPeekCtx?.exerciseId}
variantId={planningPeekCtx?.variantId ?? undefined}
peekExtras={planningPeekCtx?.peekExtras ?? undefined}
onClose={() => setPlanningPeekCtx(null)}
/>
</div>

View File

@ -242,6 +242,47 @@ export function readSlotProfilesV1(profileObj) {
}).filter(Boolean)
}
/** Kurztext für Listen/strip (Coach „Plan:“ — gleiche Logik). */
export function summarizeSlotProfileBrief(r) {
if (!r) return null
const adv = r.advance_mode || 'timed'
const bits = []
if (adv === 'timed') {
bits.push('Zeit')
if (r.load_sec != null) bits.push(`${r.load_sec}s Arbeit`)
} else if (adv === 'rep') {
bits.push('ZielWdh.')
const nSer = r.rep_series_count != null && r.rep_series_count >= 1 ? r.rep_series_count : 1
if (r.consecutive_reps != null) {
if (nSer >= 2) bits.push(`${nSer} Serien à ${r.consecutive_reps}×`)
else bits.push(`${r.consecutive_reps}×`)
}
} else {
bits.push('Coach')
const nSerMan = r.rep_series_count != null && r.rep_series_count >= 2 ? r.rep_series_count : 1
if (r.consecutive_reps != null) {
if (nSerMan >= 2) bits.push(`${nSerMan} Serien à Richtwert ${r.consecutive_reps}×`)
else bits.push(`Richtwert ${r.consecutive_reps}×`)
} else if (r.rep_series_count != null && r.rep_series_count >= 2) {
bits.push(`${r.rep_series_count} Serien`)
}
}
if (r.intra_rep_rest_sec != null) {
if (adv === 'timed') bits.push(`Pause ${r.intra_rep_rest_sec}s`)
else if (adv === 'rep' && r.rep_series_count != null && r.rep_series_count >= 2)
bits.push(`Pause zw. Serien ${r.intra_rep_rest_sec}s`)
else if (
adv === 'manual' &&
r.rep_series_count != null &&
r.rep_series_count >= 2
) {
bits.push(`Pause zw. Serien ${r.intra_rep_rest_sec}s`)
}
}
if (r.transition_after_sec != null) bits.push(`Wechsel ${r.transition_after_sec}s`)
return bits.join(' · ')
}
function normalizeOptionalNonNegInt(v) {
if (v === '' || v === undefined || v === null) return undefined
const n = typeof v === 'number' ? v : parseInt(String(v), 10)

View File

@ -276,6 +276,48 @@ export async function enrichSectionsWithVariants(sections) {
}
})
)
const titleById = new Map()
for (const id of unique) {
const row = cache.get(id)
const t = (row?.title || '').trim()
if (t) titleById.set(Number(id), t)
}
const comboCandidateExtra = new Set()
for (const id of unique) {
const row = cache.get(id)
if (String(row?.exercise_kind || '').toLowerCase().trim() !== 'combination') continue
for (const slot of row.combination_slots || []) {
for (const raw of slot.candidate_exercise_ids || []) {
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
if (Number.isFinite(n) && !titleById.has(n)) comboCandidateExtra.add(n)
}
}
}
await Promise.all(
[...comboCandidateExtra].map(async (cid) => {
try {
const ex = await api.getExercise(cid)
titleById.set(cid, ((ex.title || '').trim() || `Übung #${cid}`))
} catch {
titleById.set(cid, `Übung #${cid}`)
}
}),
)
function comboMemberTitleByIdForSlots(slots) {
const o = {}
for (const slot of slots || []) {
for (const raw of slot.candidate_exercise_ids || []) {
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
if (!Number.isFinite(n)) continue
const key = String(n)
if (!o[key]) o[key] = titleById.get(n) || `Übung #${n}`
}
}
return o
}
return sections.map((sec) => ({
...sec,
items: (sec.items || []).map((it) => {
@ -306,7 +348,7 @@ export async function enrichSectionsWithVariants(sections) {
exercise_club_id: c.club_id,
exercise_created_by: c.created_by,
exercise_status: c.status,
...(isCombo ? { combination_slots: c.combination_slots || [] } : {}),
...(isCombo ? { combination_slots: c.combination_slots || [], combo_member_title_by_id: comboMemberTitleByIdForSlots(c.combination_slots || []) } : {}),
}
}),
}))