feat: update version to 0.7.9 and enhance exercise variant management
Some checks failed
Deploy Development / deploy (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 2m0s

- Incremented application version to 0.7.9 and updated changelog to reflect new features.
- Added support for creating, updating, and deleting exercise variants via new API endpoints.
- Implemented functionality for reordering exercise variants, improving user experience in managing exercise options.
- Enhanced frontend components to display and manage exercise variants, including detailed information and editing capabilities.
This commit is contained in:
Lars 2026-04-28 10:59:09 +02:00
parent cf9f95377c
commit 1ee1a2f2d9
5 changed files with 837 additions and 26 deletions

View File

@ -167,6 +167,36 @@ class ExerciseMediaReorder(BaseModel):
media_ids: list[int]
class ExerciseVariantCreate(BaseModel):
variant_name: str = Field(..., min_length=3, max_length=200)
description: Optional[str] = None
execution_changes: Optional[str] = None
duration_min: Optional[int] = None
duration_max: Optional[int] = None
equipment_changes: Optional[list[str]] = None
difficulty_adjustment: Optional[str] = Field(None, max_length=50)
progression_level: int = Field(default=1, ge=1, le=10)
sequence_order: Optional[int] = None
prerequisite_variant_id: Optional[int] = None
class ExerciseVariantUpdate(BaseModel):
variant_name: Optional[str] = Field(None, min_length=3, max_length=200)
description: Optional[str] = None
execution_changes: Optional[str] = None
duration_min: Optional[int] = None
duration_max: Optional[int] = None
equipment_changes: Optional[list[str]] = None
difficulty_adjustment: Optional[str] = Field(None, max_length=50)
progression_level: Optional[int] = Field(None, ge=1, le=10)
sequence_order: Optional[int] = None
prerequisite_variant_id: Optional[int] = None
class ExerciseVariantsReorder(BaseModel):
variant_ids: list[int]
# ============================================================================
# Helper Functions
# ============================================================================
@ -209,6 +239,48 @@ def _assert_can_edit_exercise(cur, exercise_id: int, profile_id: int):
raise HTTPException(status_code=403, detail="Nur der Ersteller darf diese Übung bearbeiten")
def _variant_equipment_json(changes: Optional[list]) -> str:
return json.dumps(changes if changes else [])
def _normalize_variant_equipment_list(val) -> list:
if val is None:
return []
if isinstance(val, list):
return val
if isinstance(val, str):
try:
return json.loads(val)
except Exception:
return []
return []
def _validate_variant_prerequisite(cur, exercise_id: int, prereq_id: Optional[int]) -> None:
if prereq_id is None:
return
cur.execute(
"SELECT 1 FROM exercise_variants WHERE id = %s AND exercise_id = %s",
(prereq_id, exercise_id),
)
if not cur.fetchone():
raise HTTPException(status_code=400, detail="Voraussetzungs-Variante gehört nicht zu dieser Übung")
def _fetch_variant_row(cur, exercise_id: int, variant_id: int) -> dict:
cur.execute(
"""SELECT id, variant_name, description, execution_changes,
duration_min, duration_max, equipment_changes, difficulty_adjustment,
progression_level, sequence_order, prerequisite_variant_id, created_at
FROM exercise_variants WHERE id = %s AND exercise_id = %s""",
(variant_id, exercise_id),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Variante nicht gefunden")
return r2d(row)
def _count_exercise_media(cur, exercise_id: int) -> int:
cur.execute("SELECT COUNT(*) AS c FROM exercise_media WHERE exercise_id = %s", (exercise_id,))
r = cur.fetchone()
@ -323,14 +395,14 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict:
sk["required_level"] = normalize_exercise_skill_level(sk.get("required_level"))
sk["target_level"] = normalize_exercise_skill_level(sk.get("target_level"))
# Variants (1:N) - mit Progression
# Variants (1:N) - mit Progression (Reihenfolge: sequence_order, dann progression_level)
cur.execute(
"""SELECT id, variant_name, description, execution_changes,
duration_min, duration_max, equipment_changes, difficulty_adjustment,
progression_level, sequence_order, prerequisite_variant_id
progression_level, sequence_order, prerequisite_variant_id, created_at
FROM exercise_variants
WHERE exercise_id = %s
ORDER BY progression_level, sequence_order""",
ORDER BY sequence_order NULLS LAST, progression_level, id""",
(exercise_id,)
)
exercise["variants"] = [r2d(r) for r in cur.fetchall()]
@ -605,7 +677,7 @@ def list_exercises(
'variant_name', ev.variant_name,
'sequence_order', ev.sequence_order
)
ORDER BY ev.sequence_order NULLS LAST, ev.id
ORDER BY ev.sequence_order NULLS LAST, ev.progression_level, ev.id
),
'[]'::json
)
@ -840,6 +912,211 @@ def delete_exercise(
return {"ok": True}
# --- Übungsvarianten (EXERCISES_API_SPEC.md) ---
@router.put("/exercises/{exercise_id}/variants/reorder")
def reorder_exercise_variants(
exercise_id: int,
body: ExerciseVariantsReorder,
session: dict = Depends(require_auth),
):
profile_id = session["profile_id"]
if len(body.variant_ids) != len(set(body.variant_ids)):
raise HTTPException(status_code=400, detail="variant_ids dürfen keine Duplikate enthalten")
with get_db() as conn:
cur = get_cursor(conn)
_assert_can_edit_exercise(cur, exercise_id, profile_id)
cur.execute(
"SELECT id FROM exercise_variants WHERE exercise_id = %s",
(exercise_id,),
)
existing = [r["id"] for r in cur.fetchall()]
if set(existing) != set(body.variant_ids):
raise HTTPException(
status_code=400,
detail="variant_ids müssen alle Varianten dieser Übung genau einmal enthalten",
)
for pos, vid in enumerate(body.variant_ids):
cur.execute(
"""UPDATE exercise_variants SET sequence_order = %s
WHERE id = %s AND exercise_id = %s""",
(pos + 1, vid, exercise_id),
)
conn.commit()
return {"ok": True, "reordered": len(body.variant_ids)}
@router.post("/exercises/{exercise_id}/variants", status_code=201)
def create_exercise_variant(
exercise_id: int,
body: ExerciseVariantCreate,
session: dict = Depends(require_auth),
):
profile_id = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
_assert_can_edit_exercise(cur, exercise_id, profile_id)
_validate_variant_prerequisite(cur, exercise_id, body.prerequisite_variant_id)
eq_json = _variant_equipment_json(body.equipment_changes)
seq = body.sequence_order
if seq is None:
cur.execute(
"""SELECT COALESCE(MAX(sequence_order), 0) + 1 AS n
FROM exercise_variants WHERE exercise_id = %s""",
(exercise_id,),
)
seq = cur.fetchone()["n"]
desc = (body.description or "").strip() or None
exec_ch = (body.execution_changes or "").strip() or None
diff = (body.difficulty_adjustment or "").strip() or None
cur.execute(
"""INSERT INTO exercise_variants (
exercise_id, variant_name, description, execution_changes,
duration_min, duration_max, equipment_changes, difficulty_adjustment,
progression_level, sequence_order, prerequisite_variant_id
) VALUES (%s,%s,%s,%s,%s,%s,%s::jsonb,%s,%s,%s,%s)
RETURNING id""",
(
exercise_id,
body.variant_name.strip(),
desc,
exec_ch,
body.duration_min,
body.duration_max,
eq_json,
diff,
body.progression_level,
seq,
body.prerequisite_variant_id,
),
)
new_id = cur.fetchone()["id"]
row = _fetch_variant_row(cur, exercise_id, new_id)
conn.commit()
return row
@router.put("/exercises/{exercise_id}/variants/{variant_id}")
def update_exercise_variant(
exercise_id: int,
variant_id: int,
body: ExerciseVariantUpdate,
session: dict = Depends(require_auth),
):
profile_id = session["profile_id"]
data = body.dict(exclude_unset=True)
if not data:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
with get_db() as conn:
cur = get_cursor(conn)
_assert_can_edit_exercise(cur, exercise_id, profile_id)
old = _fetch_variant_row(cur, exercise_id, variant_id)
if "variant_name" in data and data["variant_name"] is not None:
old["variant_name"] = data["variant_name"].strip()
if "description" in data:
old["description"] = (data["description"] or "").strip() or None
if "execution_changes" in data:
old["execution_changes"] = (data["execution_changes"] or "").strip() or None
if "duration_min" in data:
old["duration_min"] = data["duration_min"]
if "duration_max" in data:
old["duration_max"] = data["duration_max"]
if "equipment_changes" in data:
old["equipment_changes"] = _normalize_variant_equipment_list(data["equipment_changes"])
if "difficulty_adjustment" in data:
old["difficulty_adjustment"] = (data["difficulty_adjustment"] or "").strip() or None
if "progression_level" in data and data["progression_level"] is not None:
old["progression_level"] = data["progression_level"]
if "sequence_order" in data:
old["sequence_order"] = data["sequence_order"]
if "prerequisite_variant_id" in data:
old["prerequisite_variant_id"] = data["prerequisite_variant_id"]
prereq = old.get("prerequisite_variant_id")
if prereq == variant_id:
raise HTTPException(status_code=400, detail="Variante kann nicht ihre eigene Voraussetzung sein")
_validate_variant_prerequisite(cur, exercise_id, prereq)
eq_db = _variant_equipment_json(_normalize_variant_equipment_list(old.get("equipment_changes")))
cur.execute(
"""UPDATE exercise_variants SET
variant_name = %s,
description = %s,
execution_changes = %s,
duration_min = %s,
duration_max = %s,
equipment_changes = %s::jsonb,
difficulty_adjustment = %s,
progression_level = %s,
sequence_order = %s,
prerequisite_variant_id = %s
WHERE id = %s AND exercise_id = %s""",
(
old["variant_name"],
old.get("description"),
old.get("execution_changes"),
old.get("duration_min"),
old.get("duration_max"),
eq_db,
old.get("difficulty_adjustment"),
old.get("progression_level"),
old.get("sequence_order"),
old.get("prerequisite_variant_id"),
variant_id,
exercise_id,
),
)
row = _fetch_variant_row(cur, exercise_id, variant_id)
conn.commit()
return row
def delete_exercise_variant(
exercise_id: int,
variant_id: int,
session: dict = Depends(require_auth),
):
profile_id = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
_assert_can_edit_exercise(cur, exercise_id, profile_id)
_fetch_variant_row(cur, exercise_id, variant_id)
cur.execute(
"SELECT COUNT(*) AS c FROM exercise_variants WHERE prerequisite_variant_id = %s",
(variant_id,),
)
cnt = int(cur.fetchone()["c"])
if cnt > 0:
raise HTTPException(
status_code=409,
detail="Variante ist Voraussetzung anderer Varianten — zuerst dort ändern oder entfernen",
)
cur.execute(
"DELETE FROM exercise_variants WHERE id = %s AND exercise_id = %s",
(variant_id, exercise_id),
)
conn.commit()
return {"ok": True}
# --- Medien (MEDIA_UPLOAD_SPEC.md / EXERCISES_API_SPEC.md) ---

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.7.8"
APP_VERSION = "0.7.9"
BUILD_DATE = "2026-04-27"
DB_SCHEMA_VERSION = "20260427030"
@ -11,7 +11,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
"exercises": "2.0.0", # BREAKING: Clean-Room Rebuild, Legacy-Felder entfernt, nur M:N
"exercises": "2.1.0", # Varianten-CRUD API + UI; Listen mit include_variants
"training_units": "0.1.0",
"training_programs": "0.1.0",
"planning": "0.2.0",
@ -23,6 +23,16 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.7.9",
"date": "2026-04-27",
"changes": [
"Übungsvarianten: POST/PUT/DELETE /api/exercises/{id}/variants + reorder",
"Übung bearbeiten: voller Varianten-Editor (Speichern pro Variante, Reihenfolge, Voraussetzung)",
"Übung Ansehen: Varianten-Metadaten (Dauer, Schwierigkeit, Material, Progression)",
"GET /exercises Detail: Varianten-Sortierung sequence_order → progression_level",
],
},
{
"version": "0.7.8",
"date": "2026-04-27",

View File

@ -263,24 +263,55 @@ function ExerciseDetailPage() {
{(exercise.variants || []).length > 0 && (
<section className="card exercise-detail-section">
<h2>Varianten</h2>
{exercise.variants.map((v) => (
<div
key={v.id}
style={{
marginBottom: '1rem',
paddingBottom: '1rem',
borderBottom: '1px solid var(--border)',
}}
>
<strong style={{ fontSize: '15px' }}>{v.variant_name}</strong>
{v.description && <p style={{ color: 'var(--text2)', marginTop: '4px' }}>{v.description}</p>}
{v.execution_changes && (
<div style={{ marginTop: '8px' }}>
<HtmlBlock html={v.execution_changes} />
</div>
)}
</div>
))}
{exercise.variants.map((v) => {
const dur =
v.duration_min != null || v.duration_max != null
? v.duration_min != null && v.duration_max != null && v.duration_min !== v.duration_max
? `${v.duration_min}${v.duration_max} Min.`
: v.duration_min != null
? `ca. ${v.duration_min} Min.`
: `bis ca. ${v.duration_max} Min.`
: null
const diffLabel =
v.difficulty_adjustment === 'easier'
? 'einfacher'
: v.difficulty_adjustment === 'harder'
? 'schwerer'
: v.difficulty_adjustment === 'same'
? 'gleiche Schwierigkeit'
: v.difficulty_adjustment === 'adapted'
? 'angepasst'
: null
const equip =
Array.isArray(v.equipment_changes) && v.equipment_changes.length > 0
? v.equipment_changes.join(', ')
: null
return (
<div
key={v.id}
style={{
marginBottom: '1rem',
paddingBottom: '1rem',
borderBottom: '1px solid var(--border)',
}}
>
<strong style={{ fontSize: '15px' }}>{v.variant_name}</strong>
{(dur || diffLabel || equip || v.progression_level != null) && (
<div style={{ fontSize: '13px', color: 'var(--text3)', marginTop: '4px' }}>
{[dur, diffLabel, equip && `Material: ${equip}`, v.progression_level != null && `Progression ${v.progression_level}`]
.filter(Boolean)
.join(' · ')}
</div>
)}
{v.description && <p style={{ color: 'var(--text2)', marginTop: '4px' }}>{v.description}</p>}
{v.execution_changes && (
<div style={{ marginTop: '8px' }}>
<HtmlBlock html={v.execution_changes} />
</div>
)}
</div>
)
})}
</section>
)}
</div>

View File

@ -11,6 +11,82 @@ const INTENSITY_OPTIONS = [
{ value: 'hoch', label: 'hoch' },
]
const VARIANT_DIFFICULTY = [
{ value: '', label: '—' },
{ value: 'easier', label: 'Einfacher' },
{ value: 'same', label: 'Gleich' },
{ value: 'harder', label: 'Schwerer' },
{ value: 'adapted', label: 'Angepasst' },
]
function emptyVariantDraft() {
return {
variant_name: '',
description: '',
execution_changes: '',
duration_min: '',
duration_max: '',
equipment_lines: '',
difficulty_adjustment: '',
progression_level: 1,
prerequisite_variant_id: '',
}
}
function apiVariantToRow(v) {
let lines = ''
const eq = v.equipment_changes
if (Array.isArray(eq)) {
lines = eq.join('\n')
} else if (typeof eq === 'string' && eq.trim()) {
try {
const p = JSON.parse(eq)
lines = Array.isArray(p) ? p.join('\n') : eq
} catch {
lines = eq
}
}
return {
...v,
duration_min: v.duration_min ?? '',
duration_max: v.duration_max ?? '',
equipment_lines: lines,
progression_level: v.progression_level ?? 1,
prerequisite_variant_id: v.prerequisite_variant_id ?? '',
difficulty_adjustment: v.difficulty_adjustment ?? '',
}
}
function buildVariantPayloadFromRow(row) {
const lines = (row.equipment_lines || '')
.split(/[\n,]+/)
.map((s) => s.trim())
.filter(Boolean)
const pl =
row.progression_level === '' || row.progression_level == null
? 1
: parseInt(row.progression_level, 10)
const so =
row.sequence_order === '' || row.sequence_order == null
? null
: parseInt(row.sequence_order, 10)
return {
variant_name: (row.variant_name || '').trim(),
description: (row.description || '').trim() || null,
execution_changes: (row.execution_changes || '').trim() || null,
duration_min: row.duration_min === '' || row.duration_min == null ? null : parseInt(row.duration_min, 10),
duration_max: row.duration_max === '' || row.duration_max == null ? null : parseInt(row.duration_max, 10),
equipment_changes: lines,
difficulty_adjustment: row.difficulty_adjustment || null,
progression_level: Number.isNaN(pl) ? 1 : pl,
sequence_order: so !== null && Number.isNaN(so) ? null : so,
prerequisite_variant_id:
row.prerequisite_variant_id === '' || row.prerequisite_variant_id == null
? null
: parseInt(row.prerequisite_variant_id, 10),
}
}
function emptyForm() {
return {
title: '',
@ -157,6 +233,10 @@ function ExerciseFormPage() {
const [loading, setLoading] = useState(!!isEdit)
const [saving, setSaving] = useState(false)
const [skillPick, setSkillPick] = useState('')
const [variants, setVariants] = useState([])
const [variantDraft, setVariantDraft] = useState(() => emptyVariantDraft())
const [variantSavingId, setVariantSavingId] = useState(null)
const [variantBusy, setVariantBusy] = useState(false)
const [mediaFile, setMediaFile] = useState(null)
const [mediaType, setMediaType] = useState('image')
@ -202,6 +282,8 @@ function ExerciseFormPage() {
if (!isEdit) {
setFormData(emptyForm())
setMediaList([])
setVariants([])
setVariantDraft(emptyVariantDraft())
setLoading(false)
return
}
@ -213,6 +295,8 @@ function ExerciseFormPage() {
if (cancelled) return
setFormData(detailToForm(exercise))
setMediaList(exercise.media || [])
setVariants((exercise.variants || []).map(apiVariantToRow))
setVariantDraft(emptyVariantDraft())
} catch (err) {
if (!cancelled) {
alert(err.message || 'Übung nicht ladbar')
@ -304,6 +388,7 @@ function ExerciseFormPage() {
await api.updateExercise(exerciseId, payload)
const ex = await api.getExercise(exerciseId)
setMediaList(ex.media || [])
setVariants((ex.variants || []).map(apiVariantToRow))
alert('Gespeichert.')
} else {
const created = await api.createExercise(payload)
@ -376,6 +461,85 @@ function ExerciseFormPage() {
}
}
const refreshVariants = async () => {
if (!exerciseId) return
const ex = await api.getExercise(exerciseId)
setVariants((ex.variants || []).map(apiVariantToRow))
}
const updateVariantField = (id, patch) => {
setVariants((prev) => prev.map((v) => (v.id === id ? { ...v, ...patch } : v)))
}
const saveVariantRow = async (row) => {
const payload = buildVariantPayloadFromRow(row)
if (payload.variant_name.length < 3) {
alert('Variantenname mindestens 3 Zeichen')
return
}
setVariantSavingId(row.id)
try {
await api.updateExerciseVariant(exerciseId, row.id, payload)
await refreshVariants()
} catch (e) {
alert(e.message || String(e))
} finally {
setVariantSavingId(null)
}
}
const deleteVariantRow = async (id) => {
if (!confirm('Variante wirklich löschen?')) return
setVariantBusy(true)
try {
await api.deleteExerciseVariant(exerciseId, id)
await refreshVariants()
} catch (e) {
alert(e.message || String(e))
} finally {
setVariantBusy(false)
}
}
const moveVariantRow = async (idx, dir) => {
const j = idx + dir
if (j < 0 || j >= variants.length) return
const next = [...variants]
const tmp = next[idx]
next[idx] = next[j]
next[j] = tmp
const ids = next.map((x) => x.id)
setVariantBusy(true)
try {
await api.reorderExerciseVariants(exerciseId, ids)
await refreshVariants()
} catch (e) {
alert(e.message || String(e))
} finally {
setVariantBusy(false)
}
}
const createVariantSubmit = async (e) => {
e.preventDefault()
if (!exerciseId) return
const payload = buildVariantPayloadFromRow(variantDraft)
if (payload.variant_name.length < 3) {
alert('Variantenname mindestens 3 Zeichen')
return
}
setVariantBusy(true)
try {
await api.createExerciseVariant(exerciseId, payload)
setVariantDraft(emptyVariantDraft())
await refreshVariants()
} catch (err) {
alert(err.message || String(err))
} finally {
setVariantBusy(false)
}
}
const availableSkills = skillsCatalog.filter((s) => !formData.skills.some((x) => x.skill_id === s.id))
if (loading) {
@ -689,6 +853,307 @@ function ExerciseFormPage() {
</form>
</div>
{isEdit && (
<div className="card" style={{ marginTop: '16px' }}>
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Übungsvarianten</h2>
<p style={{ color: 'var(--text2)', fontSize: '13px', marginBottom: '12px' }}>
Alternative Ausführungen zur Stammübung. Reihenfolge (Nach oben/unten) steuert Darstellung und Auswahl in der
Trainingsplanung. Voraussetzung verknüpft Varianten für spätere Progressions-Serien als Blöcke.
</p>
{variants.length === 0 && (
<p style={{ fontSize: '13px', color: 'var(--text3)', marginBottom: '12px' }}>Noch keine Varianten angelegt.</p>
)}
{variants.map((v, idx) => (
<div
key={v.id}
style={{
border: '1px solid var(--border)',
borderRadius: '10px',
padding: '12px',
marginBottom: '12px',
background: 'var(--surface2)',
}}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center', marginBottom: '10px' }}>
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>#{idx + 1}</span>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }}
disabled={variantBusy || idx === 0}
onClick={() => moveVariantRow(idx, -1)}
>
Nach oben
</button>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }}
disabled={variantBusy || idx === variants.length - 1}
onClick={() => moveVariantRow(idx, 1)}
>
Nach unten
</button>
<button
type="button"
className="btn btn-primary"
style={{ fontSize: '12px', marginLeft: 'auto' }}
disabled={variantSavingId === v.id || variantBusy}
onClick={() => saveVariantRow(v)}
>
{variantSavingId === v.id ? 'Speichern…' : 'Speichern'}
</button>
<button
type="button"
className="btn"
style={{ fontSize: '12px', background: 'var(--danger)', color: '#fff', border: 'none' }}
disabled={variantBusy}
onClick={() => deleteVariantRow(v.id)}
>
Löschen
</button>
</div>
<div className="form-row">
<label className="form-label">Variantenname *</label>
<input
type="text"
className="form-input"
value={v.variant_name || ''}
onChange={(e) => updateVariantField(v.id, { variant_name: e.target.value })}
minLength={3}
/>
</div>
<div className="form-row">
<label className="form-label">Kurzbeschreibung</label>
<textarea
className="form-input"
rows={2}
value={v.description || ''}
onChange={(e) => updateVariantField(v.id, { description: e.target.value })}
/>
</div>
<div className="form-row">
<label className="form-label">Abweichungen zur Durchführung</label>
<RichTextEditor
value={v.execution_changes || ''}
onChange={(html) => updateVariantField(v.id, { execution_changes: html })}
placeholder="Was unterscheidet diese Variante?"
minHeight="100px"
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div className="form-row">
<label className="form-label">Dauer Min</label>
<input
type="number"
className="form-input"
value={v.duration_min}
onChange={(e) => updateVariantField(v.id, { duration_min: e.target.value })}
/>
</div>
<div className="form-row">
<label className="form-label">Dauer Max</label>
<input
type="number"
className="form-input"
value={v.duration_max}
onChange={(e) => updateVariantField(v.id, { duration_max: e.target.value })}
/>
</div>
</div>
<div className="form-row">
<label className="form-label">Materialänderungen (eine Zeile pro Eintrag)</label>
<textarea
className="form-input"
rows={2}
value={v.equipment_lines || ''}
onChange={(e) => updateVariantField(v.id, { equipment_lines: e.target.value })}
placeholder="+ Pratzen"
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: '10px' }}>
<div className="form-row">
<label className="form-label">Schwere relativ</label>
<select
className="form-input"
value={v.difficulty_adjustment || ''}
onChange={(e) => updateVariantField(v.id, { difficulty_adjustment: e.target.value })}
>
{VARIANT_DIFFICULTY.map((o) => (
<option key={o.label} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div className="form-row">
<label className="form-label">Progressions-Stufe (110)</label>
<input
type="number"
min={1}
max={10}
className="form-input"
value={v.progression_level}
onChange={(e) =>
updateVariantField(v.id, {
progression_level: e.target.value === '' ? '' : parseInt(e.target.value, 10),
})
}
/>
</div>
<div className="form-row">
<label className="form-label">Voraussetzungs-Variante</label>
<select
className="form-input"
value={
v.prerequisite_variant_id === '' || v.prerequisite_variant_id == null
? ''
: String(v.prerequisite_variant_id)
}
onChange={(e) =>
updateVariantField(v.id, {
prerequisite_variant_id: e.target.value === '' ? '' : parseInt(e.target.value, 10),
})
}
>
<option value=""> keine </option>
{variants
.filter((o) => o.id !== v.id)
.map((o) => (
<option key={o.id} value={o.id}>
{o.variant_name || `Variante #${o.id}`}
</option>
))}
</select>
</div>
</div>
</div>
))}
<form
onSubmit={createVariantSubmit}
style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px solid var(--border)' }}
>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Neue Variante anlegen</h3>
<div className="form-row">
<label className="form-label">Variantenname *</label>
<input
type="text"
className="form-input"
value={variantDraft.variant_name}
onChange={(e) => setVariantDraft((d) => ({ ...d, variant_name: e.target.value }))}
minLength={3}
/>
</div>
<div className="form-row">
<label className="form-label">Kurzbeschreibung</label>
<textarea
className="form-input"
rows={2}
value={variantDraft.description}
onChange={(e) => setVariantDraft((d) => ({ ...d, description: e.target.value }))}
/>
</div>
<div className="form-row">
<label className="form-label">Abweichungen zur Durchführung</label>
<RichTextEditor
value={variantDraft.execution_changes}
onChange={(html) => setVariantDraft((d) => ({ ...d, execution_changes: html }))}
placeholder="Optional: abweichende Schritte"
minHeight="100px"
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div className="form-row">
<label className="form-label">Dauer Min</label>
<input
type="number"
className="form-input"
value={variantDraft.duration_min}
onChange={(e) => setVariantDraft((d) => ({ ...d, duration_min: e.target.value }))}
/>
</div>
<div className="form-row">
<label className="form-label">Dauer Max</label>
<input
type="number"
className="form-input"
value={variantDraft.duration_max}
onChange={(e) => setVariantDraft((d) => ({ ...d, duration_max: e.target.value }))}
/>
</div>
</div>
<div className="form-row">
<label className="form-label">Materialänderungen</label>
<textarea
className="form-input"
rows={2}
value={variantDraft.equipment_lines}
onChange={(e) => setVariantDraft((d) => ({ ...d, equipment_lines: e.target.value }))}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: '10px' }}>
<div className="form-row">
<label className="form-label">Schwere relativ</label>
<select
className="form-input"
value={variantDraft.difficulty_adjustment}
onChange={(e) => setVariantDraft((d) => ({ ...d, difficulty_adjustment: e.target.value }))}
>
{VARIANT_DIFFICULTY.map((o) => (
<option key={o.label} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div className="form-row">
<label className="form-label">Progressions-Stufe (110)</label>
<input
type="number"
min={1}
max={10}
className="form-input"
value={variantDraft.progression_level}
onChange={(e) =>
setVariantDraft((d) => ({
...d,
progression_level: e.target.value === '' ? 1 : parseInt(e.target.value, 10),
}))
}
/>
</div>
<div className="form-row">
<label className="form-label">Voraussetzungs-Variante</label>
<select
className="form-input"
value={
variantDraft.prerequisite_variant_id === '' || variantDraft.prerequisite_variant_id == null
? ''
: String(variantDraft.prerequisite_variant_id)
}
onChange={(e) =>
setVariantDraft((d) => ({
...d,
prerequisite_variant_id: e.target.value === '' ? '' : parseInt(e.target.value, 10),
}))
}
>
<option value=""> keine </option>
{variants.map((o) => (
<option key={o.id} value={o.id}>
{o.variant_name || `Variante #${o.id}`}
</option>
))}
</select>
</div>
</div>
<button type="submit" className="btn btn-primary" disabled={variantBusy}>
{variantBusy ? 'Anlegen…' : 'Variante anlegen'}
</button>
</form>
</div>
)}
{isEdit && (
<div className="card" style={{ marginTop: '16px' }}>
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Medien</h2>
@ -783,10 +1248,9 @@ function ExerciseFormPage() {
)}
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '16px' }}>
Varianten-Editor folgt später (API teilweise vorhanden).{' '}
<strong>KI-Ausbaustufe:</strong> Backend laut Spec{' '}
<code style={{ fontSize: '11px' }}>POST /api/exercises/ai/suggest</code> und{' '}
<code style={{ fontSize: '11px' }}>POST /api/exercises/{'{id}'}/ai/regenerate</code> z. B.{' '}
<code style={{ fontSize: '11px' }}>POST /api/exercises/{'{id}'}/ai/regenerate</code> z.B.{' '}
<code>OPENROUTER_API_KEY</code>, Vorschläge nur nach Trainer-Bestätigung übernehmen (siehe{' '}
<code>api.suggestExerciseAi</code>).
</p>

View File

@ -352,6 +352,31 @@ export async function deleteExercise(id) {
return request(`/api/exercises/${id}`, { method: 'DELETE' })
}
export async function createExerciseVariant(exerciseId, data) {
return request(`/api/exercises/${exerciseId}/variants`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateExerciseVariant(exerciseId, variantId, data) {
return request(`/api/exercises/${exerciseId}/variants/${variantId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteExerciseVariant(exerciseId, variantId) {
return request(`/api/exercises/${exerciseId}/variants/${variantId}`, { method: 'DELETE' })
}
export async function reorderExerciseVariants(exerciseId, variantIds) {
return request(`/api/exercises/${exerciseId}/variants/reorder`, {
method: 'PUT',
body: JSON.stringify({ variant_ids: variantIds }),
})
}
/** KI-Ausbaustufe (EXERCISES_API_SPEC): benötigt Backend + z. B. OPENROUTER_API_KEY. */
export async function suggestExerciseAi(payload) {
return request('/api/exercises/ai/suggest', {
@ -811,6 +836,10 @@ export const api = {
createExercise,
updateExercise,
deleteExercise,
createExerciseVariant,
updateExerciseVariant,
deleteExerciseVariant,
reorderExerciseVariants,
buildExerciseApiPayload,
suggestExerciseAi,
regenerateExerciseAi,