feat(exercise-detail): enhance combination exercise display with candidate links and bracket visualization
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 12s
Test Suite / playwright-tests (push) Successful in 59s

- Introduced `flattenCombinationCandidateLinks` function to streamline the extraction of unique candidate exercises for combination details.
- Updated the `ExerciseDetailPage` to conditionally render a `CombinationPlanBracket` for combination exercises, improving the visual representation of training runs.
- Enhanced the UI to display linked individual exercises associated with combination slots, providing clearer navigation and context for users.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-05-13 21:41:32 +02:00
parent d50bed428b
commit 00edc7a93d

View File

@ -3,6 +3,7 @@ import { Link, useNavigate, useParams, useLocation } from 'react-router-dom'
import api from '../utils/api'
import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock'
import ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaStrip'
import CombinationPlanBracket from '../components/CombinationPlanBracket'
import { formatSkillLevelSlug } from '../constants/skillLevels'
import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
@ -51,6 +52,28 @@ function metaParts(exercise) {
return parts
}
/** Eindeutige Kandidaten-Übungen für Schnellnavigation unter der Klammerdarstellung */
function flattenCombinationCandidateLinks(slots) {
const rows = []
const seen = new Set()
sortCombinationSlotsForDisplay(slots || []).forEach((s) => {
const cands =
s.candidates && s.candidates.length
? s.candidates
: (s.candidate_exercise_ids || []).map((id) => ({
exercise_id: id,
title: null,
}))
cands.forEach((c) => {
const eid = c.exercise_id
if (eid == null || seen.has(eid)) return
seen.add(eid)
rows.push({ exercise_id: eid, title: (c.title || '').trim() || null })
})
})
return rows
}
function ExerciseDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
@ -108,6 +131,20 @@ function ExerciseDetailPage() {
const meta = metaParts(exercise)
const fromExerciseEdit = location.state?.fromExerciseEdit === true
const isCombinationDetail =
(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' &&
Array.isArray(exercise.combination_slots) &&
exercise.combination_slots.length > 0
const combinationCandidateLinks = isCombinationDetail
? flattenCombinationCandidateLinks(exercise.combination_slots)
: []
const catalogMethodProfileForBracket =
exercise.method_profile &&
typeof exercise.method_profile === 'object' &&
!Array.isArray(exercise.method_profile)
? exercise.method_profile
: {}
return (
<div className="exercise-detail-shell" style={{ padding: '12px 12px 24px' }}>
<div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
@ -137,39 +174,34 @@ function ExerciseDetailPage() {
{meta.length > 0 && <p className="exercise-meta-line">{meta.join(' · ')}</p>}
</div>
{(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' &&
Array.isArray(exercise.combination_slots) &&
exercise.combination_slots.length > 0 && (
{isCombinationDetail ? (
<section className="card exercise-detail-section">
<h2>Stationen und Übungspools</h2>
{exercise.method_archetype ? (
<p style={{ fontSize: '14px', color: 'var(--text2)', marginTop: 0 }}>
Archetyp: <code>{String(exercise.method_archetype)}</code>
<h2>Ablauf und Stationen</h2>
<p style={{ marginTop: 0, fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45 }}>
KatalogAblauf mit Archetyp, Zeiten und Stationen dieselbe Darstellung wie in der Planung und Vorschau.
</p>
) : null}
<ol style={{ paddingLeft: '1.25rem', marginBottom: 0 }}>
{sortCombinationSlotsForDisplay(exercise.combination_slots).map((s, idx) => (
<li key={`${s.slot_index}-${idx}-${(s.title || '').slice(0, 8)}`} style={{ marginBottom: '10px' }}>
<strong>{(s.title || '').trim() || `Station ${idx + 1}`}</strong>
<ul style={{ margin: '4px 0 0', paddingLeft: '1.2rem' }}>
{(s.candidates && s.candidates.length
? s.candidates
: (s.candidate_exercise_ids || []).map((id) => ({
exercise_id: id,
title: null,
}))
).map((c) => (
<div className="training-run-combo-embed">
<CombinationPlanBracket
methodArchetype={String(exercise.method_archetype || '').trim()}
methodProfile={catalogMethodProfileForBracket}
combinationSlots={exercise.combination_slots}
planningAdjusted={false}
/>
</div>
{combinationCandidateLinks.length > 0 ? (
<div style={{ marginTop: '14px', fontSize: '0.88rem' }}>
<div style={{ fontWeight: 600, marginBottom: '6px', color: 'var(--text2)' }}>Verknüpfte Einzelübungen</div>
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
{combinationCandidateLinks.map((c) => (
<li key={c.exercise_id}>
<Link to={`/exercises/${c.exercise_id}`}>Übung #{c.exercise_id}</Link>
{c.title ? `${c.title}` : ''}
<Link to={`/exercises/${c.exercise_id}`}>{c.title || `Übung #${c.exercise_id}`}</Link>
</li>
))}
</ul>
</li>
))}
</ol>
</div>
) : null}
</section>
)}
) : null}
{exercise.goal && (
<section className="card exercise-detail-section">