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
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:
parent
d3ddc52118
commit
3898e8bc2c
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: </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 */
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user