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 typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from psycopg2.extras import Json as PsycopgJson
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql 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")) planning_mp = _normalize_planning_method_profile_payload(raw.get("planning_method_profile"))
if ek != "combination": if ek != "combination":
planning_mp = None 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")) src_mod = _optional_source_training_module_id_payload(raw.get("source_training_module_id"))
cur.execute( 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("notes"),
raw.get("modifications"), raw.get("modifications"),
src_mod, src_mod,
planning_mp, planning_sql_val,
), ),
) )

View File

@ -6327,13 +6327,31 @@ a.analysis-split__nav-item {
gap: 10px; gap: 10px;
} }
.combo-plan-bracket__station { .combo-plan-bracket__station {
display: block; display: flex;
gap: 10px;
align-items: flex-start;
padding: 10px 10px; padding: 10px 10px;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--surface2); 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 { .combo-plan-bracket__station-main {
flex: 1;
min-width: 0; min-width: 0;
} }
.combo-plan-bracket__station-title { .combo-plan-bracket__station-title {
@ -6447,6 +6465,11 @@ a.analysis-split__nav-item {
border-color: #444 !important; border-color: #444 !important;
background: #f4f6f8 !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 */ /* Coach — volle Übung, Nur-Mittelbereich scrollt; Steuerung oben/unten sichtbar */

View File

@ -7,7 +7,12 @@ import {
combinationArchetypeLabel, combinationArchetypeLabel,
sortCombinationSlotsForDisplay, sortCombinationSlotsForDisplay,
} from '../constants/combinationArchetypes' } from '../constants/combinationArchetypes'
import { describeGlobalComboProfile, effectiveStationTimingSummary, readSlotProfilesV1 } from '../utils/combinationMethodProfileUi' import {
describeGlobalComboProfile,
effectiveStationTimingSummary,
readSlotProfilesV1,
stationPrimaryLoadLabel,
} from '../utils/combinationMethodProfileUi'
function candidateLine(slot) { function candidateLine(slot) {
const cands = slot.candidates const cands = slot.candidates
@ -93,10 +98,18 @@ export default function CombinationPlanBracket({
const displayStep = si + 1 const displayStep = si + 1
const stationTitle = ((slot.title || '').trim() || `Station ${displayStep}`).trim() const stationTitle = ((slot.title || '').trim() || `Station ${displayStep}`).trim()
const names = candidateLine(slot) 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 ( return (
<li key={`slot-${stationIx}-${si}`} className="combo-plan-bracket__station"> <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-main">
<div className="combo-plan-bracket__station-title">{stationTitle}</div> <div className="combo-plan-bracket__station-title">{stationTitle}</div>
<div className="combo-plan-bracket__station-exercises">{names || '(keine Einzelübung)'}</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 { comboPlanningProfileJsonForEditor, effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
import { combinationArchetypeLabel, sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes' import { combinationArchetypeLabel, sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
import { import {
cloneJsonSerializablePlanningProfile,
comboSlotsOutlineForProfileEditor, comboSlotsOutlineForProfileEditor,
defaultSection, defaultSection,
exerciseRow, exerciseRow,
@ -1246,11 +1247,6 @@ export default function TrainingUnitSectionsEditor({
<strong style={{ color: 'var(--text1)', fontWeight: 600 }}>Archetyp:&nbsp;</strong> <strong style={{ color: 'var(--text1)', fontWeight: 600 }}>Archetyp:&nbsp;</strong>
<span style={{ color: 'var(--text1)' }}> <span style={{ color: 'var(--text1)' }}>
{stripArchLbl || stripArchRaw || '—'} {stripArchLbl || stripArchRaw || '—'}
{stripArchRaw && stripArchLbl && stripArchLbl !== stripArchRaw ? (
<span style={{ marginLeft: 6, fontWeight: 400, color: 'var(--text3)', fontSize: '0.72rem' }}>
({stripArchRaw})
</span>
) : null}
</span> </span>
<span style={{ marginLeft: 10, fontWeight: 500 }}>{compactComboPlanningCaption(it)}</span> <span style={{ marginLeft: 10, fontWeight: 500 }}>{compactComboPlanningCaption(it)}</span>
</div> </div>
@ -1628,7 +1624,13 @@ export default function TrainingUnitSectionsEditor({
try { try {
const obj = JSON.parse(json || '{}') const obj = JSON.parse(json || '{}')
if (obj && typeof obj === 'object' && !Array.isArray(obj)) { 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 { } catch {
/* Ungültiges JSON — Hinweis im Editor */ /* Ungültiges JSON — Hinweis im Editor */

View File

@ -322,6 +322,27 @@ export function summarizeSlotProfileBrief(r) {
return bits.join(' · ') 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) { function globalTimingHintsForArchetype(arch, mp) {
if (!mp || typeof mp !== 'object' || Array.isArray(mp)) return [] if (!mp || typeof mp !== 'object' || Array.isArray(mp)) return []
const bits = [] const bits = []

View File

@ -1,5 +1,28 @@
/** Effektives Ablaufprofil für Kombination im Coach/in der Planung */ /** 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). * Vereinigt slot_profiles_v1 aus Katalog und Planungs-Overlay (je slot_index).
* @param {unknown} catArr * @param {unknown} catArr
@ -20,7 +43,7 @@ function mergeSlotProfilesV1(catArr, planArr) {
const ix = Number(r.slot_index) const ix = Number(r.slot_index)
if (!Number.isFinite(ix)) continue if (!Number.isFinite(ix)) continue
const prev = byIx.get(ix) || {} 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) return [...byIx.entries()].sort((a, b) => a[0] - b[0]).map(([, row]) => row)
} }
@ -34,20 +57,30 @@ export function effectiveComboMethodProfile(catalogDict, planningSnapshot) {
const cat = const cat =
catalogDict && typeof catalogDict === 'object' && !Array.isArray(catalogDict) ? { ...catalogDict } : {} catalogDict && typeof catalogDict === 'object' && !Array.isArray(catalogDict) ? { ...catalogDict } : {}
if ( if (planningSnapshot === null || planningSnapshot === undefined) {
planningSnapshot === null ||
planningSnapshot === undefined ||
typeof planningSnapshot !== 'object' ||
Array.isArray(planningSnapshot)
) {
return { ...cat } return { ...cat }
} }
const plan = planningSnapshot if (typeof planningSnapshot === 'string') {
const merged = { ...cat, ...plan } 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')) { if (typeof planningSnapshot !== 'object' || Array.isArray(planningSnapshot)) {
merged.slot_profiles_v1 = mergeSlotProfilesV1(cat.slot_profiles_v1, plan.slot_profiles_v1) 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 return merged

View File

@ -2,6 +2,7 @@
* Hilfen für Trainingsplan-Ansicht, Coach-Modus und API-Payload für PUT /training-units/:id. * Hilfen für Trainingsplan-Ansicht, Coach-Modus und API-Payload für PUT /training-units/:id.
*/ */
import { cloneJsonSerializablePlanningProfile } from './trainingUnitSectionsForm'
export function sortedSections(unit) { export function sortedSections(unit) {
const raw = unit?.sections const raw = unit?.sections
if (!Array.isArray(raw)) return [] if (!Array.isArray(raw)) return []
@ -95,7 +96,10 @@ export function sectionsToPutPayload(unit, durationOverridesByItemId = {}) {
if (isCombo) { if (isCombo) {
const pmp = it.planning_method_profile const pmp = it.planning_method_profile
if (pmp != null && typeof pmp === 'object' && !Array.isArray(pmp)) { 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 return row

View File

@ -10,13 +10,34 @@ function normalizeCatalogMethodProfile(cp) {
return {} return {}
} }
/** NULL = Planung folgt Katalogprofil der Übung */ /** NULL = Planung folgt Katalogprofil der Übung (Reihenfolge beibehalten: zuerst String-JSON auflösen). */
function normalizePlanningMethodProfile(pm) { function normalizePlanningMethodProfile(pm) {
if (pm == null) return null 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 } if (typeof pm === 'object' && !Array.isArray(pm)) return { ...pm }
return null 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() { export function exerciseRow() {
return { return {
item_type: 'exercise', item_type: 'exercise',
@ -414,7 +435,10 @@ export function buildSectionsPayload(sections) {
if (isCombo) { if (isCombo) {
const pmp = it.planning_method_profile const pmp = it.planning_method_profile
if (pmp != null && typeof pmp === 'object' && !Array.isArray(pmp)) { 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 if (smEx != null) rowEx.source_training_module_id = smEx