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
})