feat(training-planning): enhance planning method profile handling and UI updates
All checks were successful
Deploy Development / deploy (push) Successful in 38s
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 59s

- Integrated PsycopgJson for improved handling of planning method profiles in the backend.
- Updated CombinationPlanBracket to display primary load labels for better clarity in the UI.
- Enhanced TrainingUnitSectionsEditor and utility functions to ensure proper serialization of planning profiles, preventing potential errors during API interactions.
- Improved CSS for combo plan brackets to enhance visual alignment and presentation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-05-13 15:28:37 +02:00
parent d3ddc52118
commit 3898e8bc2c
8 changed files with 146 additions and 24 deletions

View File

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

View File

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

View File

@ -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 (
<li key={`slot-${stationIx}-${si}`} className="combo-plan-bracket__station">
<div
className="combo-plan-bracket__station-load"
title={loadBadge ? 'Belastung je Station (Sekunden oder Wiederholungen)' : undefined}
>
{loadBadge || '—'}
</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

@ -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({
<strong style={{ color: 'var(--text1)', fontWeight: 600 }}>Archetyp:&nbsp;</strong>
<span style={{ color: 'var(--text1)' }}>
{stripArchLbl || stripArchRaw || '—'}
{stripArchRaw && stripArchLbl && stripArchLbl !== stripArchRaw ? (
<span style={{ marginLeft: 6, fontWeight: 400, color: 'var(--text3)', fontSize: '0.72rem' }}>
({stripArchRaw})
</span>
) : null}
</span>
<span style={{ marginLeft: 10, fontWeight: 500 }}>{compactComboPlanningCaption(it)}</span>
</div>
@ -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 */

View File

@ -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 = []

View File

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

View File

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

View File

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