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
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:
parent
d50bed428b
commit
00edc7a93d
|
|
@ -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>
|
Katalog‑Ablauf 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">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user