feat(training-unit-editor): enhance combo planning UI and functionality
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / playwright-tests (push) Successful in 1m2s
Test Suite / pytest-backend (pull_request) Successful in 33s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 10s
Test Suite / playwright-tests (pull_request) Successful in 1m2s

- Updated CSS for the combo planning strip to improve layout and visual consistency.
- Refactored `compactComboPlanningCaption` to simplify the display of planning status.
- Introduced a new utility function to infer advance mode from stored slot rows, enhancing profile handling.
- Improved merging logic for slot profiles to ensure accurate representation of advance modes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-05-13 15:49:14 +02:00
parent 3898e8bc2c
commit 81fd7d9b3b
4 changed files with 96 additions and 19 deletions

View File

@ -5412,6 +5412,24 @@ 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 */
.training-unit-sections-editor .tu-combo-planning-strip {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.training-unit-sections-editor .tu-combo-planning-strip__meta {
width: 100%;
max-width: min(100%, 42rem);
min-width: 0;
}
.training-unit-sections-editor .tu-combo-planning-strip > .btn {
align-self: flex-start;
}
.tu-planning-mod-tag {
display: inline-flex;
align-items: center;

View File

@ -65,18 +65,13 @@ function gatherPlanningModuleOutline(items, startIdx, moduleId) {
const MODULE_OUTLINE_PREVIEW_MAX = 8
/** Statuszeile: PlanungsOverride vs. Katalog, inkl. ArchetypLabel wenn bekannt. */
/** Statuszeile: nur PlanungsOverride vs. Katalog (Archetyp steht bereits links). */
function compactComboPlanningCaption(it) {
const overridden =
it.planning_method_profile != null &&
typeof it.planning_method_profile === 'object' &&
!Array.isArray(it.planning_method_profile)
const archRaw = String(it.catalog_method_archetype || '').trim()
const archLbl = archRaw ? combinationArchetypeLabel(archRaw) : null
if (overridden) {
return archLbl ? `${archLbl} · Planung angepasst` : 'Planung angepasst'
}
return archLbl ? `${archLbl} · wie Katalog` : 'wie im Katalog'
return overridden ? 'Planung angepasst' : 'wie Katalog'
}
/** Globale Eckdaten aus effective profile (optional unter Stationenliste). */
@ -1223,10 +1218,6 @@ export default function TrainingUnitSectionsEditor({
<div
className="tu-combo-planning-strip"
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'flex-start',
gap: '10px',
padding: '8px 12px 10px',
paddingLeft: enableItemDragReorder ? 44 : 12,
borderTop: '1px solid var(--border)',
@ -1234,9 +1225,8 @@ export default function TrainingUnitSectionsEditor({
}}
>
<div
className="tu-combo-planning-strip__meta"
style={{
flex: '1 1 200px',
minWidth: 0,
fontSize: '0.78rem',
color: 'var(--text2)',
lineHeight: 1.45,
@ -1248,7 +1238,9 @@ export default function TrainingUnitSectionsEditor({
<span style={{ color: 'var(--text1)' }}>
{stripArchLbl || stripArchRaw || '—'}
</span>
<span style={{ marginLeft: 10, fontWeight: 500 }}>{compactComboPlanningCaption(it)}</span>
<span style={{ marginLeft: 10, fontWeight: 500, whiteSpace: 'nowrap' }}>
{compactComboPlanningCaption(it)}
</span>
</div>
{stripGlobalRough ? (
<div

View File

@ -15,6 +15,31 @@ export function normalizeAdvanceMode(v) {
return 'timed'
}
/**
* Modus aus Roh-Zeile (Legacy ohne advance_mode; oder nur ZielWdh. ohne Sekunden).
*/
export function inferAdvanceModeFromStoredSlotRow(row) {
if (!row || typeof row !== 'object') return 'timed'
const explicitRaw = row.advance_mode
if (explicitRaw !== undefined && explicitRaw !== null && String(explicitRaw).trim() !== '') {
const e = normalizeAdvanceMode(explicitRaw)
return e === 'rep' || e === 'manual' ? e : 'timed'
}
const load =
row.load_sec !== undefined && row.load_sec !== null && row.load_sec !== ''
? normalizeOptionalNonNegInt(row.load_sec)
: undefined
const reps =
row.consecutive_reps !== undefined && row.consecutive_reps !== null && row.consecutive_reps !== ''
? normalizeOptionalPositiveInt(row.consecutive_reps)
: undefined
if (load != null && reps == null) return 'timed'
if (reps != null && load == null) return 'rep'
if (load != null && reps != null) return 'timed'
return 'timed'
}
/** UI: Serien-Anzahl aus Formularfeld; leer/ungültig ⇒ 1 (eine Serie). */
export function parseComboRepSeriesCountUi(raw) {
if (raw === '' || raw === undefined || raw === null) return 1
@ -267,10 +292,10 @@ export function readSlotProfilesV1(profileObj) {
return raw.map((row) => {
if (!row || typeof row !== 'object') return null
const si = Number(row.slot_index)
const mode = normalizeAdvanceMode(row.advance_mode)
const inferredMode = inferAdvanceModeFromStoredSlotRow(row)
const out = {
slot_index: Number.isFinite(si) ? si : 0,
advance_mode: mode,
advance_mode: inferredMode,
load_sec: normalizeOptionalNonNegInt(row.load_sec),
consecutive_reps: normalizeOptionalPositiveInt(row.consecutive_reps),
rep_series_count: normalizeOptionalPositiveInt(row.rep_series_count),
@ -289,6 +314,8 @@ export function summarizeSlotProfileBrief(r) {
if (adv === 'timed') {
bits.push('Zeit')
if (r.load_sec != null) bits.push(`${r.load_sec}s Arbeit`)
if (r.consecutive_reps != null)
bits.push(`${r.consecutive_reps}× Wdh. ohne Wechsel zur nächsten Station`)
} else if (adv === 'rep') {
bits.push('ZielWdh.')
const nSer = r.rep_series_count != null && r.rep_series_count >= 1 ? r.rep_series_count : 1
@ -307,7 +334,8 @@ export function summarizeSlotProfileBrief(r) {
}
}
if (r.intra_rep_rest_sec != null) {
if (adv === 'timed') bits.push(`Pause ${r.intra_rep_rest_sec}s`)
if (adv === 'timed')
bits.push(`Pause zw. Wdh. ${r.intra_rep_rest_sec}s (nicht Stationswechsel)`)
else if (adv === 'rep' && r.rep_series_count != null && r.rep_series_count >= 2)
bits.push(`Pause zw. Serien ${r.intra_rep_rest_sec}s`)
else if (
@ -330,6 +358,7 @@ export function stationPrimaryLoadLabel(slotRow) {
const adv = slotRow.advance_mode || 'timed'
if (adv === 'timed') {
if (slotRow.load_sec != null) return `${slotRow.load_sec}s`
if (slotRow.consecutive_reps != null) return `${slotRow.consecutive_reps}×`
return null
}
if (adv === 'rep') {

View File

@ -1,5 +1,7 @@
/** Effektives Ablaufprofil für Kombination im Coach/in der Planung */
import { inferAdvanceModeFromStoredSlotRow } from './combinationMethodProfileUi'
/** Top-Level: null/undefined aus Planungs-Snapshot löschen keine Katalog-Felder (API liefert oft JSON-null). */
function omitNullUndefinedTop(planObj) {
const out = {}
@ -11,16 +13,52 @@ function omitNullUndefinedTop(planObj) {
}
/** Je Slot-Zeile: null/undefined aus Planung überschreiben keine Katalog-Werte (z. B. consecutive_reps). */
function patchHasExplicitAdvanceMode(patch) {
return (
Object.prototype.hasOwnProperty.call(patch, 'advance_mode') &&
patch.advance_mode !== null &&
patch.advance_mode !== undefined &&
String(patch.advance_mode).trim() !== ''
)
}
/** Nach Merge: Rep/Manual ohne Sekunden-Arbeit; Zeit ohne advance_mode-Schlüssel. */
function coerceMergedSlotProfileRow(row) {
if (!row || typeof row !== 'object') return row
const inferred = inferAdvanceModeFromStoredSlotRow(row)
if (inferred === 'rep' || inferred === 'manual') {
delete row.load_sec
row.advance_mode = inferred
} else {
delete row.advance_mode
}
return row
}
function mergeSlotProfileFields(prev, patch) {
const base = prev && typeof prev === 'object' ? { ...prev } : {}
if (!patch || typeof patch !== 'object') return base
if (!patch || typeof patch !== 'object') return coerceMergedSlotProfileRow(base)
for (const [k, v] of Object.entries(patch)) {
if (v === null || v === undefined) continue
base[k] = v
}
const loadExplicit =
Object.prototype.hasOwnProperty.call(patch, 'load_sec') &&
patch.load_sec !== null &&
patch.load_sec !== undefined &&
String(patch.load_sec).trim() !== ''
// Nur Arbeitsssekunden aus Planung → Zeitmodus soll Katalogadvance_mode rep nicht „festhalten“
if (loadExplicit && !patchHasExplicitAdvanceMode(patch)) {
delete base.advance_mode
}
const ix = Number(base.slot_index)
if (Number.isFinite(ix)) base.slot_index = ix
return base
return coerceMergedSlotProfileRow(base)
}
/**