From 4e654e50c0a73c989b71872b337a2ddab1ffca30 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 13 May 2026 07:21:33 +0200 Subject: [PATCH] feat(version): bump to 0.8.103 and enhance planning method profile integration - Updated app version to 0.8.103, reflecting recent enhancements in training planning. - Incremented database schema version to 20260512057, ensuring compatibility with new features. - Introduced optional `planning_method_profile` for combination exercises, allowing for detailed planning and coaching support. - Enhanced frontend components to manage and display planning method profiles effectively in the Training Unit Sections Editor and ExerciseFullContent. Co-Authored-By: Claude Sonnet 4.6 --- ..._planning_method_profile_section_items.sql | 8 +++ backend/routers/training_planning.py | 44 ++++++++++-- backend/version.py | 13 +++- .../src/components/ExerciseFullContent.jsx | 11 ++- .../components/TrainingUnitSectionsEditor.jsx | 68 +++++++++++++++++++ frontend/src/pages/TrainingCoachPage.jsx | 5 ++ .../src/utils/comboPlanningMethodProfile.js | 26 +++++++ frontend/src/utils/trainingPlanUtils.js | 9 ++- .../src/utils/trainingUnitSectionsForm.js | 66 ++++++++++++++++-- 9 files changed, 233 insertions(+), 17 deletions(-) create mode 100644 backend/migrations/057_planning_method_profile_section_items.sql create mode 100644 frontend/src/utils/comboPlanningMethodProfile.js diff --git a/backend/migrations/057_planning_method_profile_section_items.sql b/backend/migrations/057_planning_method_profile_section_items.sql new file mode 100644 index 0000000..865df43 --- /dev/null +++ b/backend/migrations/057_planning_method_profile_section_items.sql @@ -0,0 +1,8 @@ +-- 057: Terminspezifisches Ablaufprofil fuer Kombinationsuebungen in der Planung +-- NULL = method_profile vom Katalog (exercises) verwenden; sonst dieser JSONB-Stand gilt fuer diese Platzierung. + +ALTER TABLE training_unit_section_items + ADD COLUMN IF NOT EXISTS planning_method_profile JSONB NULL; + +COMMENT ON COLUMN training_unit_section_items.planning_method_profile IS + 'Snapshots des Ablaufprofils fuer diese Einheit/Zeile; NULL = exercises.method_profile.'; diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index a6ace71..ff0172e 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -407,6 +407,18 @@ def _normalize_assistant_trainer_profile_ids( ) return uniq +def _normalize_planning_method_profile_payload(raw) -> Optional[Dict[str, Any]]: + """None = Katalog wirksam; Dict = Snapshot fuer diese Platzierung.""" + if raw is None: + return None + if isinstance(raw, dict): + return dict(raw) + raise HTTPException( + status_code=400, + detail="planning_method_profile muss ein JSON-Objekt oder null sein", + ) + + # Nachverfolgbarkeit: Übernahmen aus Rahmenprogramm über origin_framework_slot_id _ORIGIN_LINEAGE_JOIN = """ LEFT JOIN training_framework_slots origin_slot ON origin_slot.id = tu.origin_framework_slot_id @@ -452,6 +464,8 @@ def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]: e.title AS exercise_title, e.exercise_kind AS exercise_kind, e.summary AS exercise_summary, + e.method_archetype AS catalog_method_archetype, + e.method_profile AS catalog_method_profile, ( SELECT fa.name FROM exercise_focus_areas efa JOIN focus_areas fa ON fa.id = efa.focus_area_id @@ -471,6 +485,14 @@ def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]: (sec["id"],), ) sec["items"] = [r2d(r) for r in cur.fetchall()] + for it in sec["items"]: + if it.get("item_type") != "exercise": + continue + cmp_raw = it.get("catalog_method_profile") + if not isinstance(cmp_raw, dict): + it["catalog_method_profile"] = {} + else: + it["catalog_method_profile"] = dict(cmp_raw) secs.append(sec) return secs @@ -506,6 +528,7 @@ def _sections_clone_payload(cur, unit_id: int) -> List[Dict[str, Any]]: "actual_duration_min": it.get("actual_duration_min"), "notes": it.get("notes"), "modifications": it.get("modifications"), + "planning_method_profile": it.get("planning_method_profile"), } sm = _optional_source_training_module_id_payload(it.get("source_training_module_id")) if sm is not None: @@ -676,9 +699,10 @@ def _append_copied_module_items_to_section( section_id, order_index, item_type, exercise_id, exercise_variant_id, planned_duration_min, actual_duration_min, - notes, modifications, note_body, source_training_module_id + notes, modifications, note_body, + source_training_module_id, planning_method_profile ) VALUES (%s, %s, 'exercise', - %s, %s, %s, NULL, %s, NULL, NULL, %s) + %s, %s, %s, NULL, %s, NULL, NULL, %s, NULL) """, ( section_id, @@ -728,6 +752,15 @@ def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], s eid = int(eid) vid = _optional_positive_int(raw.get("exercise_variant_id"), "exercise_variant_id") _validate_variant_for_exercise(cur, eid, vid) + cur.execute( + """SELECT COALESCE(exercise_kind, 'simple') AS k FROM exercises WHERE id = %s""", + (eid,), + ) + er = cur.fetchone() + ek = str(er["k"] if er and er.get("k") is not None else "simple").strip().lower() + planning_mp = _normalize_planning_method_profile_payload(raw.get("planning_method_profile")) + if ek != "combination": + planning_mp = None src_mod = _optional_source_training_module_id_payload(raw.get("source_training_module_id")) cur.execute( """ @@ -735,10 +768,10 @@ def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], s section_id, order_index, item_type, exercise_id, exercise_variant_id, planned_duration_min, actual_duration_min, - notes, modifications, note_body, source_training_module_id + notes, modifications, note_body, + source_training_module_id, planning_method_profile ) VALUES (%s, %s, 'exercise', - %s, %s, %s, %s, %s, %s, NULL, %s - ) + %s, %s, %s, %s, %s, %s, NULL, %s, %s) """, ( section_id, @@ -750,6 +783,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, ), ) diff --git a/backend/version.py b/backend/version.py index 74e6431..ae392af 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.102" +APP_VERSION = "0.8.103" BUILD_DATE = "2026-05-12" -DB_SCHEMA_VERSION = "20260512056" +DB_SCHEMA_VERSION = "20260512057" MODULE_VERSIONS = { "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) @@ -24,7 +24,7 @@ MODULE_VERSIONS = { "exercises": "2.24.2", # Kombi: geführtes method_profile im Übungsformular nach Archetyp + Coach zeigt Profil als Key/Wert "training_units": "0.2.0", "training_programs": "0.1.0", - "planning": "0.9.1", # Kombinationsübungen: Sektionen PATCH/validator + exercise_kind GET; Frontend KEINE Varianten bei combination + "planning": "0.9.2", # Kombi: planning_method_profile auf Sektions-Items (Migration 057); Form-Payload + Coach-PUT "training_modules": "1.0.0", "import_wiki": "1.0.0", "admin": "1.0.0", @@ -35,6 +35,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.103", + "date": "2026-05-12", + "changes": [ + "Trainingsplanung: bei Kombinationszeilen optionales `planning_method_profile` (Migration 057); Planungs-Editor mit Ablaufprofil-Details, „wie Katalog“ / „aus Katalog kopieren“; Payload/Coach-PUT übernehmen Snapshot.", + ], + }, { "version": "0.8.102", "date": "2026-05-12", diff --git a/frontend/src/components/ExerciseFullContent.jsx b/frontend/src/components/ExerciseFullContent.jsx index cc5e3a5..2d4ef6e 100644 --- a/frontend/src/components/ExerciseFullContent.jsx +++ b/frontend/src/components/ExerciseFullContent.jsx @@ -6,6 +6,7 @@ import React from 'react' import { Link } from 'react-router-dom' import ExerciseRichTextBlock from './ExerciseRichTextBlock' import CombinationCoachSlots from './CombinationCoachSlots' +import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile' function TagRow({ exercise }) { const tags = [] @@ -53,9 +54,9 @@ function metaParts(exercise) { } /** - * @param {{ exercise?: object|null, loading?: boolean, error?: string|null, exerciseId?: number, variantId?: number|string|null }} props + * @param {{ exercise?: object|null, loading?: boolean, error?: string|null, exerciseId?: number, variantId?: number|string|null, planningComboMethodProfile?: object|null }} props */ -export default function ExerciseFullContent({ exercise, loading, error, exerciseId, variantId }) { +export default function ExerciseFullContent({ exercise, loading, error, exerciseId, variantId, planningComboMethodProfile }) { if (loading) { return (
@@ -80,6 +81,10 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise const isCombination = String(exercise.exercise_kind || 'simple').toLowerCase().trim() === 'combination' + const coachComboProfile = isCombination + ? effectiveComboMethodProfile(exercise.method_profile, planningComboMethodProfile) + : null + return (
{variant ? ( @@ -114,7 +119,7 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise ) : null}

{exercise.title}

diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx index 7db3da4..6ea4963 100644 --- a/frontend/src/components/TrainingUnitSectionsEditor.jsx +++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx @@ -1,5 +1,7 @@ import React, { Fragment, useCallback, useEffect, useState } from 'react' import { GripVertical, Pencil } from 'lucide-react' +import CombinationMethodProfileEditor from './CombinationMethodProfileEditor' +import { comboPlanningProfileJsonForEditor } from '../utils/comboPlanningMethodProfile' import { defaultSection, exerciseRow, @@ -1019,6 +1021,72 @@ export default function TrainingUnitSectionsEditor({
+ {isCombination && it.exercise_id ? ( +
+
+ + Ablaufprofil für diese Planung (Kombination) + + {it.planning_method_profile != null && + typeof it.planning_method_profile === 'object' && + !Array.isArray(it.planning_method_profile) + ? '— Anpassung aktiv' + : '— wie im Katalog'} + + +
+
+ + +
+ { + try { + const obj = JSON.parse(json || '{}') + if (obj && typeof obj === 'object' && !Array.isArray(obj)) { + updateItem(sIdx, iIdx, 'planning_method_profile', obj) + } + } catch { + /* Ungültiges JSON — Hinweis im Editor */ + } + }} + /> +
+
+
+ ) : null} + {showExecutionExtras ? (
diff --git a/frontend/src/pages/TrainingCoachPage.jsx b/frontend/src/pages/TrainingCoachPage.jsx index 56a75c4..ad4b8b5 100644 --- a/frontend/src/pages/TrainingCoachPage.jsx +++ b/frontend/src/pages/TrainingCoachPage.jsx @@ -740,6 +740,11 @@ export default function TrainingCoachPage() { exercise={catalogExercise} exerciseId={currentEntry?.item?.exercise_id ?? null} variantId={currentEntry?.item?.exercise_variant_id ?? null} + planningComboMethodProfile={ + String(currentEntry?.item?.exercise_kind || 'simple').toLowerCase().trim() === 'combination' + ? currentEntry?.item?.planning_method_profile ?? null + : null + } />
diff --git a/frontend/src/utils/comboPlanningMethodProfile.js b/frontend/src/utils/comboPlanningMethodProfile.js new file mode 100644 index 0000000..0de9814 --- /dev/null +++ b/frontend/src/utils/comboPlanningMethodProfile.js @@ -0,0 +1,26 @@ +/** Effektives Ablaufprofil für Kombination im Coach/in der Planung */ + +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) + ) { + return { ...planningSnapshot } + } + return { ...cat } +} + +export function comboPlanningProfileJsonForEditor(catalogDict, planningSnapshot) { + const o = effectiveComboMethodProfile(catalogDict, planningSnapshot) + try { + return JSON.stringify(Object.keys(o).length ? o : {}) + } catch { + return '{}' + } +} diff --git a/frontend/src/utils/trainingPlanUtils.js b/frontend/src/utils/trainingPlanUtils.js index a81e380..3e88e59 100644 --- a/frontend/src/utils/trainingPlanUtils.js +++ b/frontend/src/utils/trainingPlanUtils.js @@ -81,7 +81,7 @@ export function sectionsToPutPayload(unit, durationOverridesByItemId = {}) { else actual = typeof actual === 'number' ? actual : parseInt(String(actual), 10) if (actual !== null && !Number.isFinite(actual)) actual = null - return { + const row = { item_type: 'exercise', order_index: it.order_index ?? ii, exercise_id: parseInt(String(eid), 10), @@ -92,6 +92,13 @@ export function sectionsToPutPayload(unit, durationOverridesByItemId = {}) { notes: trimOrNull(it.notes), modifications: trimOrNull(it.modifications), } + if (isCombo) { + const pmp = it.planning_method_profile + if (pmp != null && typeof pmp === 'object' && !Array.isArray(pmp)) { + row.planning_method_profile = { ...pmp } + } + } + return row }) .filter(Boolean), })) diff --git a/frontend/src/utils/trainingUnitSectionsForm.js b/frontend/src/utils/trainingUnitSectionsForm.js index e9d2773..f95bd8c 100644 --- a/frontend/src/utils/trainingUnitSectionsForm.js +++ b/frontend/src/utils/trainingUnitSectionsForm.js @@ -4,6 +4,18 @@ export function defaultSection(title = 'Hauptteil') { return { title, guidance_notes: '', items: [] } } +function normalizeCatalogMethodProfile(cp) { + if (cp && typeof cp === 'object' && !Array.isArray(cp)) return { ...cp } + return {} +} + +/** NULL = Planung folgt Katalogprofil der Übung */ +function normalizePlanningMethodProfile(pm) { + if (pm == null) return null + if (typeof pm === 'object' && !Array.isArray(pm)) return { ...pm } + return null +} + export function exerciseRow() { return { item_type: 'exercise', @@ -18,6 +30,9 @@ export function exerciseRow() { modifications: '', source_training_module_id: '', source_module_title: '', + catalog_method_archetype: '', + catalog_method_profile: {}, + planning_method_profile: null, } } @@ -29,16 +44,20 @@ export async function hydrateExercisePlanningRow(exercise) { if (!id) return null let meta = {} - async function fetchFull() { + let full + + async function ensureFull() { + if (full !== undefined) return full try { - return await api.getExercise(id) + full = await api.getExercise(id) } catch { - return null + full = null } + return full } if (!variants.length) { - const full = await fetchFull() + await ensureFull() if (full) { variants = Array.isArray(full.variants) ? full.variants : [] title = full.title || title @@ -48,6 +67,8 @@ export async function hydrateExercisePlanningRow(exercise) { exercise_club_id: full.club_id ?? null, exercise_created_by: full.created_by ?? null, exercise_status: full.status || 'draft', + catalog_method_archetype: typeof full.method_archetype === 'string' ? full.method_archetype.trim() : '', + catalog_method_profile: normalizeCatalogMethodProfile(full.method_profile), } } } else { @@ -62,7 +83,7 @@ export async function hydrateExercisePlanningRow(exercise) { meta.exercise_created_by == null || exerciseKind == null ) { - const full = await fetchFull() + await ensureFull() 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 @@ -89,6 +110,15 @@ export async function hydrateExercisePlanningRow(exercise) { row.variants = variants } Object.assign(row, meta) + if (row.exercise_kind === 'combination') { + if (full === undefined) await ensureFull() + if (full) { + row.catalog_method_archetype = + typeof full.method_archetype === 'string' ? full.method_archetype.trim() : '' + row.catalog_method_profile = normalizeCatalogMethodProfile(full.method_profile) + } + } + row.planning_method_profile = null return row } @@ -147,6 +177,9 @@ export function normalizeUnitToForm(fullUnit) { : '', notes: it.notes ?? '', modifications: it.modifications ?? '', + catalog_method_archetype: String(it.catalog_method_archetype ?? '').trim(), + catalog_method_profile: normalizeCatalogMethodProfile(it.catalog_method_profile), + planning_method_profile: normalizePlanningMethodProfile(it.planning_method_profile), ...(smEx != null ? { source_training_module_id: smEx, @@ -186,6 +219,9 @@ export function normalizeUnitToForm(fullUnit) { : '', notes: ex.notes ?? '', modifications: ex.modifications ?? '', + catalog_method_archetype: String(ex.catalog_method_archetype ?? '').trim(), + catalog_method_profile: normalizeCatalogMethodProfile(ex.catalog_method_profile), + planning_method_profile: normalizePlanningMethodProfile(ex.planning_method_profile), } }), }, @@ -217,6 +253,8 @@ export async function enrichSectionsWithVariants(sections) { club_id: ex.club_id ?? null, created_by: ex.created_by ?? null, status: ex.status || 'draft', + method_archetype: typeof ex.method_archetype === 'string' ? ex.method_archetype.trim() : '', + method_profile: normalizeCatalogMethodProfile(ex.method_profile), }) } catch { cache.set(id, { @@ -227,6 +265,8 @@ export async function enrichSectionsWithVariants(sections) { club_id: null, created_by: null, status: 'draft', + method_archetype: '', + method_profile: {}, }) } }) @@ -240,8 +280,18 @@ export async function enrichSectionsWithVariants(sections) { if (!c) return it const ek = String(c.exercise_kind || 'simple').toLowerCase().trim() const isCombo = ek === 'combination' + const itemCatalog = normalizeCatalogMethodProfile(it.catalog_method_profile) + const catalog_method_profile = + Object.keys(itemCatalog).length > 0 + ? itemCatalog + : normalizeCatalogMethodProfile(c.method_profile) + const rowArche = String(it.catalog_method_archetype ?? '').trim() + const catalog_method_archetype = rowArche || String(c.method_archetype ?? '').trim() return { ...it, + catalog_method_archetype, + catalog_method_profile, + planning_method_profile: normalizePlanningMethodProfile(it.planning_method_profile), exercise_kind: isCombo ? 'combination' : 'simple', exercise_title: it.exercise_title || c.title, exercise_variant_id: isCombo ? '' : it.exercise_variant_id, @@ -296,6 +346,12 @@ export function buildSectionsPayload(sections) { notes: it.notes?.trim() ? it.notes.trim() : null, modifications: it.modifications?.trim() ? it.modifications.trim() : null, } + if (isCombo) { + const pmp = it.planning_method_profile + if (pmp != null && typeof pmp === 'object' && !Array.isArray(pmp)) { + rowEx.planning_method_profile = { ...pmp } + } + } if (smEx != null) rowEx.source_training_module_id = smEx return rowEx })