refactor(ui): enhance styling and structure of training unit sections and combination plan bracket
All checks were successful
Deploy Development / deploy (push) Successful in 40s
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 / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m5s
Test Suite / pytest-backend (pull_request) Successful in 34s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 11s
Test Suite / k6 /health Baseline (pull_request) Successful in 33s
Test Suite / playwright-tests (pull_request) Successful in 1m8s

- Updated CSS for training unit sections to improve layout and responsiveness, ensuring combo planning strips are displayed correctly.
- Refactored CombinationPlanBracket component to accept additional class names for better customization.
- Removed unused functions and streamlined imports in TrainingUnitSectionsEditor for cleaner code.
- Reintroduced ExercisePickerModal with improved placement in ExerciseFormPage for better user experience.
This commit is contained in:
Lars 2026-05-14 09:05:15 +02:00
parent 9da29a2231
commit 930a786315
4 changed files with 126 additions and 154 deletions

View File

@ -5414,22 +5414,80 @@ a.analysis-split__nav-item {
0 2px 12px rgba(15, 23, 42, 0.05);
}
/* KombinationsStrip: volle Breite unter der Zeile, begrenzte Textbreite — Hauptzeile (Name/Min.) nicht verdrängen */
/* Kombinationszeile: immer unter Hauptzeile (Titel / Minuten / Aktionen), nicht daneben */
.training-unit-sections-editor .tu-item-row--exercise.tu-item-row--combo {
flex-direction: column;
align-items: stretch;
flex-wrap: nowrap;
gap: 0;
}
.training-unit-sections-editor .tu-item-row--exercise.tu-item-row--combo .tu-item-row__mainline {
flex: none;
width: 100%;
}
/* KombinationsStrip: volle Breite; oben „Ablauf bearbeiten“, darunter KlammerVorschau */
.training-unit-sections-editor .tu-combo-planning-strip {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
padding: 10px 12px 12px;
border-top: 1px solid color-mix(in srgb, var(--border2) 85%, var(--accent) 12%);
background: color-mix(in srgb, var(--surface2) 65%, var(--surface));
margin-top: 2px;
}
.training-unit-sections-editor .tu-combo-planning-strip__meta {
width: 100%;
max-width: min(100%, 42rem);
.training-unit-sections-editor--item-drag .tu-item-row--combo .tu-combo-planning-strip {
padding-left: 44px;
}
.training-unit-sections-editor .tu-combo-planning-strip__toolbar {
display: flex;
justify-content: flex-end;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.training-unit-sections-editor .tu-combo-planning-strip__meta--fallback {
font-size: 0.78rem;
color: var(--text2);
line-height: 1.45;
}
.training-unit-sections-editor .tu-combo-planning-strip__bracket-wrap {
min-width: 0;
overflow-x: auto;
}
.training-unit-sections-editor .tu-combo-planning-strip > .btn {
align-self: flex-start;
.training-unit-sections-editor .combo-plan-bracket--planning-embed {
font-size: 0.93rem;
}
.training-unit-sections-editor .combo-plan-bracket--planning-embed .combo-plan-bracket__station {
padding: 8px 9px;
}
.training-unit-sections-editor .combo-plan-bracket--planning-embed .combo-plan-bracket__chip {
padding: 5px 8px;
}
.training-unit-sections-editor .combo-plan-bracket--planning-embed .combo-plan-bracket__globals-title {
font-size: 0.72rem;
}
.training-unit-sections-editor .combo-plan-bracket--planning-embed .combo-plan-bracket__head-main {
flex-wrap: wrap;
}
.training-unit-sections-editor .combo-plan-bracket--planning-embed .combo-plan-bracket__kicker {
font-size: 0.62rem;
}
.training-unit-sections-editor .combo-plan-bracket--planning-embed .combo-plan-bracket__archetype {
font-size: 0.88rem;
}
.tu-planning-mod-tag {

View File

@ -41,6 +41,7 @@ export default function CombinationPlanBracket({
/** 'none' | 'link' (Router) | 'button' (z. B. ExercisePeekModal / PWA-sicher) */
candidateInteraction = 'none',
onCandidatePeek,
className,
}) {
const arch = typeof methodArchetype === 'string' ? methodArchetype.trim() : ''
const archLabel = arch ? combinationArchetypeLabel(arch) : null
@ -59,7 +60,7 @@ export default function CombinationPlanBracket({
const coachHint = arch ? archetypeCoachHint(arch) : ''
return (
<div className="combo-plan-bracket">
<div className={['combo-plan-bracket', className].filter(Boolean).join(' ')}>
<div className="combo-plan-bracket__accent" aria-hidden />
<div className="combo-plan-bracket__body">
<header className="combo-plan-bracket__head">

View File

@ -3,7 +3,7 @@ import { GripVertical, Pencil } from 'lucide-react'
import CombinationMethodProfileEditor from './CombinationMethodProfileEditor'
import CombinationPlanBracket from './CombinationPlanBracket'
import { comboPlanningProfileJsonForEditor, effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
import { combinationArchetypeLabel, sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
import {
cloneJsonSerializablePlanningProfile,
comboSlotsOutlineForProfileEditor,
@ -13,7 +13,6 @@ import {
sectionPlannedMinutes,
} from '../utils/trainingUnitSectionsForm'
import api from '../utils/api'
import { effectiveStationTimingSummary, readSlotProfilesV1 } from '../utils/combinationMethodProfileUi'
import { isCompactTagLegendMode } from '../config/planningModuleUx'
import { useAuth } from '../context/AuthContext'
@ -74,60 +73,6 @@ function compactComboPlanningCaption(it) {
return overridden ? 'Planung angepasst' : 'wie Katalog'
}
/** Globale Eckdaten aus effective profile (optional unter Stationenliste). */
function comboRoughGlobalTimingHint(profileObj, archetypeKey) {
if (!profileObj || typeof profileObj !== 'object' || Array.isArray(profileObj)) return null
const bits = []
const rounds = profileObj.rounds
const ws = profileObj.work_seconds
const rb = profileObj.rest_between_rounds_sec
const hint = profileObj.hint_step_duration_sec
const globRest = profileObj.rest_between_sets_sec
if (rounds != null && rounds !== '') bits.push(`${rounds} Runden`)
if (ws != null && ws !== '') bits.push(`${ws}s Arbeit`)
if (rb != null && rb !== '') bits.push(`Pause ${rb}s`)
if (globRest != null && globRest !== '') bits.push(`Sets-Pause ${globRest}s`)
if (hint != null && hint !== '') bits.push(`Orientierung ~${hint}s`)
const arch = (archetypeKey || '').trim()
if (arch === 'time_domain_interval') {
const iw = profileObj.interval_work_sec
const ir = profileObj.interval_rest_sec
const ig = profileObj.interval_groups
if (iw != null && iw !== '') bits.push(`${iw}s Intervall`)
if (ir != null && ir !== '') bits.push(`${ir}s Erholung`)
if (ig != null && ig !== '') bits.push(`${ig} Gruppen`)
}
return bits.length ? bits.join(' · ') : null
}
/** Pro Station eine kompakte Textzeile für die Planungsliste. */
function comboPlanningStripBulletTexts(it) {
const slots = sortCombinationSlotsForDisplay(it.combination_slots || [])
if (!slots.length) return []
const mp = effectiveComboMethodProfile(it.catalog_method_profile || {}, it.planning_method_profile)
const archRaw = String(it.catalog_method_archetype || '').trim()
const byIx = new Map(readSlotProfilesV1(mp).map((r) => [Number(r.slot_index), r]))
const titles = it.combo_member_title_by_id || {}
return slots.map((slot, idx) => {
const siRaw = slot.slot_index
const siParsed =
siRaw === '' || siRaw == null ? idx : typeof siRaw === 'number' ? siRaw : parseInt(String(siRaw), 10)
const ix = Number.isFinite(siParsed) ? siParsed : idx
const stationLbl = ((slot.title || '').trim() || `Station ${idx + 1}`)
const candIds = (slot.candidate_exercise_ids || [])
.map((raw) => (typeof raw === 'number' ? raw : parseInt(String(raw), 10)))
.filter((n) => Number.isFinite(n))
const namesJoined =
candIds.length === 0
? '(keine Übung)'
: candIds.map((id) => titles[String(id)] || `Übung ${id}`).join(' ↔ ')
const timing = effectiveStationTimingSummary(archRaw, mp, byIx.get(ix))
let line = `${stationLbl}: ${namesJoined}`
if (timing) line += ` · ${timing}`
return line
})
}
/** Stabile Farbzurodnung aus Modul-ID (nur Darstellung). */
function planningModulePalette(moduleId) {
const id = normalizedPlanningModuleChainId(moduleId)
@ -703,7 +648,8 @@ export default function TrainingUnitSectionsEditor({
<div
className={
'training-unit-sections-editor' +
(wideExerciseGrid ? ' training-unit-sections-editor--wide' : '')
(wideExerciseGrid ? ' training-unit-sections-editor--wide' : '') +
(enableItemDragReorder ? ' training-unit-sections-editor--item-drag' : '')
}
>
{(!hideHeading || headingAccessory) ? (
@ -1017,10 +963,6 @@ export default function TrainingUnitSectionsEditor({
const stripArchRaw =
isCombination && it.exercise_id ? String(it.catalog_method_archetype || '').trim() : ''
const stripArchLbl =
stripArchRaw && isCombination ? combinationArchetypeLabel(stripArchRaw) : null
const stripBullets =
isCombination && it.exercise_id ? comboPlanningStripBulletTexts(it) : []
const stripMpEff =
isCombination && it.exercise_id
? effectiveComboMethodProfile(
@ -1028,17 +970,15 @@ export default function TrainingUnitSectionsEditor({
it.planning_method_profile,
)
: null
const stripGlobalRough =
isCombination && it.exercise_id && stripMpEff
? comboRoughGlobalTimingHint(stripMpEff, stripArchRaw)
: null
return (
<Fragment key={`${insertSlotKeyPrefix}sec-${sIdx}-blk-${iIdx}`}>
{!planningCompactLegend &&
renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
<div
className={`${rowCommon} tu-item-row--exercise${fromModClass}`}
className={`${rowCommon} tu-item-row--exercise${fromModClass}${
isCombination && it.exercise_id ? ' tu-item-row--combo' : ''
}`}
{...dndRowProps}
style={modBorderVarStyle}
>
@ -1215,76 +1155,48 @@ export default function TrainingUnitSectionsEditor({
</div>
{isCombination && it.exercise_id ? (
<div
className="tu-combo-planning-strip"
style={{
padding: '8px 12px 10px',
paddingLeft: enableItemDragReorder ? 44 : 12,
borderTop: '1px solid var(--border)',
background: 'var(--surface2)',
}}
>
<div
className="tu-combo-planning-strip__meta"
style={{
fontSize: '0.78rem',
color: 'var(--text2)',
lineHeight: 1.45,
}}
title="Stationen und grobe Zeiten aus Katalog bzw. Planungs-Anpassung — Details unter „Ablauf bearbeiten“ oder „Vorschau“"
>
<div style={{ marginBottom: stripBullets.length || stripGlobalRough ? 6 : 0 }}>
<strong style={{ color: 'var(--text1)', fontWeight: 600 }}>Archetyp:&nbsp;</strong>
<span style={{ color: 'var(--text1)' }}>
{stripArchLbl || stripArchRaw || '—'}
</span>
<span style={{ marginLeft: 10, fontWeight: 500, whiteSpace: 'nowrap' }}>
{compactComboPlanningCaption(it)}
</span>
</div>
{stripGlobalRough ? (
<div
style={{
marginBottom: stripBullets.length ? 6 : 0,
fontSize: '0.74rem',
color: 'var(--text3)',
}}
>
<strong style={{ color: 'var(--text2)', fontWeight: 600 }}>Block:&nbsp;</strong>
{stripGlobalRough}
</div>
) : null}
{stripBullets.length > 0 ? (
<ul
style={{
margin: 0,
paddingLeft: '1.05rem',
fontSize: '0.74rem',
color: 'var(--text2)',
}}
>
{stripBullets.map((line, bi) => (
<li key={`combo-strip-${sIdx}-${iIdx}-${bi}`} style={{ marginBottom: 2 }}>
{line}
</li>
))}
</ul>
) : (
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', fontStyle: 'italic' }}>
Stationen laden oder noch keine Kombi-Stationen im Katalog
</div>
)}
<div className="tu-combo-planning-strip">
<div className="tu-combo-planning-strip__toolbar">
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
aria-haspopup="dialog"
aria-label="Ablaufprofil Kombination für diese Planung bearbeiten"
onClick={() => setComboPlanningModal({ sIdx, iIdx })}
>
Ablauf bearbeiten
</button>
</div>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
style={{ flexShrink: 0 }}
aria-haspopup="dialog"
aria-label="Ablaufprofil Kombination für diese Planung bearbeiten"
onClick={() => setComboPlanningModal({ sIdx, iIdx })}
>
Ablauf bearbeiten
</button>
{(it.combination_slots || []).length > 0 ? (
<div className="tu-combo-planning-strip__bracket-wrap">
<CombinationPlanBracket
className="combo-plan-bracket--planning-embed"
methodArchetype={stripArchRaw}
methodProfile={stripMpEff || {}}
combinationSlots={sortCombinationSlotsForDisplay(it.combination_slots)}
planningAdjusted={
it.planning_method_profile != null &&
typeof it.planning_method_profile === 'object' &&
!Array.isArray(it.planning_method_profile)
}
candidateInteraction={onPeekExercise ? 'button' : 'none'}
onCandidatePeek={
onPeekExercise
? (exId) => onPeekExercise(Number(exId), null, undefined)
: undefined
}
/>
</div>
) : (
<div
className="tu-combo-planning-strip__meta tu-combo-planning-strip__meta--fallback"
title="Stationen aus dem Katalog — nach ersten Laden oder wenn die Kombination noch keine Slots hat."
>
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', fontStyle: 'italic', margin: 0 }}>
Stationen werden geladen oder die Kombination hat im Katalog noch keine Stationsliste
</div>
</div>
)}
</div>
) : null}

View File

@ -2403,18 +2403,6 @@ function ExerciseFormPage() {
}
/>
)}
<ExercisePickerModal
open={comboStationPickerIx !== null}
onClose={() => setComboStationPickerIx(null)}
exerciseKindAny={['simple']}
multiSelect
enableQuickCreateDraft
onSelectExercises={(picked) => {
if (comboStationPickerIx === null) return
mergePickedExercisesIntoSlot(comboStationPickerIx, picked)
setComboStationPickerIx(null)
}}
/>
{reportTarget && (
<ReportContentModal
targetType="media_asset"
@ -2426,6 +2414,19 @@ function ExerciseFormPage() {
</div>
)}
<ExercisePickerModal
open={comboStationPickerIx !== null}
onClose={() => setComboStationPickerIx(null)}
exerciseKindAny={['simple']}
multiSelect
enableQuickCreateDraft
onSelectExercises={(picked) => {
if (comboStationPickerIx === null) return
mergePickedExercisesIntoSlot(comboStationPickerIx, picked)
setComboStationPickerIx(null)
}}
/>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '16px' }}>
<strong>KI-Ausbaustufe:</strong> Backend laut Spec{' '}
<code style={{ fontSize: '11px' }}>POST /api/exercises/ai/suggest</code> und{' '}