diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py
index baac6d3..49e492f 100644
--- a/backend/routers/training_planning.py
+++ b/backend/routers/training_planning.py
@@ -8,6 +8,7 @@ from datetime import date, timedelta
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
+from psycopg2.extras import Json as PsycopgJson
from db import get_db, get_cursor, r2d
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
@@ -771,6 +772,7 @@ def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], s
planning_mp = _normalize_planning_method_profile_payload(raw.get("planning_method_profile"))
if ek != "combination":
planning_mp = None
+ planning_sql_val = PsycopgJson(planning_mp) if planning_mp is not None else None
src_mod = _optional_source_training_module_id_payload(raw.get("source_training_module_id"))
cur.execute(
"""
@@ -793,7 +795,7 @@ def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], s
raw.get("notes"),
raw.get("modifications"),
src_mod,
- planning_mp,
+ planning_sql_val,
),
)
diff --git a/frontend/src/app.css b/frontend/src/app.css
index a9fcbfd..fb4d05e 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -6327,13 +6327,31 @@ a.analysis-split__nav-item {
gap: 10px;
}
.combo-plan-bracket__station {
- display: block;
+ display: flex;
+ gap: 10px;
+ align-items: flex-start;
padding: 10px 10px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface2);
}
+.combo-plan-bracket__station-load {
+ flex-shrink: 0;
+ min-width: 3rem;
+ max-width: 5rem;
+ padding: 7px 8px;
+ border-radius: 8px;
+ font-size: 0.78rem;
+ font-weight: 700;
+ line-height: 1.2;
+ text-align: center;
+ align-self: flex-start;
+ 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 {
@@ -6447,6 +6465,11 @@ a.analysis-split__nav-item {
border-color: #444 !important;
background: #f4f6f8 !important;
}
+ .combo-plan-bracket__station-load {
+ border-color: #444 !important;
+ background: #fff !important;
+ color: #06352a !important;
+ }
}
/* Coach — volle Übung, Nur-Mittelbereich scrollt; Steuerung oben/unten sichtbar */
diff --git a/frontend/src/components/CombinationPlanBracket.jsx b/frontend/src/components/CombinationPlanBracket.jsx
index 7c919dd..fa63cc6 100644
--- a/frontend/src/components/CombinationPlanBracket.jsx
+++ b/frontend/src/components/CombinationPlanBracket.jsx
@@ -7,7 +7,12 @@ import {
combinationArchetypeLabel,
sortCombinationSlotsForDisplay,
} from '../constants/combinationArchetypes'
-import { describeGlobalComboProfile, effectiveStationTimingSummary, readSlotProfilesV1 } from '../utils/combinationMethodProfileUi'
+import {
+ describeGlobalComboProfile,
+ effectiveStationTimingSummary,
+ readSlotProfilesV1,
+ stationPrimaryLoadLabel,
+} from '../utils/combinationMethodProfileUi'
function candidateLine(slot) {
const cands = slot.candidates
@@ -93,10 +98,18 @@ export default function CombinationPlanBracket({
const displayStep = si + 1
const stationTitle = ((slot.title || '').trim() || `Station ${displayStep}`).trim()
const names = candidateLine(slot)
- const timing = effectiveStationTimingSummary(arch, methodProfile || {}, timingByIx.get(stationIx))
+ const slotProfRow = timingByIx.get(stationIx)
+ const loadBadge = stationPrimaryLoadLabel(slotProfRow)
+ const timing = effectiveStationTimingSummary(arch, methodProfile || {}, slotProfRow)
return (
+
+ {loadBadge || '—'}
+
{stationTitle}
{names || '(keine Einzelübung)'}
diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx
index 9493f42..6431ee1 100644
--- a/frontend/src/components/TrainingUnitSectionsEditor.jsx
+++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx
@@ -5,6 +5,7 @@ import CombinationPlanBracket from './CombinationPlanBracket'
import { comboPlanningProfileJsonForEditor, effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
import { combinationArchetypeLabel, sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
import {
+ cloneJsonSerializablePlanningProfile,
comboSlotsOutlineForProfileEditor,
defaultSection,
exerciseRow,
@@ -1246,11 +1247,6 @@ export default function TrainingUnitSectionsEditor({
Archetyp:
{stripArchLbl || stripArchRaw || '—'}
- {stripArchRaw && stripArchLbl && stripArchLbl !== stripArchRaw ? (
-
- ({stripArchRaw})
-
- ) : null}
{compactComboPlanningCaption(it)}
@@ -1628,7 +1624,13 @@ export default function TrainingUnitSectionsEditor({
try {
const obj = JSON.parse(json || '{}')
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
- updateItem(comboPlanningModalSX, comboPlanningModalIX, 'planning_method_profile', obj)
+ const cleaned = cloneJsonSerializablePlanningProfile(obj) ?? {}
+ updateItem(
+ comboPlanningModalSX,
+ comboPlanningModalIX,
+ 'planning_method_profile',
+ Object.keys(cleaned).length ? cleaned : null,
+ )
}
} catch {
/* Ungültiges JSON — Hinweis im Editor */
diff --git a/frontend/src/utils/combinationMethodProfileUi.js b/frontend/src/utils/combinationMethodProfileUi.js
index d5a8b9f..4e145b2 100644
--- a/frontend/src/utils/combinationMethodProfileUi.js
+++ b/frontend/src/utils/combinationMethodProfileUi.js
@@ -322,6 +322,27 @@ export function summarizeSlotProfileBrief(r) {
return bits.join(' · ')
}
+/**
+ * Kompakte Stations-Belastung für die Plan-Klammer (links): Sekunden oder Wdh., nicht Slot-ID.
+ */
+export function stationPrimaryLoadLabel(slotRow) {
+ if (!slotRow || typeof slotRow !== 'object') return null
+ const adv = slotRow.advance_mode || 'timed'
+ if (adv === 'timed') {
+ if (slotRow.load_sec != null) return `${slotRow.load_sec}s`
+ return null
+ }
+ if (adv === 'rep') {
+ if (slotRow.consecutive_reps != null) return `${slotRow.consecutive_reps}×`
+ return null
+ }
+ if (adv === 'manual') {
+ if (slotRow.consecutive_reps != null) return `~${slotRow.consecutive_reps}×`
+ return null
+ }
+ return null
+}
+
function globalTimingHintsForArchetype(arch, mp) {
if (!mp || typeof mp !== 'object' || Array.isArray(mp)) return []
const bits = []
diff --git a/frontend/src/utils/comboPlanningMethodProfile.js b/frontend/src/utils/comboPlanningMethodProfile.js
index 7ff6e7f..e461284 100644
--- a/frontend/src/utils/comboPlanningMethodProfile.js
+++ b/frontend/src/utils/comboPlanningMethodProfile.js
@@ -1,5 +1,28 @@
/** Effektives Ablaufprofil für Kombination im Coach/in der Planung */
+/** Top-Level: null/undefined aus Planungs-Snapshot löschen keine Katalog-Felder (API liefert oft JSON-null). */
+function omitNullUndefinedTop(planObj) {
+ const out = {}
+ for (const [k, v] of Object.entries(planObj)) {
+ if (v === null || v === undefined) continue
+ out[k] = v
+ }
+ return out
+}
+
+/** Je Slot-Zeile: null/undefined aus Planung überschreiben keine Katalog-Werte (z. B. consecutive_reps). */
+function mergeSlotProfileFields(prev, patch) {
+ const base = prev && typeof prev === 'object' ? { ...prev } : {}
+ if (!patch || typeof patch !== 'object') return base
+ for (const [k, v] of Object.entries(patch)) {
+ if (v === null || v === undefined) continue
+ base[k] = v
+ }
+ const ix = Number(base.slot_index)
+ if (Number.isFinite(ix)) base.slot_index = ix
+ return base
+}
+
/**
* Vereinigt slot_profiles_v1 aus Katalog und Planungs-Overlay (je slot_index).
* @param {unknown} catArr
@@ -20,7 +43,7 @@ function mergeSlotProfilesV1(catArr, planArr) {
const ix = Number(r.slot_index)
if (!Number.isFinite(ix)) continue
const prev = byIx.get(ix) || {}
- byIx.set(ix, { ...prev, ...r })
+ byIx.set(ix, mergeSlotProfileFields(prev, r))
}
return [...byIx.entries()].sort((a, b) => a[0] - b[0]).map(([, row]) => row)
}
@@ -34,20 +57,30 @@ export function effectiveComboMethodProfile(catalogDict, planningSnapshot) {
const cat =
catalogDict && typeof catalogDict === 'object' && !Array.isArray(catalogDict) ? { ...catalogDict } : {}
- if (
- planningSnapshot === null ||
- planningSnapshot === undefined ||
- typeof planningSnapshot !== 'object' ||
- Array.isArray(planningSnapshot)
- ) {
+ if (planningSnapshot === null || planningSnapshot === undefined) {
return { ...cat }
}
- const plan = planningSnapshot
- const merged = { ...cat, ...plan }
+ if (typeof planningSnapshot === 'string') {
+ const t = planningSnapshot.trim()
+ if (!t || t === 'null') return { ...cat }
+ try {
+ const p = JSON.parse(t)
+ return effectiveComboMethodProfile(catalogDict, p)
+ } catch {
+ return { ...cat }
+ }
+ }
- if (Object.prototype.hasOwnProperty.call(plan, 'slot_profiles_v1')) {
- merged.slot_profiles_v1 = mergeSlotProfilesV1(cat.slot_profiles_v1, plan.slot_profiles_v1)
+ if (typeof planningSnapshot !== 'object' || Array.isArray(planningSnapshot)) {
+ return { ...cat }
+ }
+
+ const planRaw = planningSnapshot
+ const merged = { ...cat, ...omitNullUndefinedTop(planRaw) }
+
+ if (Object.prototype.hasOwnProperty.call(planRaw, 'slot_profiles_v1')) {
+ merged.slot_profiles_v1 = mergeSlotProfilesV1(cat.slot_profiles_v1, planRaw.slot_profiles_v1)
}
return merged
diff --git a/frontend/src/utils/trainingPlanUtils.js b/frontend/src/utils/trainingPlanUtils.js
index 3e88e59..db4139d 100644
--- a/frontend/src/utils/trainingPlanUtils.js
+++ b/frontend/src/utils/trainingPlanUtils.js
@@ -2,6 +2,7 @@
* Hilfen für Trainingsplan-Ansicht, Coach-Modus und API-Payload für PUT /training-units/:id.
*/
+import { cloneJsonSerializablePlanningProfile } from './trainingUnitSectionsForm'
export function sortedSections(unit) {
const raw = unit?.sections
if (!Array.isArray(raw)) return []
@@ -95,7 +96,10 @@ export function sectionsToPutPayload(unit, durationOverridesByItemId = {}) {
if (isCombo) {
const pmp = it.planning_method_profile
if (pmp != null && typeof pmp === 'object' && !Array.isArray(pmp)) {
- row.planning_method_profile = { ...pmp }
+ const cleaned = cloneJsonSerializablePlanningProfile(pmp)
+ if (cleaned && Object.keys(cleaned).length > 0) {
+ row.planning_method_profile = cleaned
+ }
}
}
return row
diff --git a/frontend/src/utils/trainingUnitSectionsForm.js b/frontend/src/utils/trainingUnitSectionsForm.js
index f0f2be9..ecfacaa 100644
--- a/frontend/src/utils/trainingUnitSectionsForm.js
+++ b/frontend/src/utils/trainingUnitSectionsForm.js
@@ -10,13 +10,34 @@ function normalizeCatalogMethodProfile(cp) {
return {}
}
-/** NULL = Planung folgt Katalogprofil der Übung */
+/** NULL = Planung folgt Katalogprofil der Übung (Reihenfolge beibehalten: zuerst String-JSON auflösen). */
function normalizePlanningMethodProfile(pm) {
if (pm == null) return null
+ if (typeof pm === 'string') {
+ const t = pm.trim()
+ if (!t || t === 'null') return null
+ try {
+ const p = JSON.parse(t)
+ if (p && typeof p === 'object' && !Array.isArray(p)) return { ...p }
+ return null
+ } catch {
+ return null
+ }
+ }
if (typeof pm === 'object' && !Array.isArray(pm)) return { ...pm }
return null
}
+/** Reines JSON für PUT /training-units (vermeidet nicht serialisierbare Werte → 500). */
+export function cloneJsonSerializablePlanningProfile(obj) {
+ if (obj == null || typeof obj !== 'object' || Array.isArray(obj)) return null
+ try {
+ return JSON.parse(JSON.stringify(obj))
+ } catch {
+ return {}
+ }
+}
+
export function exerciseRow() {
return {
item_type: 'exercise',
@@ -414,7 +435,10 @@ export function buildSectionsPayload(sections) {
if (isCombo) {
const pmp = it.planning_method_profile
if (pmp != null && typeof pmp === 'object' && !Array.isArray(pmp)) {
- rowEx.planning_method_profile = { ...pmp }
+ const cleaned = cloneJsonSerializablePlanningProfile(pmp)
+ if (cleaned && Object.keys(cleaned).length > 0) {
+ rowEx.planning_method_profile = cleaned
+ }
}
}
if (smEx != null) rowEx.source_training_module_id = smEx