feat(combo-planning): enhance candidate interaction and UI for combination exercises
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 57s

- Introduced new CSS styles for interactive candidate buttons and links in the `CombinationPlanBracket` and `CombinationCoachSlots` components, improving user engagement.
- Updated `CombinationPlanBracket` to conditionally render candidates as buttons or links based on interaction type, enhancing navigation options.
- Refactored candidate handling in `CombinationCoachSlots` to support new interaction methods, streamlining candidate exercise display.
- Enhanced `ExercisePeekModal` and related components to support candidate peek functionality, allowing for a more seamless user experience.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-05-13 21:51:52 +02:00
parent 00edc7a93d
commit 502dddd3b3
10 changed files with 270 additions and 109 deletions

View File

@ -6398,6 +6398,68 @@ a.analysis-split__nav-item {
color: var(--text3);
margin-right: 6px;
}
.combo-plan-bracket__station-exercises--interactive {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 6px;
}
.combo-plan-bracket__cand-inline {
display: inline-flex;
align-items: baseline;
gap: 4px;
}
.combo-plan-bracket__cand-sep {
color: var(--text3);
font-size: 0.78rem;
user-select: none;
}
.combo-plan-bracket__cand-btn {
margin: 0;
padding: 2px 8px;
font: inherit;
font-size: 0.84rem;
font-weight: 600;
color: var(--accent-dark);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
text-align: left;
line-height: 1.35;
}
.combo-plan-bracket__cand-btn:hover {
border-color: var(--accent);
background: var(--surface2);
}
.combo-plan-bracket__cand-link {
font-size: 0.84rem;
font-weight: 600;
color: var(--accent-dark);
text-decoration: underline;
text-underline-offset: 2px;
}
.combo-plan-bracket__cand-link:hover {
color: var(--accent);
}
button.combo-coach-cand-link {
margin: 0;
padding: 0;
border: none;
background: none;
font: inherit;
font-size: 0.84rem;
font-weight: 600;
color: var(--accent);
text-decoration: underline;
cursor: pointer;
text-align: left;
}
button.combo-coach-cand-link:hover {
color: var(--accent-dark);
}
.training-run-combo-embed {
margin-top: 0.65rem;
}

View File

