feat(exercises): introduce combination exercises and enhance exercise management
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 1m1s

- Updated app version to 0.8.99, reflecting the addition of combination exercises.
- Implemented new data structures and validation for combination slots and archetypes in the backend.
- Enhanced frontend components to support selection and display of combination exercises, including new UI elements for managing slots and archetypes.
- Updated API payload handling to accommodate new exercise types and their associated data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-05-13 06:12:47 +02:00
parent f4f5642c21
commit 8a9f9f960f
8 changed files with 716 additions and 12 deletions

View File

@ -0,0 +1,33 @@
-- Migration 056: Kombinationsübungen (Phase 2 MVP) — Slots + Pool-Kandidaten
-- Fachgrundlage: functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md §6
ALTER TABLE exercises
ADD COLUMN IF NOT EXISTS exercise_kind VARCHAR(20) NOT NULL DEFAULT 'simple'
CHECK (exercise_kind IN ('simple', 'combination')),
ADD COLUMN IF NOT EXISTS method_archetype VARCHAR(80),
ADD COLUMN IF NOT EXISTS method_profile JSONB NOT NULL DEFAULT '{}'::jsonb;
CREATE INDEX IF NOT EXISTS idx_exercises_exercise_kind ON exercises(exercise_kind);
CREATE INDEX IF NOT EXISTS idx_exercises_method_archetype ON exercises(method_archetype)
WHERE method_archetype IS NOT NULL;
CREATE TABLE IF NOT EXISTS combination_exercise_slots (
id SERIAL PRIMARY KEY,
exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
slot_index INT NOT NULL,
title VARCHAR(200),
UNIQUE (exercise_id, slot_index)
);
CREATE INDEX IF NOT EXISTS idx_combination_exercise_slots_exercise ON combination_exercise_slots(exercise_id);
CREATE TABLE IF NOT EXISTS combination_slot_candidates (
id SERIAL PRIMARY KEY,
slot_id INT NOT NULL REFERENCES combination_exercise_slots(id) ON DELETE CASCADE,
candidate_exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
sort_order INT NOT NULL DEFAULT 0,
UNIQUE (slot_id, candidate_exercise_id)
);
CREATE INDEX IF NOT EXISTS idx_combination_slot_candidates_slot ON combination_slot_candidates(slot_id);
CREATE INDEX IF NOT EXISTS idx_combination_slot_candidates_exercise ON combination_slot_candidates(candidate_exercise_id);

View File

