feat(combo-planning): enhance combination profile handling and UI improvements
All checks were successful
Deploy Development / deploy (push) Successful in 36s
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

- 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 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-05-13 14:42:01 +02:00
parent 79dabbca5a
commit d3ddc52118
8 changed files with 142 additions and 95 deletions

View File

@ -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 */

View File

@ -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 (
<section
@ -122,14 +152,7 @@ export default function CombinationCoachSlots({
{compactPlanningView ? 'Stationen & Einzelübungen (Katalog)' : 'Kombination · Stationen & Einzelübungen'}
</h3>
{archDisplay ? (
<p style={{ margin: '0 0 6px', fontSize: '0.95rem', fontWeight: 700 }}>
{archDisplay}
{archeKey && archDisplay !== archeKey ? (
<span style={{ marginLeft: 8, fontSize: '0.78rem', fontWeight: 500, color: 'var(--text3)' }}>
({archeKey})
</span>
) : null}
</p>
<p style={{ margin: '0 0 6px', fontSize: '0.95rem', fontWeight: 700 }}>{archDisplay}</p>
) : null}
{compactPlanningView ? null : (
<p style={{ margin: '0 0 14px', fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.48 }}>
@ -148,28 +171,40 @@ export default function CombinationCoachSlots({
}}
>
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: 'var(--text3)', textTransform: 'uppercase', marginBottom: '6px' }}>
Geplantes Ablaufprofil (Katalog)
Globale Eckdaten (wie im Editor)
</div>
{methodProfileKvSansSlots.length === 0 ? (
{globalComboRows.length === 0 && profileExtraEntries.length === 0 ? (
<p style={{ margin: 0, fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.42 }}>
Nur stationsbezogene Daten (Zeiten/ZählSteuerung) 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).
</p>
) : (
<dl style={{ margin: 0, fontSize: '0.82rem', lineHeight: 1.45 }}>
{methodProfileKvSansSlots.map(([k, val]) => (
<div key={k} style={{ marginBottom: '4px', display: 'grid', gridTemplateColumns: 'minmax(72px,1fr) minmax(0,2fr)', gap: '6px 10px' }}>
{globalComboRows.map((row) => (
<div
key={row.key}
style={{
marginBottom: '6px',
display: 'grid',
gridTemplateColumns: 'minmax(88px,1fr) minmax(0,1.4fr)',
gap: '6px 10px',
}}
>
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>{row.detailLabel}</dt>
<dd style={{ margin: 0, color: 'var(--text1)', fontWeight: 600 }}>{row.value}</dd>
</div>
))}
{profileExtraEntries.map(([k, val]) => (
<div
key={k}
style={{
marginBottom: '4px',
display: 'grid',
gridTemplateColumns: 'minmax(88px,1fr) minmax(0,1.4fr)',
gap: '6px 10px',
}}
>
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)', wordBreak: 'break-all' }}>{k}</dt>
<dd style={{ margin: 0, color: 'var(--text1)' }}>
{typeof val === 'boolean'
? val
? 'ja'
: 'nein'
: typeof val === 'number'
? String(val)
: typeof val === 'string'
? val
: JSON.stringify(val)}
</dd>
<dd style={{ margin: 0, color: 'var(--text1)' }}>{formatInlineProfileValue(val)}</dd>
</div>
))}
</dl>

View File

@ -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 ArbeitSekunden.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{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)',
}}
>
<div style={{ marginBottom: '10px', fontWeight: 700, color: 'var(--text1)', fontSize: '0.9rem' }}>
Station {si}
<span style={{ fontWeight: 400, marginLeft: 8, color: 'var(--text2)', fontSize: '0.86rem' }}>{ttl}</span>
</div>
<div style={{ marginBottom: '10px', fontWeight: 700, color: 'var(--text1)', fontSize: '0.9rem' }}>{ttl}</div>
<div className="form-row" style={{ marginBottom: '10px', maxWidth: '22rem' }}>
<label className="form-label" style={{ fontSize: '11px' }}>
Steuerung
@ -392,11 +389,7 @@ export default function CombinationMethodProfileEditor({
min={slotAdv === 'rep' ? 1 : undefined}
className="form-input"
placeholder="1"
value={
row.rep_series_count != null && String(row.rep_series_count) !== ''
? String(row.rep_series_count)
: String(defaultRepSeriesCountForArchetype(methodArchetype))
}
value={String(parseComboRepSeriesCountUi(row.rep_series_count))}
onChange={(e) => onSlotRepSeriesCount(si, e.target.value)}
/>
</div>

View File

@ -58,12 +58,7 @@ export default function CombinationPlanBracket({
<header className="combo-plan-bracket__head">
<div className="combo-plan-bracket__head-main">
<span className="combo-plan-bracket__kicker">KombinationsPlan</span>
<span className="combo-plan-bracket__archetype">
{archLabel || arch || 'Archetyp'}
{arch && archLabel && archLabel !== arch ? (
<span className="combo-plan-bracket__archetype-id"> ({arch})</span>
) : null}
</span>
<span className="combo-plan-bracket__archetype">{archLabel || arch || 'Archetyp'}</span>
{planningAdjusted ? (
<span className="combo-plan-bracket__badge">Planung angepasst</span>
) : null}
@ -102,12 +97,6 @@ export default function CombinationPlanBracket({
return (
<li key={`slot-${stationIx}-${si}`} className="combo-plan-bracket__station">
<div
className="combo-plan-bracket__station-index"
title={`Technischer Slot-Index (slot_index): ${stationIx}`}
>
S{displayStep}
</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>

View File

@ -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 (
<div style={{ textAlign: 'center', padding: '1rem' }}>
@ -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 (

View File

@ -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() {
</p>
) : null}
<ol style={{ paddingLeft: '1.25rem', marginBottom: 0 }}>
{exercise.combination_slots.map((s) => (
<li key={`${s.slot_index}-${(s.title || '').slice(0, 8)}`} style={{ marginBottom: '10px' }}>
<strong>
Station {s.slot_index != null ? s.slot_index : '?'}{s.title ? `${s.title}` : ''}
</strong>
{sortCombinationSlotsForDisplay(exercise.combination_slots).map((s, idx) => (
<li key={`${s.slot_index}-${idx}-${(s.title || '').slice(0, 8)}`} style={{ marginBottom: '10px' }}>
<strong>{(s.title || '').trim() || `Station ${idx + 1}`}</strong>
<ul style={{ margin: '4px 0 0', paddingLeft: '1.2rem' }}>
{(s.candidates && s.candidates.length
? s.candidates

View File

@ -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

View File

@ -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) {