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 api from '../utils/api'
import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock' import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock'
import ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaStrip' import ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaStrip'
import CombinationPlanBracket from '../components/CombinationPlanBracket'
import { formatSkillLevelSlug } from '../constants/skillLevels' import { formatSkillLevelSlug } from '../constants/skillLevels'
import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes' import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
@ -51,6 +52,28 @@ function metaParts(exercise) {
return parts 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() { function ExerciseDetailPage() {
const { id } = useParams() const { id } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
@ -108,6 +131,20 @@ function ExerciseDetailPage() {
const meta = metaParts(exercise) const meta = metaParts(exercise)
const fromExerciseEdit = location.state?.fromExerciseEdit === true 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 ( return (
<div className="exercise-detail-shell" style={{ padding: '12px 12px 24px' }}> <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' }}> <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>} {meta.length > 0 && <p className="exercise-meta-line">{meta.join(' · ')}</p>}
</div> </div>
{(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' && {isCombinationDetail ? (
Array.isArray(exercise.combination_slots) && <section className="card exercise-detail-section">
exercise.combination_slots.length > 0 && ( <h2>Ablauf und Stationen</h2>
<section className="card exercise-detail-section"> <p style={{ marginTop: 0, fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45 }}>
<h2>Stationen und Übungspools</h2> KatalogAblauf mit Archetyp, Zeiten und Stationen dieselbe Darstellung wie in der Planung und Vorschau.
{exercise.method_archetype ? ( </p>
<p style={{ fontSize: '14px', color: 'var(--text2)', marginTop: 0 }}> <div className="training-run-combo-embed">
Archetyp: <code>{String(exercise.method_archetype)}</code> <CombinationPlanBracket
</p> methodArchetype={String(exercise.method_archetype || '').trim()}
) : null} methodProfile={catalogMethodProfileForBracket}
<ol style={{ paddingLeft: '1.25rem', marginBottom: 0 }}> combinationSlots={exercise.combination_slots}
{sortCombinationSlotsForDisplay(exercise.combination_slots).map((s, idx) => ( planningAdjusted={false}
<li key={`${s.slot_index}-${idx}-${(s.title || '').slice(0, 8)}`} style={{ marginBottom: '10px' }}> />
<strong>{(s.title || '').trim() || `Station ${idx + 1}`}</strong> </div>
<ul style={{ margin: '4px 0 0', paddingLeft: '1.2rem' }}> {combinationCandidateLinks.length > 0 ? (
{(s.candidates && s.candidates.length <div style={{ marginTop: '14px', fontSize: '0.88rem' }}>
? s.candidates <div style={{ fontWeight: 600, marginBottom: '6px', color: 'var(--text2)' }}>Verknüpfte Einzelübungen</div>
: (s.candidate_exercise_ids || []).map((id) => ({ <ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
exercise_id: id, {combinationCandidateLinks.map((c) => (
title: null, <li key={c.exercise_id}>
})) <Link to={`/exercises/${c.exercise_id}`}>{c.title || `Übung #${c.exercise_id}`}</Link>
).map((c) => ( </li>
<li key={c.exercise_id}> ))}
<Link to={`/exercises/${c.exercise_id}`}>Übung #{c.exercise_id}</Link> </ul>
{c.title ? `${c.title}` : ''} </div>
</li> ) : null}
))} </section>
</ul> ) : null}
</li>
))}
</ol>
</section>
)}
{exercise.goal && ( {exercise.goal && (
<section className="card exercise-detail-section"> <section className="card exercise-detail-section">