@ -10,12 +10,13 @@ import logging
import os
import re
from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional, Tuple
from typing import Any, Dict, Iterator, List, Literal, Optional, Tuple
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Depends, Query, Request, UploadFile, File, Form
from fastapi.responses import FileResponse, Response, StreamingResponse
from pydantic import BaseModel, Field, model_validator
from psycopg2.extras import Json
from db import get_db, get_cursor, r2d
from club_tenancy import (
@ -198,6 +199,26 @@ def _upload_limit_bytes(tenant: TenantContext) -> int:
# Pydantic Models
# ============================================================================
# Archetyp-IDs (Maschinenlesbare Ablaufmuster) — konsistent zur technischen Entwurfsspezifikation
COMBINATION_ARCHETYPE_IDS = frozenset(
{
"circuit_rotate_time",
"circuit_all_parallel",
"sequence_linear",
"station_parcour",
"pair_superset",
"time_domain_interval",
"free_method_block",
}
)
class CombinationSlotIn(BaseModel):
slot_index: int = Field(ge=0, le=99)
title: Optional[str] = Field(None, max_length=200)
candidate_exercise_ids: list[int] = Field(default_factory=list)
class ExerciseCreate(BaseModel):
# Basis-Felder (goal/execution: DB-Constraint mind. eines; Wiki oft nur eines)
title: str = Field(..., min_length=3, max_length=300)
@ -231,6 +252,12 @@ class ExerciseCreate(BaseModel):
status: str = "draft"
club_id: Optional[int] = None
# Kombinationsübung (Phase 2)
exercise_kind: Literal["simple", "combination"] = "simple"
method_archetype: Optional[str] = Field(None, max_length=80)
method_profile: Dict[str, Any] = Field(default_factory=dict)
combination_slots: list[CombinationSlotIn] = Field(default_factory=list)
@model_validator(mode="after")
def normalize_goal_execution(self):
g = (self.goal or "").strip() or None
@ -270,6 +297,11 @@ class ExerciseUpdate(BaseModel):
# Vereins-Übung: fehlende Copyrights an Datei-Assets nach Prompt-Text setzen (PUT-Retry)
default_club_media_copyright: Optional[str] = Field(default=None, max_length=2000)
exercise_kind: Optional[Literal["simple", "combination"]] = None
method_archetype: Optional[str] = Field(None, max_length=80)
method_profile: Optional[Dict[str, Any]] = None
combination_slots: Optional[list[CombinationSlotIn]] = None
@model_validator(mode="after")
def normalize_goal_execution(self):
if self.goal is not None:
@ -811,6 +843,137 @@ def _resolve_local_media_file(
return path_under_media_root(media_root, asset_storage_key)
return _abs_media_path(file_path_db or "", media_root) if file_path_db else None
def _normalize_method_profile_store(raw: Any) -> Dict[str, Any]:
if raw is None:
return {}
if isinstance(raw, dict):
return raw
raise HTTPException(status_code=400, detail="method_profile muss ein JSON-Objekt sein")
def _validate_archetype_for_kind(kind: str, archetype: Optional[str]) -> None:
if kind != "combination":
return
if archetype is None or not str(archetype).strip():
return
a = str(archetype).strip()
if a not in COMBINATION_ARCHETYPE_IDS:
raise HTTPException(
status_code=400,
detail=(
"Unbekannter method_archetype. Erlaubt: "
+ ", ".join(sorted(COMBINATION_ARCHETYPE_IDS))
),
)
def _assert_candidate_exercises_for_combination(cur, tenant: TenantContext, ids: List[int]) -> None:
if not ids:
return
seen: set[int] = set()
for cid_raw in ids:
cid = int(cid_raw)
if cid in seen:
continue
seen.add(cid)
cur.execute(
"""SELECT id, exercise_kind, visibility, club_id, created_by
FROM exercises WHERE id = %s""",
(cid,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=400, detail=f"Slot-Verweis: Übung #{cid} nicht gefunden")
rd = r2d(row)
k = str(rd.get("exercise_kind") or "simple").strip().lower()
if k != "simple":
raise HTTPException(
status_code=400,
detail=f"Slot-Verweis: Übung #{cid} ist eine Kombinationsübung — nur Einzelübungen erlaubt",
)
if not library_content_visible_to_profile(
cur,
tenant.profile_id,
rd.get("visibility"),
rd.get("club_id"),
rd.get("created_by"),
tenant.global_role,
):
raise HTTPException(status_code=403, detail=f"Slot-Verweis: keine Leserechte für Übung #{cid}")
def _validate_and_normalize_combination_slots_payload(
cur,
tenant: TenantContext,
slots: Optional[List[CombinationSlotIn]],
) -> List[Tuple[int, Optional[str], List[int]]]:
if slots is None:
return []
normalized: Dict[int, Tuple[Optional[str], List[int]]] = {}
for s in sorted(slots, key=lambda x: x.slot_index):
cid_list_raw = list(s.candidate_exercise_ids or [])
cid_list: List[int] = []
for x in cid_list_raw:
cid_list.append(int(x))
title = ((s.title or "").strip()) or None
normalized[int(s.slot_index)] = (title, cid_list)
out: List[Tuple[int, Optional[str], List[int]]] = []
for idx in sorted(normalized.keys()):
title, cands = normalized[idx]
if not cands:
raise HTTPException(
status_code=400,
detail=f"Station (Index {idx}): mindestens eine Einzelübung (Pool) ist erforderlich",
)
out.append((idx, title, cands))
return out
def replace_combination_slots(
cur,
tenant: TenantContext,
exercise_id: int,
slots_norm: List[Tuple[int, Optional[str], List[int]]],
) -> None:
flat_ids = [cid for _, __, xs in slots_norm for cid in xs]
_assert_candidate_exercises_for_combination(cur, tenant, flat_ids)
cur.execute("DELETE FROM combination_exercise_slots WHERE exercise_id = %s", (exercise_id,))
for slot_index, title, cand_ids in slots_norm:
cur.execute(
"""INSERT INTO combination_exercise_slots (exercise_id, slot_index, title)
VALUES (%s, %s, %s) RETURNING id""",
(exercise_id, slot_index, title),
)
row = cur.fetchone()
sid = row["id"] if isinstance(row, dict) else row[0]
for so, cid in enumerate(cand_ids):
cur.execute(
"""INSERT INTO combination_slot_candidates (slot_id, candidate_exercise_id, sort_order)
VALUES (%s, %s, %s)""",
(sid, int(cid), int(so)),
)
def wipe_combination_structure(cur, exercise_id: int) -> None:
cur.execute("DELETE FROM combination_exercise_slots WHERE exercise_id = %s", (exercise_id,))
def assert_exercise_not_combination(cur, exercise_id: int) -> None:
cur.execute(
"SELECT COALESCE(exercise_kind, 'simple') AS exercise_kind FROM exercises WHERE id = %s",
(exercise_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
if str(r2d(row).get("exercise_kind") or "simple").strip().lower() == "combination":
raise HTTPException(
status_code=400,
detail="Kombinationsübungen unterstützen keine Varianten.",
)
def enrich_exercise_detail(exercise_id: int, cur) -> dict:
"""
Lädt alle M:N Relations für eine Übung und gibt ein vollständiges
@ -933,6 +1096,48 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict:
)
exercise["media"] = [r2d(r) for r in cur.fetchall()]
mp_raw = exercise.get("method_profile")
exercise["method_profile"] = mp_raw if isinstance(mp_raw, dict) else {}
exercise["exercise_kind"] = str(exercise.get("exercise_kind") or "simple").strip().lower()
exercise["combination_slots"] = []
if exercise["exercise_kind"] == "combination":
cur.execute(
"""SELECT id, slot_index, title FROM combination_exercise_slots
WHERE exercise_id = %s ORDER BY slot_index ASC, id ASC""",
(exercise_id,),
)
slot_rows = [r2d(r) for r in cur.fetchall()]
slots_out: List[dict] = []
for sr in slot_rows:
slot_pk = sr["id"]
cur.execute(
"""SELECT candidate_exercise_id FROM combination_slot_candidates
WHERE slot_id = %s ORDER BY sort_order ASC, id ASC""",
(slot_pk,),
)
crows = cur.fetchall()
cids = [int(r2d(c)["candidate_exercise_id"]) for c in crows]
cand_meta: Dict[int, Optional[str]] = {}
if cids:
ph = ",".join(["%s"] * len(cids))
cur.execute(
f"SELECT id, title FROM exercises WHERE id IN ({ph})",
tuple(cids),
)
cand_meta = {int(r2d(x)["id"]): r2d(x).get("title") for x in cur.fetchall()}
slots_out.append(
{
"slot_index": sr["slot_index"],
"title": sr.get("title"),
"candidate_exercise_ids": cids,
"candidates": [
{"exercise_id": cid, "title": cand_meta.get(cid)} for cid in cids
],
}
)
exercise["combination_slots"] = slots_out
return exercise
@ -1528,6 +1733,10 @@ def list_exercises(
default=False,
description="Nur Übungen, die vom aktuellen Profil angelegt wurden (created_by = Profil)",
),
exercise_kind_any: list[str] = Query(
default=[],
description="ODER: mind. einer dieser Übungsarten — simple oder combination",
),
tenant: TenantContext = Depends(get_tenant_context),
):
"""
@ -1558,6 +1767,21 @@ def list_exercises(
where.append("e.created_by = %s")
params.append(profile_id)
ek_filtered: List[str] = []
if exercise_kind_any:
for raw in exercise_kind_any:
s = str(raw or "").strip().lower()
if not s:
continue
if s not in ("simple", "combination"):
raise HTTPException(status_code=400, detail="exercise_kind_any: nur simple oder combination")
if s not in ek_filtered:
ek_filtered.append(s)
if ek_filtered:
ph = ",".join(["%s"] * len(ek_filtered))
where.append(f"(LOWER(TRIM(COALESCE(e.exercise_kind::text,''))) IN ({ph}))")
params.extend(ek_filtered)
vis_list = _merge_str_any(visibility_any, visibility)
if vis_list:
ph = ",".join(["%s"] * len(vis_list))
@ -1776,6 +2000,7 @@ def list_exercises(
# Query (primary_focus_name für Listen-Ansicht gemäß Spec-Beispiel „focus_area“-Label)
query = f"""
SELECT e.id, e.title, e.summary, e.visibility, e.status,
e.exercise_kind, e.method_archetype,
e.created_by, p.name as creator_name,
e.club_id, c.name as club_name,
e.created_at, e.updated_at,
@ -1829,6 +2054,7 @@ def list_exercises(
out = []
for r in rows:
d = r2d(r)
d["exercise_kind"] = str(d.get("exercise_kind") or "simple").strip().lower()
pfn = d.get("primary_focus_name")
d["focus_area"] = pfn
d["focus_area_names"] = _coerce_json_str_list(d.get("focus_area_names"))
@ -1913,16 +2139,39 @@ def create_exercise(
cur, profile_id, tenant.global_role, body.visibility, club_id
)
kind_clean = str(body.exercise_kind or "simple").strip().lower()
if kind_clean not in ("simple", "combination"):
raise HTTPException(status_code=400, detail="exercise_kind: simple oder combination")
prof_dict = _normalize_method_profile_store(body.method_profile)
arch_raw = body.method_archetype
arch_val = (arch_raw.strip() if isinstance(arch_raw, str) and arch_raw.strip() else None)
_validate_archetype_for_kind(kind_clean, arch_val)
slots_norm: List[Tuple[int, Optional[str], List[int]]] = []
if kind_clean == "combination":
slots_norm = _validate_and_normalize_combination_slots_payload(
cur, tenant, body.combination_slots or []
)
if not slots_norm:
raise HTTPException(
status_code=400,
detail="Kombinationsübung: mindestens eine Station mit Übungen nötig",
)
mp_json = Json(prof_dict if kind_clean == "combination" else {})
arch_db = arch_val if kind_clean == "combination" else None
# Equipment als JSONB
equipment_json = json.dumps(body.equipment) if body.equipment else None
# INSERT
cur.execute(
"""INSERT INTO exercises
(title, summary, goal, execution, preparation, trainer_notes,
duration_min, duration_max, group_size_min, group_size_max,
equipment, visibility, status, created_by, club_id)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
equipment, visibility, status, created_by, club_id,
exercise_kind, method_archetype, method_profile)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id""",
(
body.title, body.summary, body.goal, body.execution,
@ -1931,11 +2180,15 @@ def create_exercise(
body.group_size_min, body.group_size_max,
equipment_json,
body.visibility, body.status, profile_id, club_id,
)
kind_clean, arch_db, mp_json,
),
)
row = cur.fetchone()
exercise_id = row['id'] if isinstance(row, dict) else row[0]
if kind_clean == "combination":
replace_combination_slots(cur, tenant, exercise_id, slots_norm)
data = body.dict()
assign_exercise_relations(cur, conn, exercise_id, data, do_commit=False)
if (body.visibility or "").strip().lower() == "club":
@ -1963,7 +2216,7 @@ def update_exercise(
cur = get_cursor(conn)
cur.execute(
f"""SELECT created_by, visibility, club_id,
f"""SELECT created_by, visibility, club_id, exercise_kind, method_archetype, method_profile,
{", ".join(sorted(RICH_HTML_EXERCISE_FIELDS))}
FROM exercises WHERE id = %s""",
(exercise_id,),
@ -1993,6 +2246,84 @@ def update_exercise(
default_official_copy = data.pop("default_official_media_copyright", None)
default_club_copy = data.pop("default_club_media_copyright", None)
combo_slots_provided = "combination_slots" in data
combo_slots_payload = data.pop("combination_slots", None)
ec_kind_was = str(rd_full.get("exercise_kind") or "simple").strip().lower()
ek_provided = "exercise_kind" in data
next_kind = ec_kind_was
if ek_provided:
next_kind = str(data.pop("exercise_kind") or "simple").strip().lower()
arche_provided = "method_archetype" in data
meth_prof_provided = "method_profile" in data
next_ma_db = rd_full.get("method_archetype")
if isinstance(next_ma_db, str):
next_ma_db = next_ma_db.strip() or None
else:
next_ma_db = None
mp_row = rd_full.get("method_profile")
next_mp_db = mp_row if isinstance(mp_row, dict) else {}
if ek_provided and next_kind not in ("simple", "combination"):
raise HTTPException(status_code=400, detail="exercise_kind: simple oder combination")
if arche_provided:
va = data.pop("method_archetype")
if va is None or (isinstance(va, str) and not va.strip()):
next_ma_db = None
elif isinstance(va, str):
next_ma_db = va.strip() or None
else:
next_ma_db = None
if meth_prof_provided:
next_mp_db = _normalize_method_profile_store(data.pop("method_profile"))
if next_kind == "simple":
next_ma_db = None
next_mp_db = {}
_validate_archetype_for_kind(next_kind, next_ma_db)
if ec_kind_was == "simple" and next_kind == "combination":
if not combo_slots_provided:
raise HTTPException(
status_code=400,
detail='Umschalten auf Kombinationsübung: Feld "combination_slots" ist erforderlich',
)
combo_slots_normalized: Optional[List[Tuple[int, Optional[str], List[int]]]] = None
if combo_slots_provided:
if next_kind != "combination":
raise HTTPException(
status_code=400,
detail="combination_slots nur bei exercise_kind=combination erlaubt",
)
slots_in_raw = combo_slots_payload if combo_slots_payload is not None else []
slots_in: List[CombinationSlotIn] = []
for s in slots_in_raw:
if isinstance(s, CombinationSlotIn):
slots_in.append(s)
elif isinstance(s, dict):
slots_in.append(CombinationSlotIn(**s))
else:
raise HTTPException(status_code=400, detail="Ungültige combination_slots Payload-Struktur")
combo_slots_normalized = _validate_and_normalize_combination_slots_payload(
cur, tenant, slots_in
)
if not combo_slots_normalized:
raise HTTPException(status_code=400, detail="Kombinationsübung: mindestens eine Station mit Übungen")
update_combo_cols = (
ek_provided
or arche_provided
or meth_prof_provided
or (next_kind == "simple" and ec_kind_was != "simple")
)
merged_rich = {fld: rich_row.get(fld) for fld in RICH_HTML_EXERCISE_FIELDS}
for fld in RICH_HTML_EXERCISE_FIELDS:
if fld not in data:
@ -2064,11 +2395,27 @@ def update_exercise(
fields.append("equipment = %s")
params.append(json.dumps(data["equipment"]) if data["equipment"] else None)
if update_combo_cols:
if ek_provided:
fields.append("exercise_kind = %s")
params.append(next_kind)
fields.append("method_archetype = %s")
params.append(next_ma_db)
fields.append("method_profile = %s")
params.append(Json(next_mp_db))
if fields:
fields.append("updated_at = NOW()")
params.append(exercise_id)
query = f"UPDATE exercises SET {', '.join(fields)} WHERE id = %s"
cur.execute(query, params)
elif combo_slots_normalized is not None:
cur.execute("UPDATE exercises SET updated_at = NOW() WHERE id = %s", (exercise_id,))
if combo_slots_normalized is not None:
replace_combination_slots(cur, tenant, exercise_id, combo_slots_normalized)
elif ec_kind_was == "combination" and next_kind == "simple":
wipe_combination_structure(cur, exercise_id)
assign_exercise_relations(cur, conn, exercise_id, data, do_commit=False)
try:
@ -2144,6 +2491,7 @@ def reorder_exercise_variants(
with get_db() as conn:
cur = get_cursor(conn)
_assert_can_edit_exercise(cur, exercise_id, tenant)
assert_exercise_not_combination(cur, exercise_id)
cur.execute(
"SELECT id FROM exercise_variants WHERE exercise_id = %s",
@ -2178,6 +2526,7 @@ def create_exercise_variant(
with get_db() as conn:
cur = get_cursor(conn)
_assert_can_edit_exercise(cur, exercise_id, tenant)
assert_exercise_not_combination(cur, exercise_id)
_validate_variant_prerequisite(cur, exercise_id, body.prerequisite_variant_id)
eq_json = _variant_equipment_json(body.equipment_changes)
@ -2242,6 +2591,7 @@ def update_exercise_variant(
with get_db() as conn:
cur = get_cursor(conn)
_assert_can_edit_exercise(cur, exercise_id, tenant)
assert_exercise_not_combination(cur, exercise_id)
old = _fetch_variant_row(cur, exercise_id, variant_id)
if "variant_name" in data and data["variant_name"] is not None:
@ -2323,6 +2673,7 @@ def delete_exercise_variant(
with get_db() as conn:
cur = get_cursor(conn)
_assert_can_edit_exercise(cur, exercise_id, tenant)
assert_exercise_not_combination(cur, exercise_id)
_fetch_variant_row(cur, exercise_id, variant_id)
cur.execute(

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.98"
APP_VERSION = "0.8.99"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260512055"
DB_SCHEMA_VERSION = "20260512056"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@ -21,7 +21,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
"exercises": "2.23.0", # P-11: enrich_exercise_detail + download_file blocken Legal-Hold-Assets (451)
"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)
@ -35,6 +35,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.99",
"date": "2026-05-12",
"changes": [
"exercises Phase 2 (Kombinationsübungen): Migration 056 (`exercise_kind`, `method_archetype`, `method_profile`; Tabellen Slots/Kandidaten); CRUD über POST/PUT/GET Übung mit `combination_slots`; Liste-Filter `exercise_kind_any`; Varianten-Endpoints verbieten `exercise_kind=combination`.",
],
},
{
"version": "0.8.98",
"date": "2026-05-12",

View File

@ -225,6 +225,7 @@ export default function ExercisePickerModal({
...queryBase,
include_archived: true,
include_variants: true,
exercise_kind_any: ['simple'],
limit: PAGE_SIZE,
offset: 0,
})
@ -253,6 +254,7 @@ export default function ExercisePickerModal({
...queryBase,
include_archived: true,
include_variants: true,
exercise_kind_any: ['simple'],
limit: PAGE_SIZE,
offset,
})

View File

@ -136,6 +136,42 @@ function ExerciseDetailPage() {
{meta.length > 0 && <p className="exercise-meta-line">{meta.join(' · ')}</p>}
</div>
{(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' &&
Array.isArray(exercise.combination_slots) &&
exercise.combination_slots.length > 0 && (
<section className="card exercise-detail-section">
<h2>Stationen und Übungspools</h2>
{exercise.method_archetype ? (
<p style={{ fontSize: '14px', color: 'var(--text2)', marginTop: 0 }}>
Archetyp: <code>{String(exercise.method_archetype)}</code>
</p>
) : null}
<ol style={{ paddingLeft: '1.25rem', marginBottom: 0 }}>
{exercise.combination_slots.map((s) => (
<li key={`${s.slot_index}-${(s.title || '').slice(0, 8)}`} style={{ marginBottom: '10px' }}>
<strong>
Station {s.slot_index != null ? s.slot_index : '?'}{s.title ? `${s.title}` : ''}
</strong>
<ul style={{ margin: '4px 0 0', paddingLeft: '1.2rem' }}>
{(s.candidates && s.candidates.length
? s.candidates
: (s.candidate_exercise_ids || []).map((id) => ({
exercise_id: id,
title: null,
}))
).map((c) => (
<li key={c.exercise_id}>
<Link to={`/exercises/${c.exercise_id}`}>Übung #{c.exercise_id}</Link>
{c.title ? `${c.title}` : ''}
</li>
))}
</ul>
</li>
))}
</ol>
</section>
)}
{exercise.goal && (
<section className="card exercise-detail-section">
<h2>Ziel</h2>

View File

@ -30,6 +30,29 @@ const VARIANT_DIFFICULTY = [
{ value: 'adapted', label: 'Angepasst' },
]
/** An API `method_archetype` (Backend `COMBINATION_ARCHETYPE_IDS`) */
const COMBINATION_ARCHETYPE_OPTIONS = [
{ id: 'sequence_linear', label: 'Lineare Sequenz' },
{ id: 'circuit_rotate_time', label: 'Rotierender Zirkel (Zeit)' },
{ id: 'circuit_all_parallel', label: 'Parallele Stationen' },
{ id: 'station_parcour', label: 'Parcours' },
{ id: 'pair_superset', label: 'Partner- / Paarwechsel' },
{ id: 'time_domain_interval', label: 'Intervallblock (Zeitdomäne)' },
{ id: 'free_method_block', label: 'Freier Methodenblock' },
]
function comboSlotsFromDetail(exercise) {
const raw = exercise?.combination_slots
if (!Array.isArray(raw) || raw.length === 0) {
return [{ slot_index: 0, title: '', idsText: '' }]
}
return raw.map((s, i) => ({
slot_index: s.slot_index != null ? Number(s.slot_index) : i,
title: s.title != null ? String(s.title) : '',
idsText: Array.isArray(s.candidate_exercise_ids) ? s.candidate_exercise_ids.join(', ') : '',
}))
}
function emptyVariantDraft() {
return {
variant_name: '',
@ -249,6 +272,10 @@ function emptyForm() {
visibility: 'private',
status: 'draft',
skills: [],
exercise_kind: 'simple',
method_archetype: '',
method_profile_json: '{}',
combination_slots: [{ slot_index: 0, title: '', idsText: '' }],
}
}
@ -291,6 +318,18 @@ function detailToForm(exercise) {
required_level: normalizeSkillLevelSlug(s.required_level),
target_level: normalizeSkillLevelSlug(s.target_level),
})) || [],
exercise_kind:
String(exercise.exercise_kind || 'simple').toLowerCase() === 'combination'
? 'combination'
: 'simple',
method_archetype: exercise.method_archetype != null ? String(exercise.method_archetype) : '',
method_profile_json:
typeof exercise.method_profile === 'object' &&
exercise.method_profile != null &&
!Array.isArray(exercise.method_profile)
? JSON.stringify(exercise.method_profile, null, 2)
: '{}',
combination_slots: comboSlotsFromDetail(exercise),
}
}
@ -949,6 +988,180 @@ function ExerciseFormPage() {
/>
</div>
<div
style={{
padding: '12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
marginBottom: '12px',
}}
>
<h3 style={{ marginTop: 0, marginBottom: '10px', fontSize: '1rem' }}>Übungstyp</h3>
<div className="form-row">
<label className="form-label">Art</label>
<select
className="form-input"
value={formData.exercise_kind === 'combination' ? 'combination' : 'simple'}
onChange={(e) => {
const nk = e.target.value
setFormDirty(true)
setFormData((prev) => ({
...prev,
exercise_kind: nk,
...(nk === 'simple'
? {
method_archetype: '',
method_profile_json: '{}',
combination_slots: [{ slot_index: 0, title: '', idsText: '' }],
}
: {}),
}))
}}
>
<option value="simple">Einzelübung</option>
<option value="combination">Kombinationsübung (Stationen / Pool)</option>
</select>
</div>
{formData.exercise_kind === 'combination' ? (
<>
<div className="form-row">
<label className="form-label">Methoden-Archetyp (optional)</label>
<select
className="form-input"
value={formData.method_archetype || ''}
onChange={(e) => updateFormField('method_archetype', e.target.value)}
>
<option value=""> später wählen </option>
{COMBINATION_ARCHETYPE_OPTIONS.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</div>
<div className="form-row">
<label className="form-label">Ablaufprofil (JSON, optional)</label>
<textarea
className="form-input"
rows={4}
value={formData.method_profile_json || '{}'}
onChange={(e) => updateFormField('method_profile_json', e.target.value)}
spellCheck={false}
placeholder='{"work_seconds":45,"rest_seconds":15,"rounds":3}'
/>
</div>
<div>
<strong style={{ fontSize: '14px' }}>Stationen</strong>
<p style={{ fontSize: '12px', color: 'var(--text2)', margin: '6px 0 10px' }}>
Index (Reihenfolge), optional Stationstitel und kommaseparierte IDs von{' '}
<strong>Einzelübungen</strong> im Pool (nur Übungen mit Art Einzelübung).
</p>
{(formData.combination_slots || []).map((row, idx) => (
<div
key={`cs-${idx}`}
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0, 72px) minmax(0, 1fr) minmax(0, 1.2fr) auto',
gap: '8px',
marginBottom: '8px',
alignItems: 'end',
}}
>
<div className="form-row">
<label className="form-label" style={{ fontSize: '12px' }}>
Idx.
</label>
<input
type="number"
min={0}
max={99}
className="form-input"
value={row.slot_index}
onChange={(e) => {
const next = [...(formData.combination_slots || [])]
const v = e.target.value
next[idx] = {
...row,
slot_index: v === '' ? '' : parseInt(v, 10),
}
updateFormField('combination_slots', next)
}}
/>
</div>
<div className="form-row">
<label className="form-label" style={{ fontSize: '12px' }}>
Titel
</label>
<input
type="text"
className="form-input"
value={row.title || ''}
onChange={(e) => {
const next = [...(formData.combination_slots || [])]
next[idx] = { ...row, title: e.target.value }
updateFormField('combination_slots', next)
}}
/>
</div>
<div className="form-row">
<label className="form-label" style={{ fontSize: '12px' }}>
Übungs-IDs
</label>
<input
type="text"
className="form-input"
value={row.idsText || ''}
onChange={(e) => {
const next = [...(formData.combination_slots || [])]
next[idx] = { ...row, idsText: e.target.value }
updateFormField('combination_slots', next)
}}
placeholder="z. B. 12, 34, 56"
/>
</div>
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '6px 8px' }}
onClick={() => {
const prev = formData.combination_slots || []
const next = prev.filter((_, j) => j !== idx)
updateFormField(
'combination_slots',
next.length ? next : [{ slot_index: 0, title: '', idsText: '' }],
)
}}
>
Entf.
</button>
</div>
))}
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px', marginTop: '4px' }}
onClick={() => {
const cur = formData.combination_slots || []
const ixList = cur
.map((r) =>
typeof r.slot_index === 'number' && !Number.isNaN(r.slot_index) ? r.slot_index : null,
)
.filter((n) => n != null)
const nextIx = ixList.length ? Math.max(...ixList) + 1 : 0
updateFormField('combination_slots', [
...cur,
{ slot_index: nextIx, title: '', idsText: '' },
])
}}
>
+ Station
</button>
</div>
</>
) : null}
</div>
<div className="form-row">
<label className="form-label">Ziel *</label>
<RichTextEditor
@ -1219,7 +1432,7 @@ function ExerciseFormPage() {
</form>
</div>
{isEdit && (
{isEdit && formData.exercise_kind !== 'combination' && (
<details ref={variantsDetailsRef} className="card exercise-variants-details" style={{ marginTop: '16px' }}>
<summary className="exercise-variants-summary">
<span className="exercise-variants-summary__title">Übungsvarianten</span>
@ -1380,7 +1593,7 @@ function ExerciseFormPage() {
</details>
)}
{isEdit && (
{isEdit && formData.exercise_kind !== 'combination' && (
<details className="card exercise-variants-details" style={{ marginTop: '16px' }}>
<summary className="exercise-variants-summary">
<span className="exercise-variants-summary__title">Progressionsgraph</span>

View File

@ -1408,6 +1408,11 @@ function ExercisesListPage() {
{typeNames.map((name) => (
<span key={`tt:${name}`} className="exercise-tag exercise-tag--training">{name}</span>
))}
{(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' ? (
<span className="exercise-tag" style={{ background: 'var(--accent-soft)', color: 'var(--accent-dark)' }}>
Kombination
</span>
) : null}
</div>
{exercise.summary && String(exercise.summary).trim() ? (
<div className="exercise-card-summary exercise-card-summary--rich">

View File

@ -451,7 +451,7 @@ export function buildExerciseApiPayload(formData, extras = {}) {
.filter((x) => x && x.target_group_id)
.map((x) => ({ target_group_id: Number(x.target_group_id), is_primary: !!x.is_primary }))
return {
const payload = {
title: (formData.title || '').trim(),
summary: formData.summary || null,
goal: goalHtml.trim() ? goalHtml : null,
@ -478,8 +478,65 @@ export function buildExerciseApiPayload(formData, extras = {}) {
visibility: formData.visibility || 'private',
status: formData.status || 'draft',
club_id: formData.club_id ?? null,
exercise_kind:
String(formData.exercise_kind || 'simple').toLowerCase() === 'combination'
? 'combination'
: 'simple',
...extras,
}
const isCombo = payload.exercise_kind === 'combination'
if (isCombo) {
let mpObj = {}
const mpRaw = typeof formData.method_profile_json === 'string' ? formData.method_profile_json.trim() : ''
if (mpRaw) {
try {
const parsed = JSON.parse(mpRaw)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Ablaufprofil muss ein JSON-Objekt sein.')
}
mpObj = parsed
} catch (e) {
if (e instanceof SyntaxError) {
throw new Error('Ablaufprofil (JSON): Syntax ungültig.')
}
throw e
}
}
const slotRows = Array.isArray(formData.combination_slots) ? formData.combination_slots : []
const combination_slots = []
for (let i = 0; i < slotRows.length; i += 1) {
const row = slotRows[i] || {}
const ix =
row.slot_index === '' || row.slot_index == null ? i : parseInt(row.slot_index, 10)
if (Number.isNaN(ix) || ix < 0 || ix > 99) {
throw new Error(`Station Index ungültig (Zeile ${i + 1}).`)
}
const idsText = typeof row.idsText === 'string' ? row.idsText : ''
const candidate_exercise_ids = idsText
.split(/[\s,;]+/)
.map((s) => s.trim())
.filter(Boolean)
.map((s) => parseInt(s, 10))
.filter((n) => Number.isFinite(n))
combination_slots.push({
slot_index: ix,
title: (typeof row.title === 'string' && row.title.trim()) || null,
candidate_exercise_ids,
})
}
payload.method_archetype = (formData.method_archetype || '').trim() || null
payload.method_profile = mpObj
payload.combination_slots = combination_slots
} else {
payload.method_archetype = null
payload.method_profile = {}
}
return payload
}
export async function uploadExerciseMedia(exerciseId, formData) {