feat(exercises): update to version 0.8.100 and enhance combination exercise handling
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 12s
Test Suite / playwright-tests (push) Successful in 56s

- 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 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-05-13 06:30:53 +02:00
parent 8a9f9f960f
commit 3dc4c9c79e
7 changed files with 162 additions and 54 deletions

View File

@ -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 variant_id:
return
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
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

View File

@ -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",

View File

@ -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}
</span>
)}
{(ex.exercise_kind || '').toLowerCase().trim() === 'combination' ? (
<span
className="exercise-tag"
style={{
marginTop: 6,
marginLeft: 6,
background: 'var(--accent-soft)',
color: 'var(--accent-dark)',
}}
>
Kombination
</span>
) : null}
</>
)
if (multiSelect) {

View File

@ -1055,7 +1055,12 @@ export default function ExerciseProgressionGraphPanel({
</div>
)}
<ExercisePickerModal open={pickerOpen} onClose={() => setPickContext(null)} onSelectExercise={applyPickedExercise} />
<ExercisePickerModal
open={pickerOpen}
onClose={() => setPickContext(null)}
onSelectExercise={applyPickedExercise}
exerciseKindAny={['simple']}
/>
</div>
)
}

View File

@ -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({
) : (
<span className="tu-ex-title-placeholder">Keine Übung gewählt</span>
)}
{isCombination ? (
<span
className="exercise-tag"
style={{
marginLeft: 8,
fontSize: '11px',
alignSelf: 'center',
background: 'var(--accent-soft)',
color: 'var(--accent-dark)',
}}
>
Kombination
</span>
) : null}
{planningCompactLegend && curMn ? (
<PlanningModuleRowTag moduleId={curMn} title={modBandTitle} />
) : null}

View File

@ -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

View File

@ -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.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,10 +166,14 @@ export function normalizeUnitToForm(fullUnit) {
{
title: 'Übungen',
guidance_notes: '',
items: fullUnit.exercises.map((ex) => ({
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: ex.exercise_variant_id ?? '',
exercise_variant_id: isCombo ? '' : (ex.exercise_variant_id ?? ''),
exercise_title: ex.exercise_title || '',
variants: [],
planned_duration_min:
@ -157,7 +186,8 @@ export function normalizeUnitToForm(fullUnit) {
: '',
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)