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 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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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: </strong>
|
<strong style={{ color: 'var(--text1)', fontWeight: 600 }}>Archetyp: </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 */
|
||||||
|
|
|
||||||
|
|
@ -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 = []
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user