@ -35,15 +35,26 @@ export default function CombinationCoachSlots({
methodProfile,
compactPlanningView = false,
omitGlobalKeyValueBlock = false,
/** Wenn gesetzt: Kandidaten als Button → Peek (kein Router-Wechsel, PWA-sicher) */
onOpenCandidatePeek,
}) {
const slots = useMemo(() => sortCombinationSlotsForDisplay(combinationSlots), [combinationSlots])
const candidateIds = useMemo(() => {
const set = new Set()
for (const s of slots) {
for (const id of s.candidate_exercise_ids || []) {
const n = typeof id === 'number' ? id : parseInt(String(id), 10)
if (Number.isFinite(n)) set.add(n)
if (Array.isArray(s.candidates) && s.candidates.length) {
for (const c of s.candidates) {
const raw = c.exercise_id
if (raw == null) continue
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
if (Number.isFinite(n)) set.add(n)
}
} else {
for (const id of s.candidate_exercise_ids || []) {
const n = typeof id === 'number' ? id : parseInt(String(id), 10)
if (Number.isFinite(n)) set.add(n)
}
}
}
return [...set]
@ -282,9 +293,19 @@ export default function CombinationCoachSlots({
<>
<p style={{ margin: '0 0 4px', fontSize: '0.96rem', fontWeight: 700 }}>{ex.title}</p>
<p style={{ margin: 0 }}>
<Link to={`/exercises/${cid}`} style={{ fontSize: '0.82rem', color: 'var(--accent)' }}>
Im Katalog öffnen
</Link>
{typeof onOpenCandidatePeek === 'function' ? (
<button
type="button"
className="combo-coach-cand-link"
onClick={() => onOpenCandidatePeek(cid)}
>
Details anzeigen
</button>
) : (
<Link to={`/exercises/${cid}`} style={{ fontSize: '0.82rem', color: 'var(--accent)' }}>
Im Katalog öffnen
</Link>
)}
</p>
</>
) : (
@ -320,9 +341,19 @@ export default function CombinationCoachSlots({
</details>
) : null}
<p style={{ marginTop: '8px', marginBottom: 0 }}>
<Link to={`/exercises/${cid}`} style={{ fontSize: '0.84rem', color: 'var(--accent)' }}>
Volle Übungsseite
</Link>
{typeof onOpenCandidatePeek === 'function' ? (
<button
type="button"
className="combo-coach-cand-link"
onClick={() => onOpenCandidatePeek(cid)}
>
Volle Übungsansicht
</button>
) : (
<Link to={`/exercises/${cid}`} style={{ fontSize: '0.84rem', color: 'var(--accent)' }}>
Volle Übungsseite
</Link>
)}
</p>
</>
)

View File

@ -2,6 +2,7 @@
* Kombination: konsolidierte Darstellung globales Profil + Stationen mit Zeiten (Vorschau, Plan-Ansicht, Druck).
*/
import React, { useMemo } from 'react'
import { Link } from 'react-router-dom'
import {
archetypeCoachHint,
combinationArchetypeLabel,
@ -14,24 +15,22 @@ import {
stationPrimaryLoadLabel,
} from '../utils/combinationMethodProfileUi'
function candidateLine(slot) {
const cands = slot.candidates
if (Array.isArray(cands) && cands.length > 0) {
return cands
.map((c) =>
((c.title || '').trim() || (c.exercise_id != null ? `Übung #${c.exercise_id}` : '')).trim(),
)
.filter(Boolean)
.join(' ↔ ')
/** @returns {{ exerciseId: number, label: string }[]} */
export function normalizeCombinationSlotCandidates(slot) {
const out = []
const cands =
slot.candidates && slot.candidates.length
? slot.candidates
: (slot.candidate_exercise_ids || []).map((id) => ({ exercise_id: id, title: null }))
for (const c of cands) {
const rawId = c.exercise_id
if (rawId == null) continue
const n = typeof rawId === 'number' ? rawId : parseInt(String(rawId), 10)
if (!Number.isFinite(n)) continue
const label = ((c.title || '').trim() || `Übung #${n}`).trim()
out.push({ exerciseId: n, label })
}
const ids = slot.candidate_exercise_ids || []
return ids
.map((raw) => {
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
return Number.isFinite(n) ? `Übung #${n}` : ''
})
.filter(Boolean)
.join(' ↔ ')
return out
}
export default function CombinationPlanBracket({
@ -39,6 +38,9 @@ export default function CombinationPlanBracket({
methodProfile,
combinationSlots,
planningAdjusted = false,
/** 'none' | 'link' (Router) | 'button' (z. B. ExercisePeekModal / PWA-sicher) */
candidateInteraction = 'none',
onCandidatePeek,
}) {
const arch = typeof methodArchetype === 'string' ? methodArchetype.trim() : ''
const archLabel = arch ? combinationArchetypeLabel(arch) : null
@ -97,7 +99,8 @@ export default function CombinationPlanBracket({
const stationIx = Number.isFinite(ixParsed) ? ixParsed : si
const displayStep = si + 1
const stationTitle = ((slot.title || '').trim() || `Station ${displayStep}`).trim()
const names = candidateLine(slot)
const candRows = normalizeCombinationSlotCandidates(slot)
const names = candRows.length ? candRows.map((r) => r.label).join(' ↔ ') : ''
const slotProfRow = timingByIx.get(stationIx)
const loadBadge = stationPrimaryLoadLabel(slotProfRow)
const timing = effectiveStationTimingSummary(arch, methodProfile || {}, slotProfRow)
@ -112,7 +115,35 @@ export default function CombinationPlanBracket({
</div>
<div className="combo-plan-bracket__station-main">
<div className="combo-plan-bracket__station-title">{stationTitle}</div>
<div className="combo-plan-bracket__station-exercises">{names || '(keine Einzelübung)'}</div>
{candidateInteraction === 'button' && typeof onCandidatePeek === 'function' && candRows.length > 0 ? (
<div className="combo-plan-bracket__station-exercises combo-plan-bracket__station-exercises--interactive">
{candRows.map((c, ci) => (
<span key={`${c.exerciseId}-${ci}`} className="combo-plan-bracket__cand-inline">
{ci > 0 ? <span className="combo-plan-bracket__cand-sep"></span> : null}
<button
type="button"
className="combo-plan-bracket__cand-btn"
onClick={() => onCandidatePeek(c.exerciseId, c.label)}
>
{c.label}
</button>
</span>
))}
</div>
) : candidateInteraction === 'link' && candRows.length > 0 ? (
<div className="combo-plan-bracket__station-exercises combo-plan-bracket__station-exercises--interactive">
{candRows.map((c, ci) => (
<span key={`${c.exerciseId}-${ci}`} className="combo-plan-bracket__cand-inline">
{ci > 0 ? <span className="combo-plan-bracket__cand-sep"></span> : null}
<Link to={`/exercises/${c.exerciseId}`} className="combo-plan-bracket__cand-link">
{c.label}
</Link>
</span>
))}
</div>
) : (
<div className="combo-plan-bracket__station-exercises">{names || '(keine Einzelübung)'}</div>
)}
{timing ? (
<div className="combo-plan-bracket__station-timing">
<span className="combo-plan-bracket__timing-label">Zeit / Steuerung</span>

View File

@ -54,9 +54,18 @@ function metaParts(exercise) {
}
/**
* @param {{ exercise?: object|null, loading?: boolean, error?: string|null, exerciseId?: number, variantId?: number|string|null, planningComboMethodProfile?: object|null, catalogMethodProfileSnapshot?: object|null }} props
* @param {{ exercise?: object|null, loading?: boolean, error?: string|null, exerciseId?: number, variantId?: number|string|null, planningComboMethodProfile?: object|null, catalogMethodProfileSnapshot?: object|null, onCandidateExercisePeek?: (exerciseId: number) => void }} props
*/
export default function ExerciseFullContent({ exercise, loading, error, exerciseId, variantId, planningComboMethodProfile, catalogMethodProfileSnapshot }) {
export default function ExerciseFullContent({
exercise,
loading,
error,
exerciseId,
variantId,
planningComboMethodProfile,
catalogMethodProfileSnapshot,
onCandidateExercisePeek,
}) {
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '1rem' }}>
@ -129,6 +138,7 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
combinationSlots={exercise.combination_slots}
methodArchetype={exercise.method_archetype}
methodProfile={coachComboProfile}
onOpenCandidatePeek={onCandidateExercisePeek}
/>
) : null}
<h2 style={{ margin: '0 0 8px', fontSize: '1.2rem', lineHeight: 1.35 }}>{exercise.title}</h2>

View File

@ -1,7 +1,8 @@
/**
* Schnellansicht einer Übung aus dem Katalog (ohne die Planungsseite zu verlassen).
* Unterstützt Drill-down zu Kandidaten-Übungen bei Kombinationen inkl. Zurück (PWA-sicher).
*/
import React, { useEffect, useMemo, useState } from 'react'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
@ -25,6 +26,8 @@ function TagMini({ exercise }) {
)
}
/** @typedef {{ exerciseId: number, variantId?: number | null, peekExtras?: object | null }} PeekStackEntry */
export default function ExercisePeekModal({
open,
exerciseId,
@ -37,36 +40,37 @@ export default function ExercisePeekModal({
const [loading, setLoading] = useState(false)
const [err, setErr] = useState(null)
const [exercise, setExercise] = useState(null)
/** @type {[PeekStackEntry[], React.Dispatch<React.SetStateAction<PeekStackEntry[]>>]} */
const [stack, setStack] = useState([])
const variant =
variantId != null && variantId !== '' && exercise?.variants?.length
? 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])
/** @type {React.MutableRefObject<boolean>} */
const wasOpenRef = useRef(false)
useEffect(() => {
if (!open) {
setExercise(null)
setErr(null)
setStack([])
wasOpenRef.current = false
return
}
if (!exerciseId) {
setErr('Keine Übung gewählt')
if (exerciseId == null || exerciseId === '') return
if (!wasOpenRef.current) {
wasOpenRef.current = true
setStack([
{
exerciseId: Number(exerciseId),
variantId: variantId ?? null,
peekExtras: peekExtras ?? null,
},
])
}
}, [open, exerciseId, variantId, peekExtras])
const top = stack.length ? stack[stack.length - 1] : null
useEffect(() => {
if (!open || !top?.exerciseId) {
setExercise(null)
setErr(null)
return
}
let cancelled = false
@ -74,7 +78,7 @@ export default function ExercisePeekModal({
setLoading(true)
setErr(null)
try {
const data = await api.getExercise(exerciseId)
const data = await api.getExercise(top.exerciseId)
if (!cancelled) setExercise(data)
} catch (e) {
if (!cancelled) setErr(e.message || 'Laden fehlgeschlagen')
@ -85,7 +89,40 @@ export default function ExercisePeekModal({
return () => {
cancelled = true
}
}, [open, exerciseId, variantId])
}, [open, top?.exerciseId])
const variant =
top?.variantId != null &&
top.variantId !== '' &&
exercise?.variants?.length
? exercise.variants.find((v) => String(v.id) === String(top.variantId)) || null
: null
const isCombination =
exercise && String(exercise.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
const comboMethodProfileEffective = useMemo(() => {
if (!exercise || !isCombination) return {}
const fromPeek =
top?.peekExtras?.catalog_method_profile &&
typeof top.peekExtras.catalog_method_profile === 'object' &&
!Array.isArray(top.peekExtras.catalog_method_profile) &&
Object.keys(top.peekExtras.catalog_method_profile).length > 0
? top.peekExtras.catalog_method_profile
: exercise.method_profile || {}
return effectiveComboMethodProfile(fromPeek, top?.peekExtras?.planning_method_profile ?? null)
}, [exercise, isCombination, top?.peekExtras])
const planningAdjustedBadge =
top?.peekExtras?.planning_method_profile != null &&
typeof top.peekExtras.planning_method_profile === 'object' &&
!Array.isArray(top.peekExtras.planning_method_profile)
const pushCandidatePeek = (id) => {
const n = Number(id)
if (!Number.isFinite(n)) return
setStack((s) => [...s, { exerciseId: n, variantId: null, peekExtras: null }])
}
if (!open) return null
@ -107,9 +144,19 @@ export default function ExercisePeekModal({
}}
onClick={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
<h3 id="exercise-peek-title" className="admin-modal-sheet__title">
{loading ? '…' : exercise?.title || titleFallback || `Übung #${exerciseId}`}
<div className="admin-modal-sheet__header" style={{ gap: '8px', flexWrap: 'wrap' }}>
{stack.length > 1 ? (
<button
type="button"
className="btn btn-secondary"
style={{ flexShrink: 0, fontWeight: 600 }}
onClick={() => setStack((s) => (s.length > 1 ? s.slice(0, -1) : s))}
>
Zurück
</button>
) : null}
<h3 id="exercise-peek-title" className="admin-modal-sheet__title" style={{ flex: '1 1 200px', minWidth: 0 }}>
{loading ? '…' : exercise?.title || titleFallback || (top?.exerciseId != null ? `Übung #${top.exerciseId}` : 'Übung')}
</h3>
<button type="button" className="btn btn-secondary admin-modal-sheet__close" onClick={onClose}>
Schließen
@ -130,14 +177,10 @@ export default function ExercisePeekModal({
<CombinationPlanBracket
methodArchetype={exercise.method_archetype || ''}
methodProfile={comboMethodProfileEffective}
combinationSlots={
Array.isArray(exercise.combination_slots) ? exercise.combination_slots : []
}
planningAdjusted={
peekExtras?.planning_method_profile != null &&
typeof peekExtras.planning_method_profile === 'object' &&
!Array.isArray(peekExtras.planning_method_profile)
}
combinationSlots={Array.isArray(exercise.combination_slots) ? exercise.combination_slots : []}
planningAdjusted={planningAdjustedBadge}
candidateInteraction="button"
onCandidatePeek={pushCandidatePeek}
/>
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '1rem 0' }} />
</>
@ -210,13 +253,17 @@ export default function ExercisePeekModal({
</>
)}
</div>
{exerciseId && (
{top?.exerciseId != null ? (
<div style={{ padding: '0 1rem 1rem', flexShrink: 0 }}>
<Link to={`/exercises/${exerciseId}`} className="btn btn-secondary" style={{ width: '100%', textDecoration: 'none', textAlign: 'center', display: 'block' }}>
<Link
to={`/exercises/${top.exerciseId}`}
className="btn btn-secondary"
style={{ width: '100%', textDecoration: 'none', textAlign: 'center', display: 'block' }}
>
Vollständige Übungsseite öffnen
</Link>
</div>
)}
) : null}
</div>
</div>
)

View File

@ -1596,6 +1596,12 @@ export default function TrainingUnitSectionsEditor({
typeof comboPlanningModalItem.planning_method_profile === 'object' &&
!Array.isArray(comboPlanningModalItem.planning_method_profile)
}
candidateInteraction={onPeekExercise ? 'button' : 'none'}
onCandidatePeek={
onPeekExercise
? (exId) => onPeekExercise(Number(exId), null, undefined)
: undefined
}
/>
</div>
) : (

View File

@ -5,7 +5,6 @@ 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'
function TagRow({ exercise }) {
const tags = []
@ -52,28 +51,6 @@ 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()
@ -135,9 +112,6 @@ function ExerciseDetailPage() {
(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' &&
@ -186,20 +160,9 @@ function ExerciseDetailPage() {
methodProfile={catalogMethodProfileForBracket}
combinationSlots={exercise.combination_slots}
planningAdjusted={false}
candidateInteraction="link"
/>
</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}`}>{c.title || `Übung #${c.exercise_id}`}</Link>
</li>
))}
</ul>
</div>
) : null}
</section>
) : null}

View File

@ -5,6 +5,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../utils/api'
import ExerciseFullContent from '../components/ExerciseFullContent'
import ExercisePeekModal from '../components/ExercisePeekModal'
import {
flattenPlanTimeline,
itemStableKey,
@ -178,6 +179,7 @@ export default function TrainingCoachPage() {
const [trainerAppend, setTrainerAppend] = useState('')
const [saveMarkDone, setSaveMarkDone] = useState(true)
const [saving, setSaving] = useState(false)
const [candidatePeekId, setCandidatePeekId] = useState(null)
const [saveOk, setSaveOk] = useState(null)
const reloadUnit = useCallback(async () => {
@ -460,6 +462,12 @@ export default function TrainingCoachPage() {
return (
<div className="training-coach-page training-coach-layout">
<ExercisePeekModal
key={candidatePeekId != null ? String(candidatePeekId) : 'coach-peek-closed'}
open={candidatePeekId != null}
exerciseId={candidatePeekId}
onClose={() => setCandidatePeekId(null)}
/>
<nav
className="no-print training-coach-meta-nav"
style={{
@ -750,6 +758,7 @@ export default function TrainingCoachPage() {
? currentEntry?.item?.planning_method_profile ?? null
: null
}
onCandidateExercisePeek={(id) => setCandidatePeekId(id)}
/>
</div>
</>

View File

@ -1223,6 +1223,7 @@ export default function TrainingFrameworkProgramEditPage() {
/>
<ExercisePeekModal
key={peekCtx != null ? String(peekCtx.exerciseId) : 'fw-peek-closed'}
open={peekCtx != null}
exerciseId={peekCtx?.exerciseId || 0}
variantId={peekCtx?.variantId ?? undefined}

View File

@ -3087,6 +3087,7 @@ function TrainingPlanningPage() {
}}
/>
<ExercisePeekModal
key={planningPeekCtx != null ? String(planningPeekCtx.exerciseId) : 'plan-peek-closed'}
open={planningPeekCtx != null}
exerciseId={planningPeekCtx?.exerciseId}
variantId={planningPeekCtx?.variantId ?? undefined}