From 502dddd3b3b5fe7329fea046443dcffb21952746 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 13 May 2026 21:51:52 +0200 Subject: [PATCH] feat(combo-planning): enhance candidate interaction and UI for combination exercises - 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 --- frontend/src/app.css | 62 +++++++++ .../src/components/CombinationCoachSlots.jsx | 49 +++++-- .../src/components/CombinationPlanBracket.jsx | 69 +++++++--- .../src/components/ExerciseFullContent.jsx | 14 +- frontend/src/components/ExercisePeekModal.jsx | 129 ++++++++++++------ .../components/TrainingUnitSectionsEditor.jsx | 6 + frontend/src/pages/ExerciseDetailPage.jsx | 39 +----- frontend/src/pages/TrainingCoachPage.jsx | 9 ++ .../TrainingFrameworkProgramEditPage.jsx | 1 + frontend/src/pages/TrainingPlanningPage.jsx | 1 + 10 files changed, 270 insertions(+), 109 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index b094299..2503d53 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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; } diff --git a/frontend/src/components/CombinationCoachSlots.jsx b/frontend/src/components/CombinationCoachSlots.jsx index a5286af..e2b5975 100644 --- a/frontend/src/components/CombinationCoachSlots.jsx +++ b/frontend/src/components/CombinationCoachSlots.jsx @@ -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({ <>

{ex.title}

- - Im Katalog öffnen - + {typeof onOpenCandidatePeek === 'function' ? ( + + ) : ( + + Im Katalog öffnen + + )}

) : ( @@ -320,9 +341,19 @@ export default function CombinationCoachSlots({ ) : null}

- - Volle Übungsseite - + {typeof onOpenCandidatePeek === 'function' ? ( + + ) : ( + + Volle Übungsseite + + )}

) diff --git a/frontend/src/components/CombinationPlanBracket.jsx b/frontend/src/components/CombinationPlanBracket.jsx index fa63cc6..28438ee 100644 --- a/frontend/src/components/CombinationPlanBracket.jsx +++ b/frontend/src/components/CombinationPlanBracket.jsx @@ -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({
{stationTitle}
-
{names || '(keine Einzelübung)'}
+ {candidateInteraction === 'button' && typeof onCandidatePeek === 'function' && candRows.length > 0 ? ( +
+ {candRows.map((c, ci) => ( + + {ci > 0 ? : null} + + + ))} +
+ ) : candidateInteraction === 'link' && candRows.length > 0 ? ( +
+ {candRows.map((c, ci) => ( + + {ci > 0 ? : null} + + {c.label} + + + ))} +
+ ) : ( +
{names || '(keine Einzelübung)'}
+ )} {timing ? (
Zeit / Steuerung diff --git a/frontend/src/components/ExerciseFullContent.jsx b/frontend/src/components/ExerciseFullContent.jsx index 9615ac1..6458396 100644 --- a/frontend/src/components/ExerciseFullContent.jsx +++ b/frontend/src/components/ExerciseFullContent.jsx @@ -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 (
@@ -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}

{exercise.title}

diff --git a/frontend/src/components/ExercisePeekModal.jsx b/frontend/src/components/ExercisePeekModal.jsx index e3efdb8..9fae195 100644 --- a/frontend/src/components/ExercisePeekModal.jsx +++ b/frontend/src/components/ExercisePeekModal.jsx @@ -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>]} */ + 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} */ + 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()} > -
-

- {loading ? '…' : exercise?.title || titleFallback || `Übung #${exerciseId}`} +
+ {stack.length > 1 ? ( + + ) : null} +

+ {loading ? '…' : exercise?.title || titleFallback || (top?.exerciseId != null ? `Übung #${top.exerciseId}` : 'Übung')}

- {exerciseId && ( + {top?.exerciseId != null ? (
- + Vollständige Übungsseite öffnen
- )} + ) : null}

) diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx index f6fdea9..995802d 100644 --- a/frontend/src/components/TrainingUnitSectionsEditor.jsx +++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx @@ -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 + } />
) : ( diff --git a/frontend/src/pages/ExerciseDetailPage.jsx b/frontend/src/pages/ExerciseDetailPage.jsx index 3888320..aa35822 100644 --- a/frontend/src/pages/ExerciseDetailPage.jsx +++ b/frontend/src/pages/ExerciseDetailPage.jsx @@ -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" />
- {combinationCandidateLinks.length > 0 ? ( -
-
Verknüpfte Einzelübungen
-
    - {combinationCandidateLinks.map((c) => ( -
  • - {c.title || `Übung #${c.exercise_id}`} -
  • - ))} -
-
- ) : null} ) : null} diff --git a/frontend/src/pages/TrainingCoachPage.jsx b/frontend/src/pages/TrainingCoachPage.jsx index 8cb8481..a81bef5 100644 --- a/frontend/src/pages/TrainingCoachPage.jsx +++ b/frontend/src/pages/TrainingCoachPage.jsx @@ -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 (
+ setCandidatePeekId(null)} + />
diff --git a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx index d6df2d8..f0a2a00 100644 --- a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx +++ b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx @@ -1223,6 +1223,7 @@ export default function TrainingFrameworkProgramEditPage() { />