From 3dc4c9c79ea1d090f724fddbaf1d7c1189d75ba9 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 13 May 2026 06:30:53 +0200 Subject: [PATCH] feat(exercises): update to version 0.8.100 and enhance combination exercise handling - Bumped app version to 0.8.100, reflecting recent updates. - Improved validation logic for combination exercises in the backend, ensuring proper handling of exercise variants. - Enhanced frontend components, including the ExercisePickerModal, to support filtering and displaying combination exercises. - Updated API payloads and utility functions to accommodate new exercise types and their properties. Co-Authored-By: Claude Sonnet 4.6 --- backend/routers/training_planning.py | 25 +++- backend/version.py | 11 +- .../src/components/ExercisePickerModal.jsx | 25 +++- .../ExerciseProgressionGraphPanel.jsx | 7 +- .../components/TrainingUnitSectionsEditor.jsx | 18 ++- frontend/src/utils/trainingPlanUtils.js | 4 +- .../src/utils/trainingUnitSectionsForm.js | 126 ++++++++++++------ 7 files changed, 162 insertions(+), 54 deletions(-) diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index 4294d5d..a6ace71 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -40,12 +40,28 @@ def _optional_positive_int(val, field_name: str) -> Optional[int]: def _validate_variant_for_exercise(cur, exercise_id: Optional[int], variant_id: Optional[int]): + if not exercise_id: + if variant_id: + raise HTTPException( + status_code=400, detail="exercise_variant_id nur zusammen mit exercise_id erlaubt" + ) + return + cur.execute( + "SELECT COALESCE(exercise_kind, 'simple') AS exercise_kind FROM exercises WHERE id = %s", + (int(exercise_id),), + ) + ek_row = cur.fetchone() + if not ek_row: + raise HTTPException(status_code=400, detail="Übung nicht gefunden") + if str(r2d(ek_row).get("exercise_kind") or "simple").strip().lower() == "combination": + if variant_id: + raise HTTPException( + status_code=400, + detail="Kombinationsübungen haben keine Varianten — bitte exercise_variant_id weglassen", + ) + return if not variant_id: return - if not exercise_id: - raise HTTPException( - status_code=400, detail="exercise_variant_id nur zusammen mit exercise_id erlaubt" - ) cur.execute( "SELECT 1 FROM exercise_variants WHERE id = %s AND exercise_id = %s", (variant_id, exercise_id), @@ -434,6 +450,7 @@ def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]: """ SELECT tusi.*, e.title AS exercise_title, + e.exercise_kind AS exercise_kind, e.summary AS exercise_summary, ( SELECT fa.name FROM exercise_focus_areas efa diff --git a/backend/version.py b/backend/version.py index 4012a16..9f8d5b5 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.99" +APP_VERSION = "0.8.100" BUILD_DATE = "2026-05-12" DB_SCHEMA_VERSION = "20260512056" @@ -24,7 +24,7 @@ MODULE_VERSIONS = { "exercises": "2.24.0", # Phase 2: Kombinationsübungen exercise_kind/combination_slots + Archetyp/Profil (Migration 056) "training_units": "0.2.0", "training_programs": "0.1.0", - "planning": "0.9.0", # apply-training-module; Trainingsmodule-Bibliothek (Phase 1) + "planning": "0.9.1", # Kombinationsübungen: Sektionen PATCH/validator + exercise_kind GET; Frontend KEINE Varianten bei combination "training_modules": "1.0.0", "import_wiki": "1.0.0", "admin": "1.0.0", @@ -35,6 +35,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.100", + "date": "2026-05-12", + "changes": [ + "Planungs-API/UI: Kombinationsübungen in Trainingsseinheiten (exercise_kind in Sektions-Responses; PATCH verbietet exercise_variant_id für combination); ExercisePicker ohne simple-only Filter, Badge Kombination.", + ], + }, { "version": "0.8.99", "date": "2026-05-12", diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx index f2fb54d..e93a896 100644 --- a/frontend/src/components/ExercisePickerModal.jsx +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -32,6 +32,8 @@ export default function ExercisePickerModal({ multiSelect = false, onSelectExercises = null, enableQuickCreateDraft = false, + /** Wenn gesetzt: z. B. ['simple'] oder ['combination'] — sonst alle Übungsarten */ + exerciseKindAny = undefined, }) { const { user } = useAuth() const [catalogs, setCatalogs] = useState({ @@ -213,8 +215,14 @@ export default function ExercisePickerModal({ if (filters.include_archived) q.include_archived = true if (debouncedSearch) q.search = debouncedSearch if (debouncedAi) q.ai_search = debouncedAi + if ( + Array.isArray(exerciseKindAny) && + exerciseKindAny.length > 0 + ) { + q.exercise_kind_any = exerciseKindAny + } return q - }, [filters, debouncedSearch, debouncedAi]) + }, [filters, debouncedSearch, debouncedAi, exerciseKindAny]) const reload = useCallback(async () => { if (!open || !catalogsReady) return @@ -225,7 +233,6 @@ export default function ExercisePickerModal({ ...queryBase, include_archived: true, include_variants: true, - exercise_kind_any: ['simple'], limit: PAGE_SIZE, offset: 0, }) @@ -254,7 +261,6 @@ export default function ExercisePickerModal({ ...queryBase, include_archived: true, include_variants: true, - exercise_kind_any: ['simple'], limit: PAGE_SIZE, offset, }) @@ -608,6 +614,19 @@ export default function ExercisePickerModal({ {ex.focus_area} )} + {(ex.exercise_kind || '').toLowerCase().trim() === 'combination' ? ( + + Kombination + + ) : null} ) if (multiSelect) { diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx index f9ee3fd..2100409 100644 --- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx +++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx @@ -1055,7 +1055,12 @@ export default function ExerciseProgressionGraphPanel({ )} - setPickContext(null)} onSelectExercise={applyPickedExercise} /> + setPickContext(null)} + onSelectExercise={applyPickedExercise} + exerciseKindAny={['simple']} + /> ) } diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx index bd812c7..7db3da4 100644 --- a/frontend/src/components/TrainingUnitSectionsEditor.jsx +++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx @@ -837,9 +837,11 @@ export default function TrainingUnitSectionsEditor({ const variantOpts = Array.isArray(it.variants) ? it.variants : [] const exTitle = it.exercise_title || (it.exercise_id ? `Übung #${it.exercise_id}` : '') + const isCombination = + String(it.exercise_kind || 'simple').toLowerCase().trim() === 'combination' const annotPrev = truncatePreview(it.notes || '', 220) const annotHasText = Boolean((it.notes || '').trim()) - const hasVariants = variantOpts.length > 0 && it.exercise_id + const hasVariants = !isCombination && variantOpts.length > 0 && it.exercise_id const variantIdPeek = it.exercise_variant_id === '' || it.exercise_variant_id == null ? undefined @@ -893,6 +895,20 @@ export default function TrainingUnitSectionsEditor({ ) : ( Keine Übung gewählt )} + {isCombination ? ( + + Kombination + + ) : null} {planningCompactLegend && curMn ? ( ) : null} diff --git a/frontend/src/utils/trainingPlanUtils.js b/frontend/src/utils/trainingPlanUtils.js index 30e896e..a81e380 100644 --- a/frontend/src/utils/trainingPlanUtils.js +++ b/frontend/src/utils/trainingPlanUtils.js @@ -71,7 +71,9 @@ export function sectionsToPutPayload(unit, durationOverridesByItemId = {}) { if (eid === '' || eid == null || Number.isNaN(Number(eid))) { return null } - const vid = it.exercise_variant_id + const isCombo = + String(it.exercise_kind || 'simple').toLowerCase().trim() === 'combination' + const vid = isCombo ? null : it.exercise_variant_id let actual = durationOverridesByItemId[String(it.id)]?.actual_duration_min ?? it.actual_duration_min diff --git a/frontend/src/utils/trainingUnitSectionsForm.js b/frontend/src/utils/trainingUnitSectionsForm.js index f99851e..e9d2773 100644 --- a/frontend/src/utils/trainingUnitSectionsForm.js +++ b/frontend/src/utils/trainingUnitSectionsForm.js @@ -9,6 +9,7 @@ export function exerciseRow() { item_type: 'exercise', exercise_id: '', exercise_variant_id: '', + exercise_kind: 'simple', exercise_title: '', variants: [], planned_duration_min: '', @@ -23,22 +24,31 @@ export function exerciseRow() { export async function hydrateExercisePlanningRow(exercise) { let variants = Array.isArray(exercise?.variants) ? exercise.variants : [] let title = exercise?.title || '' + let exerciseKind = exercise?.exercise_kind const id = exercise?.id if (!id) return null let meta = {} - if (!variants.length) { + + async function fetchFull() { try { - const full = await api.getExercise(id) - variants = Array.isArray(full?.variants) ? full.variants : [] - title = full?.title || title - meta = { - exercise_visibility: full?.visibility || 'private', - exercise_club_id: full?.club_id ?? null, - exercise_created_by: full?.created_by ?? null, - exercise_status: full?.status || 'draft', - } + return await api.getExercise(id) } catch { - variants = [] + return null + } + } + + if (!variants.length) { + const full = await fetchFull() + if (full) { + variants = Array.isArray(full.variants) ? full.variants : [] + title = full.title || title + if (exerciseKind == null) exerciseKind = full.exercise_kind + meta = { + exercise_visibility: full.visibility || 'private', + exercise_club_id: full.club_id ?? null, + exercise_created_by: full.created_by ?? null, + exercise_status: full.status || 'draft', + } } } else { meta = { @@ -47,25 +57,37 @@ export async function hydrateExercisePlanningRow(exercise) { exercise_created_by: exercise?.created_by ?? null, exercise_status: exercise?.status ?? null, } - if (meta.exercise_visibility == null || meta.exercise_created_by == null) { - try { - const full = await api.getExercise(id) - if (meta.exercise_visibility == null) meta.exercise_visibility = full?.visibility || 'private' - if (meta.exercise_club_id == null) meta.exercise_club_id = full?.club_id ?? null - if (meta.exercise_created_by == null) meta.exercise_created_by = full?.created_by ?? null - if (meta.exercise_status == null) meta.exercise_status = full?.status || 'draft' - } catch { - /* keep partial meta */ + if ( + meta.exercise_visibility == null || + meta.exercise_created_by == null || + exerciseKind == null + ) { + const full = await fetchFull() + if (full) { + if (meta.exercise_visibility == null) meta.exercise_visibility = full.visibility || 'private' + if (meta.exercise_club_id == null) meta.exercise_club_id = full.club_id ?? null + if (meta.exercise_created_by == null) meta.exercise_created_by = full.created_by ?? null + if (meta.exercise_status == null) meta.exercise_status = full.status || 'draft' + if (exerciseKind == null) exerciseKind = full.exercise_kind + if (!variants.length) variants = Array.isArray(full.variants) ? full.variants : [] } } meta.exercise_visibility = meta.exercise_visibility || 'private' meta.exercise_status = meta.exercise_status || 'draft' } + const row = exerciseRow() row.exercise_id = id row.exercise_variant_id = '' row.exercise_title = title - row.variants = variants + row.exercise_kind = + String(exerciseKind || 'simple').toLowerCase().trim() === 'combination' ? 'combination' : 'simple' + if (row.exercise_kind === 'combination') { + row.variants = [] + row.exercise_variant_id = '' + } else { + row.variants = variants + } Object.assign(row, meta) return row } @@ -106,10 +128,13 @@ export function normalizeUnitToForm(fullUnit) { return rowNote } const smEx = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id) + const ek = String(it.exercise_kind || 'simple').toLowerCase().trim() + const isCombo = ek === 'combination' return { item_type: 'exercise', exercise_id: it.exercise_id, - exercise_variant_id: it.exercise_variant_id ?? '', + exercise_kind: isCombo ? 'combination' : 'simple', + exercise_variant_id: isCombo ? '' : it.exercise_variant_id ?? '', exercise_title: it.exercise_title || '', variants: [], planned_duration_min: @@ -141,23 +166,28 @@ export function normalizeUnitToForm(fullUnit) { { title: 'Übungen', guidance_notes: '', - items: fullUnit.exercises.map((ex) => ({ - item_type: 'exercise', - exercise_id: ex.exercise_id, - exercise_variant_id: ex.exercise_variant_id ?? '', - exercise_title: ex.exercise_title || '', - variants: [], - planned_duration_min: - ex.planned_duration_min !== null && ex.planned_duration_min !== undefined - ? String(ex.planned_duration_min) - : '', - actual_duration_min: - ex.actual_duration_min !== null && ex.actual_duration_min !== undefined - ? String(ex.actual_duration_min) - : '', - notes: ex.notes ?? '', - modifications: ex.modifications ?? '', - })), + items: fullUnit.exercises.map((ex) => { + const ek = String(ex.exercise_kind || 'simple').toLowerCase().trim() + const isCombo = ek === 'combination' + return { + item_type: 'exercise', + exercise_kind: ek, + exercise_id: ex.exercise_id, + exercise_variant_id: isCombo ? '' : (ex.exercise_variant_id ?? ''), + exercise_title: ex.exercise_title || '', + variants: [], + planned_duration_min: + ex.planned_duration_min !== null && ex.planned_duration_min !== undefined + ? String(ex.planned_duration_min) + : '', + actual_duration_min: + ex.actual_duration_min !== null && ex.actual_duration_min !== undefined + ? String(ex.actual_duration_min) + : '', + notes: ex.notes ?? '', + modifications: ex.modifications ?? '', + } + }), }, ] } @@ -181,6 +211,7 @@ export async function enrichSectionsWithVariants(sections) { const ex = await api.getExercise(id) cache.set(id, { title: ex.title || '', + exercise_kind: String(ex.exercise_kind || 'simple').toLowerCase().trim(), variants: Array.isArray(ex.variants) ? ex.variants : [], visibility: ex.visibility || 'private', club_id: ex.club_id ?? null, @@ -190,6 +221,7 @@ export async function enrichSectionsWithVariants(sections) { } catch { cache.set(id, { title: '', + exercise_kind: 'simple', variants: [], visibility: 'private', club_id: null, @@ -206,11 +238,15 @@ export async function enrichSectionsWithVariants(sections) { if (!it.exercise_id) return it const c = cache.get(it.exercise_id) if (!c) return it + const ek = String(c.exercise_kind || 'simple').toLowerCase().trim() + const isCombo = ek === 'combination' return { ...it, + exercise_kind: isCombo ? 'combination' : 'simple', exercise_title: it.exercise_title || c.title, + exercise_variant_id: isCombo ? '' : it.exercise_variant_id, variants: - Array.isArray(it.variants) && it.variants.length > 0 ? it.variants : c.variants, + isCombo ? [] : Array.isArray(it.variants) && it.variants.length > 0 ? it.variants : c.variants, exercise_visibility: c.visibility, exercise_club_id: c.club_id, exercise_created_by: c.created_by, @@ -246,7 +282,8 @@ export function buildSectionsPayload(sections) { if (it.exercise_id === '' || it.exercise_id == null || Number.isNaN(Number(it.exercise_id))) { return null } - const vid = it.exercise_variant_id + const isCombo = String(it.exercise_kind || 'simple').toLowerCase().trim() === 'combination' + const vid = isCombo ? null : it.exercise_variant_id const smEx = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id) const rowEx = { item_type: 'exercise', @@ -320,7 +357,12 @@ export async function insertTrainingModuleIntoPlanningSections({ if (!hydrated) continue hydrated.source_training_module_id = midNum hydrated.source_module_title = modTitle - if (mi.exercise_variant_id) hydrated.exercise_variant_id = String(mi.exercise_variant_id) + if ( + hydrated.exercise_kind !== 'combination' && + mi.exercise_variant_id + ) { + hydrated.exercise_variant_id = String(mi.exercise_variant_id) + } hydrated.planned_duration_min = mi.planned_duration_min !== null && mi.planned_duration_min !== undefined ? String(mi.planned_duration_min)