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