From d3ddc52118f129922d8b97b09eea86e22439170a Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 13 May 2026 14:42:01 +0200 Subject: [PATCH] feat(combo-planning): enhance combination profile handling and UI improvements - Updated CombinationCoachSlots to integrate a new function for formatting inline profile values, improving data display consistency. - Refactored CombinationMethodProfileEditor to streamline slot index handling and enhance title clarity. - Improved CombinationPlanBracket by removing unnecessary elements for a cleaner UI. - Enhanced ExerciseFullContent to support additional catalog method profile snapshots, improving exercise detail accuracy. - Updated CSS for combo plan brackets to enhance visual presentation and alignment. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/app.css | 28 +----- .../src/components/CombinationCoachSlots.jsx | 95 +++++++++++++------ .../CombinationMethodProfileEditor.jsx | 17 +--- .../src/components/CombinationPlanBracket.jsx | 13 +-- .../src/components/ExerciseFullContent.jsx | 15 ++- frontend/src/pages/ExerciseDetailPage.jsx | 9 +- frontend/src/pages/TrainingCoachPage.jsx | 5 + .../src/utils/comboPlanningMethodProfile.js | 55 +++++++++-- 8 files changed, 142 insertions(+), 95 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index f23f9cf..a9fcbfd 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -6318,38 +6318,22 @@ a.analysis-split__nav-item { color: var(--text1); } .combo-plan-bracket__stations { - list-style: none; - padding: 0; + list-style: decimal; + list-style-position: outside; + padding: 0 0 0 1.35rem; margin: 0; display: flex; flex-direction: column; gap: 10px; } .combo-plan-bracket__station { - display: flex; - gap: 10px; - align-items: flex-start; + display: block; padding: 10px 10px; border-radius: 10px; border: 1px solid var(--border); background: var(--surface2); } -.combo-plan-bracket__station-index { - flex-shrink: 0; - min-width: 2.25rem; - height: 2.25rem; - display: flex; - align-items: center; - justify-content: center; - border-radius: 8px; - font-size: 0.72rem; - font-weight: 800; - background: var(--surface); - border: 1px solid var(--border); - color: var(--accent-dark); -} .combo-plan-bracket__station-main { - flex: 1; min-width: 0; } .combo-plan-bracket__station-title { @@ -6463,10 +6447,6 @@ a.analysis-split__nav-item { border-color: #444 !important; background: #f4f6f8 !important; } - .combo-plan-bracket__station-index { - border-color: #444 !important; - color: #06352a !important; - } } /* Coach — volle Übung, Nur-Mittelbereich scrollt; Steuerung oben/unten sichtbar */ diff --git a/frontend/src/components/CombinationCoachSlots.jsx b/frontend/src/components/CombinationCoachSlots.jsx index d9a1954..a5286af 100644 --- a/frontend/src/components/CombinationCoachSlots.jsx +++ b/frontend/src/components/CombinationCoachSlots.jsx @@ -10,7 +10,24 @@ import { combinationArchetypeLabel, sortCombinationSlotsForDisplay, } from '../constants/combinationArchetypes' -import { effectiveStationTimingSummary, readSlotProfilesV1 } from '../utils/combinationMethodProfileUi' +import { + describeGlobalComboProfile, + effectiveStationTimingSummary, + METHOD_PROFILE_GUI_FIELDS, + readSlotProfilesV1, +} from '../utils/combinationMethodProfileUi' + +function formatInlineProfileValue(val) { + if (val === null || val === undefined) return '—' + if (typeof val === 'boolean') return val ? 'ja' : 'nein' + if (typeof val === 'number' && Number.isFinite(val)) return String(val) + if (typeof val === 'string') return val.trim() === '' ? '—' : val + try { + return JSON.stringify(val) + } catch { + return String(val) + } +} export default function CombinationCoachSlots({ combinationSlots, @@ -93,12 +110,25 @@ export default function CombinationCoachSlots({ return m }, [methodProfile]) - const methodProfileKvSansSlots = useMemo(() => { + const globalComboRows = useMemo( + () => describeGlobalComboProfile(archeKey, methodProfile || {}), + [archeKey, methodProfile], + ) + + const profileExtraEntries = useMemo(() => { if (!methodProfile || typeof methodProfile !== 'object' || Array.isArray(methodProfile)) return [] - return Object.entries(methodProfile) - .filter(([k]) => k !== 'slot_profiles_v1') - .sort(([a], [b]) => a.localeCompare(b, 'de')) - }, [methodProfile]) + const known = new Set(['slot_profiles_v1']) + for (const f of METHOD_PROFILE_GUI_FIELDS[archeKey] || []) { + known.add(f.key) + } + const out = [] + for (const [k, val] of Object.entries(methodProfile)) { + if (known.has(k)) continue + if (val === null || val === undefined || val === '') continue + out.push([k, val]) + } + return out.sort((a, b) => String(a[0]).localeCompare(String(b[0]), 'de')) + }, [methodProfile, archeKey]) return (
{archDisplay ? ( -

- {archDisplay} - {archeKey && archDisplay !== archeKey ? ( - - ({archeKey}) - - ) : null} -

+

{archDisplay}

) : null} {compactPlanningView ? null : (

@@ -148,28 +171,40 @@ export default function CombinationCoachSlots({ }} >

- Geplantes Ablaufprofil (Katalog) + Globale Eckdaten (wie im Editor)
- {methodProfileKvSansSlots.length === 0 ? ( + {globalComboRows.length === 0 && profileExtraEntries.length === 0 ? (

- Nur stationsbezogene Daten (Zeiten/Zähl‑Steuerung) — siehe je Station unter der Überschrift „Plan:“. + Keine globalen Zahlenfelder gesetzt — Zeiten und Steuerung siehe je Station unter „Plan:“ (oder nur im Freitext der Kombination).

) : (
- {methodProfileKvSansSlots.map(([k, val]) => ( -
+ {globalComboRows.map((row) => ( +
+
{row.detailLabel}
+
{row.value}
+
+ ))} + {profileExtraEntries.map(([k, val]) => ( +
{k}
-
- {typeof val === 'boolean' - ? val - ? 'ja' - : 'nein' - : typeof val === 'number' - ? String(val) - : typeof val === 'string' - ? val - : JSON.stringify(val)} -
+
{formatInlineProfileValue(val)}
))}
diff --git a/frontend/src/components/CombinationMethodProfileEditor.jsx b/frontend/src/components/CombinationMethodProfileEditor.jsx index a8fae3f..4236ad5 100644 --- a/frontend/src/components/CombinationMethodProfileEditor.jsx +++ b/frontend/src/components/CombinationMethodProfileEditor.jsx @@ -1,5 +1,5 @@ import React, { useMemo, useState } from 'react' -import { archetypeCoachHint, combinationArchetypeLabel, sortCombinationSlotsForDisplay, defaultRepSeriesCountForArchetype } from '../constants/combinationArchetypes' +import { archetypeCoachHint, combinationArchetypeLabel, sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes' import { METHOD_PROFILE_GUI_FIELDS, parseProfileJson, @@ -299,13 +299,13 @@ export default function CombinationMethodProfileEditor({ Zirkel erst die globalen Arbeit‑Sekunden.

- {outlineSorted.map((slot) => { + {outlineSorted.map((slot, ordIdx) => { const siRaw = slot.slot_index const si = siRaw === '' || siRaw == null ? null : typeof siRaw === 'number' ? siRaw : parseInt(String(siRaw), 10) if (!Number.isFinite(si)) return null const row = lookupSlotTiming(si) - const ttl = ((slot.title || '').trim() || `Station ${si}`).trim() + const ttl = ((slot.title || '').trim() || `Station ${ordIdx + 1}`).trim() const slotAdv = normalizeAdvanceMode(row.advance_mode) const serieLabel = slotAdv === 'timed' ? 'Wdh. ohne Wechsel' : slotAdv === 'rep' ? 'Wdh. / Serie' : 'Richtwert' @@ -323,10 +323,7 @@ export default function CombinationMethodProfileEditor({ background: 'var(--surface2)', }} > -
- Station {si} - {ttl} -
+
{ttl}
diff --git a/frontend/src/components/CombinationPlanBracket.jsx b/frontend/src/components/CombinationPlanBracket.jsx index fa8e497..7c919dd 100644 --- a/frontend/src/components/CombinationPlanBracket.jsx +++ b/frontend/src/components/CombinationPlanBracket.jsx @@ -58,12 +58,7 @@ export default function CombinationPlanBracket({
Kombinations‑Plan - - {archLabel || arch || 'Archetyp'} - {arch && archLabel && archLabel !== arch ? ( - ({arch}) - ) : null} - + {archLabel || arch || 'Archetyp'} {planningAdjusted ? ( Planung angepasst ) : null} @@ -102,12 +97,6 @@ export default function CombinationPlanBracket({ return (
  • -
    - S{displayStep} -
    {stationTitle}
    {names || '(keine Einzelübung)'}
    diff --git a/frontend/src/components/ExerciseFullContent.jsx b/frontend/src/components/ExerciseFullContent.jsx index 2d4ef6e..9615ac1 100644 --- a/frontend/src/components/ExerciseFullContent.jsx +++ b/frontend/src/components/ExerciseFullContent.jsx @@ -54,9 +54,9 @@ function metaParts(exercise) { } /** - * @param {{ exercise?: object|null, loading?: boolean, error?: string|null, exerciseId?: number, variantId?: number|string|null, planningComboMethodProfile?: object|null }} props + * @param {{ exercise?: object|null, loading?: boolean, error?: string|null, exerciseId?: number, variantId?: number|string|null, planningComboMethodProfile?: object|null, catalogMethodProfileSnapshot?: object|null }} props */ -export default function ExerciseFullContent({ exercise, loading, error, exerciseId, variantId, planningComboMethodProfile }) { +export default function ExerciseFullContent({ exercise, loading, error, exerciseId, variantId, planningComboMethodProfile, catalogMethodProfileSnapshot }) { if (loading) { return (
    @@ -81,8 +81,17 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise const isCombination = String(exercise.exercise_kind || 'simple').toLowerCase().trim() === 'combination' + const catalogFromExercise = + exercise.method_profile && typeof exercise.method_profile === 'object' && !Array.isArray(exercise.method_profile) + ? exercise.method_profile + : {} + const catalogFromUnit = + catalogMethodProfileSnapshot && typeof catalogMethodProfileSnapshot === 'object' && !Array.isArray(catalogMethodProfileSnapshot) + ? catalogMethodProfileSnapshot + : {} + const coachComboProfile = isCombination - ? effectiveComboMethodProfile(exercise.method_profile, planningComboMethodProfile) + ? effectiveComboMethodProfile({ ...catalogFromExercise, ...catalogFromUnit }, planningComboMethodProfile) : null return ( diff --git a/frontend/src/pages/ExerciseDetailPage.jsx b/frontend/src/pages/ExerciseDetailPage.jsx index a3403ea..9909f5e 100644 --- a/frontend/src/pages/ExerciseDetailPage.jsx +++ b/frontend/src/pages/ExerciseDetailPage.jsx @@ -4,6 +4,7 @@ import api from '../utils/api' import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock' import ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaStrip' import { formatSkillLevelSlug } from '../constants/skillLevels' +import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes' function TagRow({ exercise }) { const tags = [] @@ -147,11 +148,9 @@ function ExerciseDetailPage() {

    ) : null}
      - {exercise.combination_slots.map((s) => ( -
    1. - - Station {s.slot_index != null ? s.slot_index : '?'}{s.title ? ` — ${s.title}` : ''} - + {sortCombinationSlotsForDisplay(exercise.combination_slots).map((s, idx) => ( +
    2. + {(s.title || '').trim() || `Station ${idx + 1}`}
        {(s.candidates && s.candidates.length ? s.candidates diff --git a/frontend/src/pages/TrainingCoachPage.jsx b/frontend/src/pages/TrainingCoachPage.jsx index ad4b8b5..8cb8481 100644 --- a/frontend/src/pages/TrainingCoachPage.jsx +++ b/frontend/src/pages/TrainingCoachPage.jsx @@ -740,6 +740,11 @@ export default function TrainingCoachPage() { exercise={catalogExercise} exerciseId={currentEntry?.item?.exercise_id ?? null} variantId={currentEntry?.item?.exercise_variant_id ?? null} + catalogMethodProfileSnapshot={ + String(currentEntry?.item?.exercise_kind || 'simple').toLowerCase().trim() === 'combination' + ? currentEntry?.item?.catalog_method_profile ?? null + : null + } planningComboMethodProfile={ String(currentEntry?.item?.exercise_kind || 'simple').toLowerCase().trim() === 'combination' ? currentEntry?.item?.planning_method_profile ?? null diff --git a/frontend/src/utils/comboPlanningMethodProfile.js b/frontend/src/utils/comboPlanningMethodProfile.js index 0de9814..7ff6e7f 100644 --- a/frontend/src/utils/comboPlanningMethodProfile.js +++ b/frontend/src/utils/comboPlanningMethodProfile.js @@ -1,19 +1,56 @@ /** Effektives Ablaufprofil für Kombination im Coach/in der Planung */ +/** + * Vereinigt slot_profiles_v1 aus Katalog und Planungs-Overlay (je slot_index). + * @param {unknown} catArr + * @param {unknown} planArr + */ +function mergeSlotProfilesV1(catArr, planArr) { + const c = Array.isArray(catArr) ? catArr : [] + const p = Array.isArray(planArr) ? planArr : [] + const byIx = new Map() + for (const r of c) { + if (!r || typeof r !== 'object') continue + const ix = Number(r.slot_index) + if (!Number.isFinite(ix)) continue + byIx.set(ix, { ...r }) + } + for (const r of p) { + if (!r || typeof r !== 'object') continue + const ix = Number(r.slot_index) + if (!Number.isFinite(ix)) continue + const prev = byIx.get(ix) || {} + byIx.set(ix, { ...prev, ...r }) + } + return [...byIx.entries()].sort((a, b) => a[0] - b[0]).map(([, row]) => row) +} + +/** + * Katalog-Basis + optionaler Planungs-Snapshot. + * Wichtig: `planning_method_profile` darf nur **Überschreibungen** sein — nie den Katalog komplett ersetzen + * (sonst verschwinden Zeiten/Runden bei leerem Objekt oder Teil-Payload). + */ export function effectiveComboMethodProfile(catalogDict, planningSnapshot) { const cat = - catalogDict && typeof catalogDict === 'object' && !Array.isArray(catalogDict) - ? catalogDict - : {} + catalogDict && typeof catalogDict === 'object' && !Array.isArray(catalogDict) ? { ...catalogDict } : {} + if ( - planningSnapshot !== null && - planningSnapshot !== undefined && - typeof planningSnapshot === 'object' && - !Array.isArray(planningSnapshot) + planningSnapshot === null || + planningSnapshot === undefined || + typeof planningSnapshot !== 'object' || + Array.isArray(planningSnapshot) ) { - return { ...planningSnapshot } + return { ...cat } } - return { ...cat } + + const plan = planningSnapshot + const merged = { ...cat, ...plan } + + if (Object.prototype.hasOwnProperty.call(plan, 'slot_profiles_v1')) { + merged.slot_profiles_v1 = mergeSlotProfilesV1(cat.slot_profiles_v1, plan.slot_profiles_v1) + } + + return merged } export function comboPlanningProfileJsonForEditor(catalogDict, planningSnapshot) {