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)