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
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:
parent
8a9f9f960f
commit
3dc4c9c79e
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user