diff --git a/.claude/docs/functional/DOMAIN_MODEL.md b/.claude/docs/functional/DOMAIN_MODEL.md
index 8de4ca0..f951050 100644
--- a/.claude/docs/functional/DOMAIN_MODEL.md
+++ b/.claude/docs/functional/DOMAIN_MODEL.md
@@ -1,7 +1,7 @@
# Shinkan Jinkendo - Fachliches Domänenmodell
-**Version:** 0.4.5
-**Stand:** 2026-05-12 (Fachlicher Nutzerüberblick: `docs/FACHLICHE_NUTZERFUNKTIONEN.md`)
+**Version:** 0.4.6
+**Stand:** 2026-05-14 (Fachlicher Nutzerüberblick: `docs/FACHLICHE_NUTZERFUNKTIONEN.md`)
**Basis:** `shinkan_anforderungsdokument_entwurf.md` + Fähigkeitsmatrix
---
@@ -474,6 +474,14 @@ skill_level_definitions (
**Konkretisierung (037/API):** `POST /api/training-units/from-framework-slot` legt eine geplante Einheit aus dem Slot‑Blueprint an; **`origin_framework_slot_id`** dient als Herkunftsreferenz (**Lineage light**; weiteres Feedback/Lineage‑Konzept: Konzeptpapier Schritt **E**).
+### Parallele Trainingsstreams (Breakout, Entwurf)
+
+**Fachlich:** Eine Kalender‑**Einheit** kann aus **Phasen** bestehen — z. B. gemeinsamer Block, dann **beliebig viele parallele** „Teilstrecken“ (**Streams**) mit je eigenem Miniplan (Abschnitte/Übungen), erneut gemeinsamer Block. Das ist **nicht** dasselbe wie ein **Rahmenprogramm‑Slot** (Serien‑Session über Wochen): Slots strukturieren **mehrere Einheiten** in einem Programm; **Streams** strukturieren **gleichzeitige** Abläufe **innerhalb einer** Einheit.
+
+**Sonderfall Stationen:** Rotation kann **innerhalb** einer Stream‑Planung über **Kombinationsübungen** (Methodenprofil/Archetyp) abgebildet werden; hallenweit **synchron** getaktete Rotation ist eine **erweiterte** Ausbaustufe (siehe Fachkonzept).
+
+**Dokumentation:** `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, Umsetzung `technical/PARALLEL_TRAINING_STREAMS_SPEC.md`.
+
---
## Medien-Archiv & Übungs-Anhänge (Stand 2026-05-07)
diff --git a/.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md b/.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md
new file mode 100644
index 0000000..0023cf8
--- /dev/null
+++ b/.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md
@@ -0,0 +1,106 @@
+# Parallele Trainingsstreams (Breakout) — Fachkonzept
+
+**Status:** Entwurf zur Abstimmung · **Stand:** 2026-05-14
+**Ziel:** Planung und Durchführung von Training mit **phasenweise gemeinsamem** Ablauf und **beliebig vielen parallelen Teilstrecken** (Breakout-Sessions), inkl. Sonderfall **rotierende Stationen**.
+
+**Technische Ausarbeitung:** `.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md`
+**Domänenbegriffe (Überblick):** `.claude/docs/functional/DOMAIN_MODEL.md` (Abschnitt Parallele Streams)
+
+---
+
+## 1. Ausgangslage und Problem
+
+In Kinder- und Breitensport-Training ist ein typischer Ablauf:
+
+1. **Gemeinsam:** Aufwärmen, Koordination, Ansagen.
+2. **Getrennt:** Kinder in mehrere Gruppen teilen; **Co-Trainer** leiten jeweils eigene Inhalte **gleichzeitig**.
+3. **Gemeinsam:** Abschluss, gemeinsame Übungen, Verabschiedung.
+
+Die aktuelle Shinkan-Planung modelliert pro Termin **eine lineare Folge von Abschnitten und Übungen** pro Einheit. Das genügt nicht, wenn **mehrere gleichzeitige „Unter-Sessions“** mit unterschiedlichen Plänen dokumentiert und auf der Matte geführt werden sollen.
+
+---
+
+## 2. Ziele (fachlich)
+
+| ID | Ziel |
+|----|------|
+| PT‑01 | Eine **Kalender-Einheit** bleibt **ein** Termin (eine Halle, eine Gruppe, ein Datum) — kein Splitten in künstlich mehrere Kalendereinträge nur für Parallelität. |
+| PT‑02 | **Unbegrenzte** Anzahl paralleler **Streams** (Teilstrecken) in einer oder mehreren **Parallelphasen**. |
+| PT‑03 | **Phasenmodell:** klar erkennbar **Gemeinsam** vs. **Parallel** vs. wieder **Gemeinsam** (auch mehrfach hintereinander möglich). |
+| PT‑04 | **Rollen:** Leitung (Haupttrainer) und Co-Trainer; Zuordnung der Co-Trainer **soll** an konkrete Streams anschließbar sein (heute: nur flache Liste pro Einheit — siehe technische Spec). |
+| PT‑05 | **Sonderfall Stationen:** rotierender Ablauf (z. B. Wechsel alle 20 Min.) **inhaltlich** unterscheiden zwischen (a) Rotation **innerhalb** einer Teilstrecke und (b) **synchron** getakteter Hallen-Rotation — siehe §5. |
+| PT‑06 | **Durchführung:** Trainer können „ihre“ Spur auf dem Gerät abarbeiten; Fortschritt pro Spur nachvollziehbar. |
+
+**Nicht-Ziel (frühe Stufen):** Echtzeit-Synchronisation mehrerer Geräte; individuelles Athleten-Tracking; automatische Raumbelegung.
+
+---
+
+## 3. Begriffe
+
+| Begriff | Definition |
+|---------|------------|
+| **Einheit / Termin** | Geplante `training_unit` für Gruppe und Datum — übergeordneter Rahmen des Abends. |
+| **Phase** | Organisatorischer Block innerhalb der Einheit: entweder **ganze Gruppe** oder **parallel**. |
+| **Stream / Teilstrecke** | Innerhalb einer Parallelphase: eine von N **gleichzeitig** stattfindenden Unter-Abläufen mit **eigenem** Miniplan (Abschnitte, Übungen, Notizen — analog heutiger Planung). |
+| **Synchronisationspunkt** | Fachlich: alle treffen sich wieder (Beginn einer **Gemeinschaftsphase** nach Parallelität). |
+| **Station (Rotation)** | Inhaltlicher Fokus oder Platz, den Teilnehmer **wechselnd** anlaufen; kann als Kombinations-/Zirkellogik oder als koordinierter Hallenrhythmus modelliert werden (§5). |
+
+**Abgrenzung „Rahmenprogramm-Slot“:** Ein Slot im **Rahmenprogramm** ist eine **Session in einer Serie** (z. B. Woche 1 vs. Woche 2), **nicht** „Teilgruppe A gleichzeitig mit Teilgruppe B in derselben Stunde“. Parallele Streams sind **innerhalb einer Einheit**, orthogonal zum Rahmen-Slot.
+
+**Abgrenzung **Kombinationsübung**:** Eine Kombi-Übung bündelt **mehrere Einzelübungen** mit Methodenprofil (Archetyp, ggf. Rotation) **in einem Plan-Item**. Sie ersetzt **nicht** mehrere Trainer mit **jeweils eigenem Gesamtablauf**, kann aber **pro Stream** für Stationslogik genutzt werden.
+
+---
+
+## 4. Szenarien
+
+### 4.1 Klassischer Breakout
+
+30 Min. gemeinsam → 25 Min. drei parallele Streams (Gruppe an Matte / an Schlagsack / Fußarbeit) → 15 Min. gemeinsam.
+
+### 4.2 Viele Kinder, mehrere Co-Trainer
+
+Haupttrainer plant die Gesamtstruktur; jeder Co-Trainer sieht in der Durchführung primär die zugewiesene Teilstrecke.
+
+### 4.3 Rollierendes Stationssystem
+
+Alle Gruppen arbeiten an **verschiedenen Schwerpunkten** und **wechseln** nach festem Intervall die Station — entweder **nur innerhalb einer Spur** oder **hallenweit synchron** (offene fachliche Präzisierung in MVP vs. später, §5).
+
+---
+
+## 5. Sonderfall: Stationen und Kombinationsübungen
+
+### 5.1 Variante A — Rotation innerhalb einer Teilstrecke
+
+Eine Teilgruppe rotiert durch mehrere Übungen (Zeit oder Runden). Das liegt nah an einer **Kombinationsübung** mit Archetyp z. B. „Zirkel / zeitgesteuerte Rotation“ und Parametern (Wechselintervall). **Empfehlung:** Diese Variante über **bestehendes** Kombinationsübungs-Konzept in der jeweiligen **Stream-Planung** abbilden (`planning_method_profile`).
+
+### 5.2 Variante B — Synchron getaktete Hallen-Rotation
+
+Alle Streams (oder alle Kinder insgesamt) **wechseln gleichzeitig** zur nächsten Station; Startstation kann pro Teilgruppe **versetzt** sein. Das ist **organisatorisch** schwerer: es braucht entweder **Phasen-Metadaten** (globaler Takt) oder eine explizite **Rot/Matrix**. **Empfehlung:** In einer **zweiten Ausbaustufe** abbilden; MVP kann bei Variante A starten, sofern fachlich ausreichend.
+
+---
+
+## 6. Rollen und Verantwortlichkeiten
+
+- **Leitungstrainer:** Hält den Faden, startet Gemeinschaftsphasen, koordiniert Parallelbeginn/-ende (fachlich; ggf. später UI-Hinweise).
+- **Co-Trainer:** Verantwortlich für **zugeteilte** Streams; Zuordnung soll **pro Stream** möglich werden (Erweiterung gegenüber reiner Einheits-Co-Trainer-Liste).
+
+---
+
+## 7. Offene fachliche Entscheidungen
+
+1. **MVP Umfang:** Reicht **freie Parallelität** ohne **synchronen** Hallenwechsel (Variante B)?
+2. **Dauer:** Sollen Phasen oder Streams **Soll-Minuten** tragen (nur Anzeige vs. später Timer)?
+3. **Vorlagen:** Müssen `training_plan_templates` parallel-fähig werden **vor** oder **mit** der ersten Implementierung?
+4. **Sichtbarkeit:** Dürfen alle Co-Trainer alle Streams sehen, oder „nur meine Spur“?
+
+---
+
+## 8. Verwandte Dokumente
+
+| Dokument | Bezug |
+|----------|--------|
+| `technical/TRAINING_FRAMEWORK_SPEC.md` | Rahmen-Slots = Serien-Sessions, **nicht** Intra-Einheit-Parallelität |
+| `technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md` | Kombinationsübungen, Archetypen, Stationslogik **im Item** |
+| `functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` | Fachliche Tiefe Kombi |
+| `docs/FACHLICHE_NUTZERFUNKTIONEN.md` | Nutzerüberblick |
+| `technical/DATABASE_SCHEMA.md` | Aktueller Stand Tabellen |
diff --git a/.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md b/.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md
new file mode 100644
index 0000000..5d894e3
--- /dev/null
+++ b/.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md
@@ -0,0 +1,130 @@
+# Parallele Trainingsstreams — Technische Spezifikation (Umsetzung)
+
+**Status:** Entwurf · **Stand:** 2026-05-14
+**Fachgrundlage:** `.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`
+
+Dieses Dokument beschreibt die **Umsetzung** auf Basis der **aktuellen Codebasis** (Stand Analyse 2026-05-14): eine `training_unit` mit **`training_unit_sections`** und **`training_unit_section_items`** (Übung/Notiz, optional `planning_method_profile` für Kombinationsübungen, Migration **057**); Rahmen-**Blueprint**-Einheiten mit `framework_slot_id` (**037**); Leitung **`lead_trainer_profile_id`** (**038**); Co-Trainer **`assistant_trainer_profile_ids`** JSONB (**042**); Durchführung **`TrainingUnitRunPage`** (sequentiell über Sektionen).
+
+---
+
+## 1. Ist-Stand (relevant)
+
+| Bereich | Aktuell |
+|---------|---------|
+| Planstruktur | **Eine** lineare Liste `training_unit_sections` je `training_unit_id`; Items in `training_unit_section_items`. |
+| Rahmenprogramm | `training_framework_slots` verweisen auf **Blueprint**-`training_units` — Slots = **Serien-Spalten**, nicht simultane Breakouts in **einer** Halle. |
+| Kombinationsübung | Ein **Item** kann Kombi sein; `planning_method_profile` = Snapshot; Coaching-UI teilweise (`CombinationPlanBracket` in Run/Peek). |
+| Trainer-Zuweisung | `lead_trainer_profile_id`, `assistant_trainer_profile_ids` am **`training_units`**-Kopf; **keine** Zuordnung zu „welcher parallelen Spur“. |
+| Run-Modus | `TrainingUnitRunPage`: sortierte Sektionen/Items, Checkliste, Fortschritt in `sessionStorage` pro Einheit. |
+
+**Konsequenz:** Parallele Streams erfordern ein **erweitertes konzeptionelles „Gefäß“** unterhalb der Einheit (Phasen und/oder Streams) und eine **Verknüpfung** bestehender Sektionen mit diesem Gefäß — oder eine **Migration** zu einem neuen Pflicht-Container (siehe §3).
+
+---
+
+## 2. Zielarchitektur (logisch)
+
+```
+training_unit (Kalender-Einheit)
+├── phase (order, kind: whole_group | parallel, optional Metadaten)
+│ ├── [whole_group] → sections[] → items[] (wie heute)
+│ └── [parallel] → stream (order, label, optional trainer_ids[])
+│ └── sections[] → items[]
+```
+
+**Abwärtskompatibilität:** Einheiten **ohne** explizite Phasen/Streams verhalten sich wie heute: **implizit** eine einzige „Gemeinschaftsphase“ mit den vorhandenen Sektionen (Migration: alle bestehenden Sektionen an diese Default-Hülle hängen).
+
+---
+
+## 3. Datenmodell — Optionen
+
+### 3.1 Empfohlen: explizite Phasen + Streams (normalisiert)
+
+Neue Tabellen (Namen bei Implementierung final festlegen):
+
+| Tabelle | Zweck |
+|---------|--------|
+| `training_unit_phases` | `training_unit_id`, `order_index`, `phase_kind` (`whole_group` \| `parallel`), optional `title`, `guidance_notes`, optional `planned_duration_min` |
+| `training_unit_parallel_streams` | `phase_id` (FK, nur wenn parent parallel), `order_index`, `title`/`label`, optional `notes`, optional `assigned_trainer_profile_ids` JSONB (oder 1:n-Hilfstabelle) |
+
+**Anpassung `training_unit_sections`:** Zusätzliche FK-Spalte(n), z. B.:
+
+- `phase_id` **NULL** und `parallel_stream_id` **NULL** → **Legacy / Default-Einheitsphase** (Migration setzt Default-Phase); oder
+- genau einer von `phase_id` (whole group) oder `parallel_stream_id` gesetzt.
+
+**Constraints:** CHECK: nicht beide gesetzt; bei `phase_kind = parallel` Sektionen nur unter `parallel_stream_id`; bei `whole_group` nur unter `phase_id`.
+
+**Vorteil:** Klare Semantik, Reporting, API-Shape konsistent.
+
+### 3.2 Minimalvariante (nicht ideal fachlich)
+
+Nur **`training_unit_parallel_streams`** + `parallel_stream_id` auf Sektionen; Phasen implizit durch „Marker“-Sektionen oder Konvention. **Nicht empfohlen**, erschwert UI und Erklärbarkeit.
+
+---
+
+## 4. API
+
+- **`GET /api/training-units/:id`** (und Listen-Payloads wo vollständiger Plan nötig): verschachtelte Struktur **Phasen → Streams → sections → items** oder flache `sections` mit ausgefüllten `phase_id` / `parallel_stream_id` (Frontend kann normalisieren).
+- **`PUT/PATCH`:** Atomares Ersetzen der Phasen/Streams/Sektionen analog zu bestehendem `_replace_unit_sections`-Muster; **Validierung** der CHECK-Regeln serverseitig.
+- **Blueprint / Rahmen:** Blueprint-`training_units` dürfen dieselbe Struktur tragen; `GET` Kalenderliste blendet Blueprints weiter aus (`framework_slot_id IS NOT NULL`).
+
+**Governance / Mandant:** Unverändert über Einheit → `group_id`; keine neuen Mandanten-Entitäten.
+
+---
+
+## 5. Frontend
+
+### 5.1 Planung (`TrainingPlanningPage`)
+
+- Darstellung als **vertikale Phasen**: Gemeinschaftsblöcke + Parallelphase mit **N Spalten** (Streams).
+- **Wiederverwendung:** `TrainingUnitSectionsEditor` **pro Stream** und pro Gemeinschaftsphase — analog zur Wiederverwendung **pro Rahmen-Slot** in `TrainingFrameworkProgramEditPage`.
+- **Co-Trainer:** UI pro Stream (`assigned_trainer_profile_ids`); Regel zur **Kopfliste** `assistant_trainer_profile_ids` festlegen (z. B. Union aller Stream-Zuweisungen für „Wer ist heute dabei“ + Rückwärtskompatibilität wenn Stream-Felder leer).
+
+### 5.2 Durchführung (`TrainingUnitRunPage`)
+
+- Gemeinschaftsphasen: heutiges **lineares** Verhalten.
+- Parallelphase: **Tabs, Akkordeon oder Swipe** zwischen Streams; Fortschritt **pro Stream** (Storage-Key z. B. `${unitId}:${streamId}`).
+- Kombi-Items: unverändert `CombinationPlanBracket` / `effectiveComboMethodProfile`.
+- Optional später: Filter „nur meine Spur“ anhand Session-Profil vs. Stream-Zuweisung.
+
+### 5.3 Vorlagen (`training_plan_templates`)
+
+- Erweiterung um **dieselbe** Phasen/Streams-Semantik (Kindtabellen oder serialisiertes JSON — Abgleich mit Kopierlogik aus Vorlage in Einheit).
+- **Kein** Live-Spiegel: weiterhin Materialisierung beim Anwenden.
+
+---
+
+## 6. Bezug Kombinationsübungen
+
+- **Variante A** (Rotation innerhalb einer Teilstrecke): ein oder mehrere **Items** vom Typ Kombi im jeweiligen Stream; Archetyp und Parameter wie in `TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md`.
+- **Variante B** (synchron Hallenweit): erweiterte **Phasen-** oder **Stream-übergreifende** Metadaten — **nicht** in MVP-Zwang; eigenes Teilpaket nach fachlicher Freigabe (`PARALLEL_TRAINING_STREAMS_CONCEPT.md` §5.2).
+
+---
+
+## 7. Migration und Risiken
+
+1. **Datenmigration:** Alle existierenden `training_unit_sections` einer Einheit einer **Default-Phase** `whole_group` zuordnen.
+2. **API-Versionierung:** Clients, die nur flache `sections` erwarten, müssen angepasst werden (oder Server liefert **beides** kurzzeitig — nur wenn nötig).
+3. **Performance:** Tiefe Kopien (Rahmen-Slot, Duplikat Einheit) müssen rekursiv Phasen/Streams mitsamt Sektionen/Items kopieren.
+4. **Tests:** pytest für PUT/GET mit gemischten Phasen; ggf. Playwright-Smoke für Planung/Run.
+
+---
+
+## 8. Implementierungsphasen (Vorschlag)
+
+| Phase | Inhalt |
+|-------|--------|
+| **P1** | Schema Phasen + Streams; Migration; GET/PATCH Einheit verschachtelt; Planungs-UI; Run-UI mit Stream-Tabs |
+| **P2** | Trainer-Zuordnung pro Stream + effektive Anzeige; Vorlagen erweitert |
+| **P3** | Synchroner Hallen-Takt / Rotationsmatrix (falls fachlich freigegeben) |
+
+---
+
+## 9. Verwandte Dokumente
+
+| Dokument | Bezug |
+|----------|--------|
+| `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md` | Fachziele, Begriffe, Entscheidungsfragen |
+| `technical/TRAINING_FRAMEWORK_SPEC.md` | Rahmen-Slot vs. Parallelität |
+| `technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md` | Kombi, `planning_method_profile` |
+| `technical/DATABASE_SCHEMA.md`, `backend/migrations/` | DDL-Historie |
+| `frontend/src/pages/TrainingPlanningPage.jsx`, `TrainingUnitRunPage.jsx`, `TrainingFrameworkProgramEditPage.jsx` | Ist-UI |
diff --git a/.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md b/.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md
index 2062851..485b574 100644
--- a/.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md
+++ b/.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md
@@ -15,6 +15,7 @@
| `DATABASE_SCHEMA.md` | **Nachgeordnete** Übersicht: Migrationshistorie und Tabellenliste; Detail-DDL primär **hier §2–§3** + SQL unter `backend/migrations/`. |
| `functional/DOMAIN_MODEL.md` | Fachliche Begriffe; Kurzverweis auf Progressionsgraph ergänzt. |
| `TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` | **Was** und **warum** (Bibliothek vs. Instanz, Governance, CURR‑Tabelle). |
+| `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, `technical/PARALLEL_TRAINING_STREAMS_SPEC.md` | **Parallele Streams / Breakout innerhalb einer Einheit** — orthogonale Domäne zu **Rahmen‑Slots** (Serien‑Sessions). |
**Konsequenz:** Diese Datei bleibt der **technische Arbeitspool** für Rahmenprogramm Stufe 1–2. Abschnitt **§4** beschreibt explizit den **aktuellen Produktfreigabe-Umfang** und **bekannte Lücken** (damit Trainingsplanung weiter gebaut werden kann ohne falscher Erwartung an „Alternative‑Pakete“ in der UI).
diff --git a/.claude/docs/technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md b/.claude/docs/technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md
index d9b0952..6a00d68 100644
--- a/.claude/docs/technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md
+++ b/.claude/docs/technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md
@@ -15,6 +15,7 @@
| Dokument | Bezug |
|----------|--------|
| `TRAINING_FRAMEWORK_SPEC.md` | Rahmen-Bibliothek, Slot-Blueprint, Kopiersemantik (`from-framework-slot`) |
+| `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, `technical/PARALLEL_TRAINING_STREAMS_SPEC.md` | Parallele Teilstrecken **innerhalb einer Einheit**; Kombi-Übungen weiter nutzbar **pro Stream** für Stationsrotation |
| `DATABASE_SCHEMA.md` | Aktueller Stand `training_units`, Sektionen, Items |
| `functional/DOMAIN_MODEL.md` | Domänenbegriffe (bei Bedarf zu erweitern) |
| `EXERCISES_*` (Katalog) | Einzelübungen, Varianten |
diff --git a/backend/fastapi_param_unwrap.py b/backend/fastapi_param_unwrap.py
new file mode 100644
index 0000000..b67d84b
--- /dev/null
+++ b/backend/fastapi_param_unwrap.py
@@ -0,0 +1,16 @@
+"""Hilfen für direkte Python-Aufrufe von FastAPI-Route-Handlern (ohne Request-Kontext)."""
+from __future__ import annotations
+
+from typing import Any
+
+
+def unwrap_query_default(value: Any) -> Any:
+ """
+ Parameter mit Annotation ``= Query(default=…)`` sind im Funktionskörper ``fastapi.params.Query``-Instanzen,
+ solange FastAPI sie nicht durch echte Werte ersetzt hat (interne Aufrufe, Aggregat-Endpunkte).
+ """
+ try:
+ from fastapi.params import Query
+ except ImportError:
+ return value
+ return value.default if isinstance(value, Query) else value
diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py
index c85840b..02ffcc6 100644
--- a/backend/routers/exercises.py
+++ b/backend/routers/exercises.py
@@ -19,6 +19,8 @@ from fastapi.responses import FileResponse, Response, StreamingResponse
from pydantic import BaseModel, Field, model_validator
from psycopg2.extras import Json
+from fastapi_param_unwrap import unwrap_query_default
+
from db import get_db, get_cursor, r2d
from club_tenancy import (
assert_valid_governance_visibility,
@@ -1773,6 +1775,11 @@ def list_exercises(
Optional include_variants für Variantenauswahl in der Trainingsplanung.
Keyset: cursor_updated_at + cursor_id ersetzt große OFFSET-Werte (Sortierung: updated_at DESC, id DESC).
"""
+ cursor_updated_at = unwrap_query_default(cursor_updated_at)
+ cursor_id = unwrap_query_default(cursor_id)
+ limit = unwrap_query_default(limit)
+ offset = unwrap_query_default(offset)
+
profile_id = tenant.profile_id
c_ts_raw = (cursor_updated_at or "").strip() or None
diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py
index d2c635b..461999e 100644
--- a/backend/routers/training_planning.py
+++ b/backend/routers/training_planning.py
@@ -10,6 +10,8 @@ from typing import Any, Dict, List, Optional, Tuple
from fastapi import APIRouter, Depends, HTTPException, Query
from psycopg2.extras import Json as PsycopgJson
+from fastapi_param_unwrap import unwrap_query_default
+
from db import get_db, get_cursor, r2d
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
from club_tenancy import (
@@ -1341,6 +1343,19 @@ def list_training_units(
),
tenant: TenantContext = Depends(get_tenant_context),
):
+ group_id = unwrap_query_default(group_id)
+ club_id = unwrap_query_default(club_id)
+ start_date = unwrap_query_default(start_date)
+ end_date = unwrap_query_default(end_date)
+ status = unwrap_query_default(status)
+ assigned_to_me = unwrap_query_default(assigned_to_me)
+ debrief_pending = unwrap_query_default(debrief_pending)
+ sort = unwrap_query_default(sort)
+ limit = unwrap_query_default(limit)
+ cursor_planned_date = unwrap_query_default(cursor_planned_date)
+ cursor_planned_time = unwrap_query_default(cursor_planned_time)
+ cursor_id = unwrap_query_default(cursor_id)
+
profile_id = tenant.profile_id
role = tenant.global_role
diff --git a/backend/tests/test_dashboard_kpis.py b/backend/tests/test_dashboard_kpis.py
index 8847f01..c9ea3dc 100644
--- a/backend/tests/test_dashboard_kpis.py
+++ b/backend/tests/test_dashboard_kpis.py
@@ -1,13 +1,15 @@
-"""GET /api/dashboard/kpis: Auth (kein DB nötig)."""
+"""GET /api/dashboard/kpis: Auth + interne Aufruf-Hilfen."""
from __future__ import annotations
import os
import pytest
+from fastapi import Query
from fastapi.testclient import TestClient
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
+from fastapi_param_unwrap import unwrap_query_default
from main import app
@@ -16,6 +18,12 @@ def client() -> TestClient:
return TestClient(app)
+def test_unwrap_query_default_for_direct_route_calls() -> None:
+ assert unwrap_query_default(Query(default=None)) is None
+ assert unwrap_query_default("2026-01-01") == "2026-01-01"
+ assert unwrap_query_default(7) == 7
+
+
def test_dashboard_kpis_unauthenticated_401(client: TestClient) -> None:
r = client.get("/api/dashboard/kpis")
assert r.status_code == 401
diff --git a/backend/version.py b/backend/version.py
index be158f1..7364ece 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.119"
+APP_VERSION = "0.8.123"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260514062"
@@ -36,6 +36,34 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.123",
+ "date": "2026-05-13",
+ "changes": [
+ "Fix: GET /api/dashboard/kpis — interne Aufrufe von list_exercises / list_training_units erhielten FastAPI-Query-Defaults statt None; .strip() auf Query-Objekt → 500. unwrap_query_default in beiden Handlern (Hilfsmodul fastapi_param_unwrap.py).",
+ ],
+ },
+ {
+ "version": "0.8.122",
+ "date": "2026-05-13",
+ "changes": [
+ "Frontend Phase 3 (Teil): useExerciseListCatalogsAndQuery — Katalog-Fetch und Übungslisten-Laden/Keyset-Pagination aus ExercisesListPage in Hook ausgelagert.",
+ ],
+ },
+ {
+ "version": "0.8.121",
+ "date": "2026-05-13",
+ "changes": [
+ "Frontend Phase 3 (Teil): Übungsliste — Suchleiste/Chips in ExerciseListSearchBar; API-Query-Bau und Filter-Chips in utils/exerciseListQuery.js bzw. exerciseListFilterChips.js.",
+ ],
+ },
+ {
+ "version": "0.8.120",
+ "date": "2026-05-13",
+ "changes": [
+ "Frontend: Übungsliste — Filter- und Massenänderungs-Dialoge in ExerciseListFilterModal / ExerciseListBulkModal ausgelagert; Playwright-Test 10 (Filter-Dialog).",
+ ],
+ },
{
"version": "0.8.119",
"date": "2026-05-13",
diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md
index 7879f36..e52a4ef 100644
--- a/docs/HANDOVER.md
+++ b/docs/HANDOVER.md
@@ -1,7 +1,7 @@
# Shinkan Jinkendo – Entwicklungsstand & Handover
**Stand:** 2026-05-13
-**App-Version / DB-Schema:** App **0.8.119**, DB-Schema **`20260514062`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`)
+**App-Version / DB-Schema:** App **0.8.120**, DB-Schema **`20260514062`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`)
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
@@ -76,7 +76,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
- **036 / 037:** Bibliotheks-Rahmen, Slot-Inhalt als **`training_units`** mit **`framework_slot_id`**; **`POST /api/training-units/from-framework-slot`**.
- **Code:** `training_framework_programs.py`, `training_planning.py`; Frontend **`TrainingFrameworkProgramEditPage.jsx`**, **`createTrainingUnitFromFrameworkSlot`** in `api.js`.
-### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.119**)
+### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.120**)
- **Fachspez & Drift-Schutz:** `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (**§ 10.2.1** IDs, **§ 10.4** Coaching-Stufen, **§ 10.6** Produkt-Backlog, **Anhang A** Abgleich).
- **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4a–g** — u. a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung).
diff --git a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
index ffc57fd..ba2f011 100644
--- a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
+++ b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
@@ -7,7 +7,9 @@
- **Phase 1 (Teil):** Org-Inbox: **ein** gemeinsamer Ladepfad `fetchOrgInboxSnapshot` für Mount-`useEffect` und `refreshOrgInbox` (gleiche Requests, weniger Drift-Risiko; Verhalten unverändert).
- **Phase 2:** **abgeschlossen** (2026-05-14) — Indizes 058–062, Keyset `/api/exercises` + `/api/training-units`, **`/api/dashboard/kpis`** inkl. `training_home`, EXPLAIN-Vorlagen **`scripts/load/explain-readpaths.sql`**.
- **Offen Phase 1:** Inbox optional **TTL** / nur bei sichtbarem Widget.
-- **Phase 3 (gestartet 2026-05-13):** Übungsliste — extrahierte Karte, **virtualisierter** Picker, **lazy** Progressions-Panel; Playwright **Test 9**; Grid `data-testid`. Weiter: God-Pages (Planung/Formular) zerteilen. Nach MVP eine **nachhaltige** Architektur für Wachstum, **Performance** (Server + schwache Clients) und **sichere Feature-Erweiterung**.
+- **Phase 3 (gestartet 2026-05-13):** Übungsliste modularisiert (Karte, Filter-/Bulk-Modals, virtualisierter Picker, lazy Progression); Playwright **Tests 9–10**. Weiter: God-Pages (Planung/Formular).
+
+**Ziel:** Nach MVP eine **nachhaltige** Architektur für Wachstum, **Performance** (Server + schwache Clients) und **sichere Feature-Erweiterung**.
**Leitdokumente:** [ZIELBILD_ARCHITEKTUR.md](./ZIELBILD_ARCHITEKTUR.md), [SCHULDEN_UND_REMEDIATION.md](./SCHULDEN_UND_REMEDIATION.md), [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md).
---
@@ -80,7 +82,7 @@
| Virtualisierung für die längste produktive Liste | A1, S2 |
| Schwere Imports auf `import()` umziehen (gezielt) | A4 |
-**Teil umgesetzt (2026-05-13):** `ExercisesListPage` — Karten in `components/exercises/ExerciseListCard.jsx`; Tab „Progressionsgraphen“ lädt **`ExerciseProgressionGraphPanel`** per `React.lazy` + `Suspense`; **`ExercisePickerModal`** virtualisiert (`@tanstack/react-virtual`, Scroll-Container `data-testid="exercise-picker-scroll"`); Gitter `data-testid="exercises-list-grid"` + `content-visibility` in `app.css`; Playwright **Test 9**. Offen: Seite unter Soft-Limit (~500 Zeilen), vollständige Zerteilung `TrainingPlanningPage` / `ExerciseFormPage`.
+**Teil umgesetzt (2026-05-13):** `ExercisesListPage` — Karten `ExerciseListCard.jsx`; Filter/Massenänderung `ExerciseListFilterModal.jsx` / `ExerciseListBulkModal.jsx`; Tab „Progressionsgraphen“ **lazy**; **Picker** virtualisiert; Gitter `data-testid` + `content-visibility`; Playwright **Tests 9–10**. Offen: Seite unter Soft-Limit (~500 Zeilen, derzeit ~918 LOC), Zerteilung Planung/Übungsformular.
**Abnahme:** Referenz-Page unter Soft-Limit; Regel S1 für neue Änderungen durchsetzbar.
diff --git a/frontend/src/components/exercises/ExerciseListBulkModal.jsx b/frontend/src/components/exercises/ExerciseListBulkModal.jsx
new file mode 100644
index 0000000..0d1913a
--- /dev/null
+++ b/frontend/src/components/exercises/ExerciseListBulkModal.jsx
@@ -0,0 +1,240 @@
+import React from 'react'
+import { activeClubMemberships } from '../../utils/activeClub'
+import MultiSelectCombo from '../MultiSelectCombo'
+
+/**
+ * Massenänderung für ausgewählte Übungen in der Liste.
+ */
+export default function ExerciseListBulkModal({
+ open,
+ onClose,
+ onSubmit,
+ bulkSubmitting,
+ selectedCount,
+ bulkMaxIds,
+ user,
+ isPlatformAdmin,
+ statusOptions,
+ bulkVisibilityOptions,
+ focusOptions,
+ styleOptions,
+ trainingTypeOptions,
+ targetGroupOptions,
+ bulkVisibility,
+ setBulkVisibility,
+ bulkStatus,
+ setBulkStatus,
+ bulkClubSelect,
+ setBulkClubSelect,
+ bulkClubManual,
+ setBulkClubManual,
+ bulkPatchFocusAreas,
+ setBulkPatchFocusAreas,
+ bulkFocusAreaIds,
+ setBulkFocusAreaIds,
+ bulkPatchStyleDirections,
+ setBulkPatchStyleDirections,
+ bulkStyleDirectionIds,
+ setBulkStyleDirectionIds,
+ bulkPatchTrainingTypes,
+ setBulkPatchTrainingTypes,
+ bulkTrainingTypeIds,
+ setBulkTrainingTypeIds,
+ bulkPatchTargetGroups,
+ setBulkPatchTargetGroups,
+ bulkTargetGroupIds,
+ setBulkTargetGroupIds,
+}) {
+ if (!open) return null
+
+ return (
+
{
+ if (e.target === e.currentTarget) onClose()
+ }}
+ >
+
e.stopPropagation()}
+ >
+
+
+ Massenänderung
+
+
+ Schließen
+
+
+
+
+ Es werden {selectedCount} Übung(en) aus der aktuellen Auswahl bearbeitet. Pro Durchlauf
+ höchstens {bulkMaxIds}. Ohne Berechtigung bleiben Einzelübungen unverändert (siehe Hinweis nach dem
+ Speichern).
+
+
+ Unter „Zuordnung ersetzen“: die gewählte Liste ersetzt die bisherige Zuordnung bei allen betroffenen Übungen
+ vollständig (leere Auswahl = alle Zuordnungen dieser Kategorie entfernen). Die erste Auswahl gilt als
+ Primärzuordnung.
+
+
+ Sichtbarkeit
+ setBulkVisibility(e.target.value)}>
+ {bulkVisibilityOptions.map((o) => (
+
+ {o.label}
+
+ ))}
+
+
+ {bulkVisibility === 'club' ? (
+
+ Verein zuordnen
+ setBulkClubSelect(e.target.value)}>
+ Aktiver Verein (Vereins-Umschalter / Header)
+ {activeClubMemberships(user?.clubs).map((c) => (
+
+ {c.name || `#${c.id}`}
+
+ ))}
+
+ {isPlatformAdmin ? (
+ <>
+
+ Oder Vereins-ID (Plattform-Admin)
+
+ setBulkClubManual(e.target.value)}
+ />
+ >
+ ) : null}
+
+ ) : null}
+
+ Status
+ setBulkStatus(e.target.value)}>
+ — nicht ändern —
+ {statusOptions.map((o) => (
+
+ {o.label}
+
+ ))}
+
+
+
+
+ Zuordnung (optional)
+
+
+
+
+
+ Abbrechen
+
+
+ {bulkSubmitting ? 'Speichern…' : 'Anwenden'}
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/exercises/ExerciseListFilterModal.jsx b/frontend/src/components/exercises/ExerciseListFilterModal.jsx
new file mode 100644
index 0000000..e9f0583
--- /dev/null
+++ b/frontend/src/components/exercises/ExerciseListFilterModal.jsx
@@ -0,0 +1,236 @@
+import React from 'react'
+import { SKILL_LEVEL_OPTIONS } from '../../constants/skillLevels'
+import MultiSelectCombo from '../MultiSelectCombo'
+import ExerciseFocusRulePicker from '../ExerciseFocusRulePicker'
+import CatalogRulePicker from '../CatalogRulePicker'
+
+const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
+
+/**
+ * Filter-Dialog für die Übungsliste (gleiche Logik wie zuvor inline in ExercisesListPage).
+ */
+export default function ExerciseListFilterModal({
+ open,
+ onClose,
+ filters,
+ setFilters,
+ focusOptions,
+ styleOptions,
+ trainingTypeOptions,
+ targetGroupOptions,
+ skillOptions,
+ visibilityOptions,
+ statusOptions,
+ savingExercisePrefs,
+ onSaveStandard,
+ onResetAll,
+}) {
+ if (!open) return null
+
+ return (
+ {
+ if (e.target === e.currentTarget) onClose()
+ }}
+ >
+
e.stopPropagation()}
+ >
+
+
+ Übungen filtern
+
+
+ Schließen
+
+
+
+
+ Zwischen den Bereichen gilt UND . Fokusbereiche: mehrere „+ mit“ bedeuten alle müssen
+ gesetzt sein; „− ohne“ schließt Übungen aus, die diesen Fokus zusätzlich haben. Stilrichtung /
+ Trainingsstil / Zielgruppe: mehrere „+“ = alle zutreffend (UND); „−“ verbietet die Zuordnung. Unter
+ „Freigabe“: Sichtbarkeit / Status mit „+“ = eine davon (ODER); „−“ blendet aus.
+
+
+
+ Zuordnung
+ setFilters((prev) => ({ ...prev, ...patch }))}
+ />
+
+ setFilters((prev) => ({ ...prev, ...patch }))}
+ />
+ setFilters((prev) => ({ ...prev, ...patch }))}
+ />
+ setFilters((prev) => ({ ...prev, ...patch }))}
+ />
+
+
+
+
+ Fähigkeit und zugehörige Stufe
+
+
Fähigkeit
+
setFilters({ ...filters, skill_ids: v })}
+ options={skillOptions}
+ placeholder="Fähigkeit suchen …"
+ />
+
+ Die Stufen filtern nach dem Niveau der Zuordnung Übung ↔ Fähigkeit (von–bis).
+
+
+
+ von
+ setFilters({ ...filters, skill_min_level: e.target.value })}
+ >
+ –
+ {LEVEL_FILTER_OPTS.map((o) => (
+
+ {o.level}
+
+ ))}
+
+
+
+ –
+
+
+ bis
+ setFilters({ ...filters, skill_max_level: e.target.value })}
+ >
+ –
+ {LEVEL_FILTER_OPTS.map((o) => (
+
+ {o.level}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ Freigabe
+
+ Pro Übung nur ein Wert: mehrere „+“ bedeuten „eine davon“ (ODER). „−“ blendet Werte aus.
+
+
+ setFilters((prev) => ({ ...prev, ...patch }))}
+ />
+ setFilters((prev) => ({ ...prev, ...patch }))}
+ />
+
+
+
+
+
+ {savingExercisePrefs ? 'Speichern…' : 'Als Standard speichern'}
+
+
+ Alle Filter zurücksetzen
+
+
+ Fertig
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/exercises/ExerciseListSearchBar.jsx b/frontend/src/components/exercises/ExerciseListSearchBar.jsx
new file mode 100644
index 0000000..eaad9b9
--- /dev/null
+++ b/frontend/src/components/exercises/ExerciseListSearchBar.jsx
@@ -0,0 +1,107 @@
+import React from 'react'
+
+export default function ExerciseListSearchBar({
+ searchTitleSuggestions,
+ searchInput,
+ onSearchInputChange,
+ aiSearchInput,
+ onAiSearchInputChange,
+ mineOnly,
+ onToggleMineOnly,
+ onOpenFilter,
+ filterChips,
+ onResetAllFilters,
+ exerciseCount,
+ allOnPageSelected,
+ onToggleSelectAllPage,
+}) {
+ return (
+
+
Volltextsuche (Titel, Ziel, …)
+
+ {searchTitleSuggestions.map((t) => (
+
+ ))}
+
+
onSearchInputChange(e.target.value)}
+ autoComplete="on"
+ name="exercise-fulltext-search"
+ list="exercise-search-titles"
+ enterKeyHint="search"
+ />
+
Ergänzende Suche / KI-Vorbereitung (Beta)
+
onAiSearchInputChange(e.target.value)}
+ autoComplete="on"
+ name="exercise-ai-search"
+ list="exercise-search-titles"
+ enterKeyHint="search"
+ />
+
+
+
+ Meine Übungen
+
+
+ Filter
+ {filterChips.length > 0 ? (
+
+ {filterChips.length}
+
+ ) : null}
+
+ {filterChips.length > 0 ? (
+
+ Alle entfernen
+
+ ) : null}
+
+
+ {filterChips.length > 0 ? (
+
+ {filterChips.map((c) => (
+ c.onRemove()}
+ >
+ {c.label}
+
+ ×
+
+
+ ))}
+
+ ) : null}
+
+ Trefferliste aktualisiert sich kurz nach Eingabe. Titel der aktuellen Liste erscheinen als Vorschläge (Pfeil im
+ Feld). Fachliche Filter über „Filter“ – zwischen Feldern UND, Auswahl mehrerer Werte je Feld mit ODER.
+ {exerciseCount > 0 ? (
+ <>
+ {' '}
+
+ {allOnPageSelected ? 'Auswahl auf dieser Seite aufheben' : 'Alle auf dieser Seite auswählen'}
+
+ >
+ ) : null}
+
+
+ )
+}
diff --git a/frontend/src/hooks/useExerciseListCatalogsAndQuery.js b/frontend/src/hooks/useExerciseListCatalogsAndQuery.js
new file mode 100644
index 0000000..64bbe00
--- /dev/null
+++ b/frontend/src/hooks/useExerciseListCatalogsAndQuery.js
@@ -0,0 +1,120 @@
+import { useState, useEffect, useCallback } from 'react'
+import api from '../utils/api'
+
+export const EXERCISE_LIST_PAGE_SIZE = 100
+
+/**
+ * Lädt Kataloge für Filter/Bulk einmalig und hält die Übungsliste (Offset + Keyset „Mehr laden“) synchron zu queryBase.
+ */
+export function useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey }) {
+ const [catalogs, setCatalogs] = useState({
+ focusAreas: [],
+ styleDirections: [],
+ trainingTypes: [],
+ targetGroups: [],
+ skills: [],
+ })
+ const [catalogsReady, setCatalogsReady] = useState(false)
+ const [exercises, setExercises] = useState([])
+ const [listFetching, setListFetching] = useState(false)
+ const [loadingMore, setLoadingMore] = useState(false)
+ const [hasMore, setHasMore] = useState(false)
+
+ useEffect(() => {
+ let cancelled = false
+ ;(async () => {
+ try {
+ const [fa, sd, tt, tg, sk] = await Promise.all([
+ api.listFocusAreas(),
+ api.listStyleDirections(),
+ api.listTrainingTypes(),
+ api.listTargetGroups(),
+ api.listSkills(),
+ ])
+ if (!cancelled) {
+ setCatalogs({
+ focusAreas: fa,
+ styleDirections: sd,
+ trainingTypes: tt,
+ targetGroups: tg,
+ skills: sk,
+ })
+ setCatalogsReady(true)
+ }
+ } catch (err) {
+ if (!cancelled) {
+ console.error(err)
+ alert('Kataloge konnten nicht geladen werden: ' + err.message)
+ setCatalogsReady(true)
+ }
+ }
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ useEffect(() => {
+ if (!catalogsReady || pageTab !== 'list') return
+ let cancelled = false
+ const run = async () => {
+ setListFetching(true)
+ try {
+ const batch = await api.listExercises({
+ ...queryBase,
+ limit: EXERCISE_LIST_PAGE_SIZE,
+ offset: 0,
+ })
+ if (cancelled) return
+ setExercises(batch)
+ setHasMore(batch.length === EXERCISE_LIST_PAGE_SIZE)
+ } catch (err) {
+ if (!cancelled) {
+ console.error('Failed to load data:', err)
+ alert('Fehler beim Laden: ' + err.message)
+ }
+ } finally {
+ if (!cancelled) setListFetching(false)
+ }
+ }
+ run()
+ return () => {
+ cancelled = true
+ }
+ }, [queryBase, catalogsReady, pageTab, tenantClubDepKey])
+
+ const loadMore = useCallback(async () => {
+ if (loadingMore || !hasMore) return
+ const last = exercises[exercises.length - 1]
+ if (!last?.id || last.updated_at == null) return
+ setLoadingMore(true)
+ try {
+ const batch = await api.listExercises({
+ ...queryBase,
+ limit: EXERCISE_LIST_PAGE_SIZE,
+ cursor_updated_at:
+ typeof last.updated_at === 'string'
+ ? last.updated_at
+ : new Date(last.updated_at).toISOString(),
+ cursor_id: last.id,
+ })
+ setExercises((prev) => [...prev, ...batch])
+ setHasMore(batch.length === EXERCISE_LIST_PAGE_SIZE)
+ } catch (err) {
+ alert('Fehler: ' + err.message)
+ } finally {
+ setLoadingMore(false)
+ }
+ }, [loadingMore, hasMore, exercises, queryBase])
+
+ return {
+ catalogs,
+ catalogsReady,
+ exercises,
+ setExercises,
+ listFetching,
+ loadingMore,
+ hasMore,
+ loadMore,
+ }
+}
diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx
index 1cd50ad..5e2473b 100644
--- a/frontend/src/pages/ExercisesListPage.jsx
+++ b/frontend/src/pages/ExercisesListPage.jsx
@@ -3,76 +3,27 @@ import { Link } from 'react-router-dom'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
-import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
-import MultiSelectCombo from '../components/MultiSelectCombo'
-import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker'
-import CatalogRulePicker from '../components/CatalogRulePicker'
import PageSectionNav from '../components/PageSectionNav'
import ExerciseListCard from '../components/exercises/ExerciseListCard'
+import ExerciseListFilterModal from '../components/exercises/ExerciseListFilterModal'
+import ExerciseListBulkModal from '../components/exercises/ExerciseListBulkModal'
+import ExerciseListSearchBar from '../components/exercises/ExerciseListSearchBar'
+import { buildExerciseListFilterChips } from '../utils/exerciseListFilterChips'
+import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../utils/exerciseListQuery'
+import { useExerciseListCatalogsAndQuery } from '../hooks/useExerciseListCatalogsAndQuery'
import {
INITIAL_EXERCISE_LIST_FILTERS,
mergeExerciseListPrefsFromApi,
compactExerciseListPrefsPayload,
- splitMnCatalogRules,
- splitScalarCatalogRules,
} from '../constants/exerciseListFilters'
const ExerciseProgressionGraphPanel = lazy(() => import('../components/ExerciseProgressionGraphPanel'))
-const PAGE_SIZE = 100
const BULK_MAX_IDS = 500
const EXERCISES_PAGE_TABS = [
{ id: 'list', label: 'Liste' },
{ id: 'progression', label: 'Progressionsgraphen' },
]
-const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
-
-function pushCatalogRuleFilterChips(chips, field, rules, options, topicLabel, setFilters) {
- ;(rules || []).forEach((r) => {
- const rid = String(r.id ?? r.focus_area_id ?? '')
- const opt = options.find((o) => String(o.id) === rid)
- chips.push({
- key: `${field}-${r.key}`,
- label: `${topicLabel}: ${r.mode === 'forbid' ? '−' : '+'} ${opt?.label ?? rid}`,
- onRemove: () =>
- setFilters((prev) => ({
- ...prev,
- [field]: (prev[field] || []).filter((x) => x.key !== r.key),
- })),
- })
- })
-}
-
-function levelOptionShort(levelStr) {
- const o = LEVEL_FILTER_OPTS.find((x) => String(x.level) === String(levelStr))
- return o ? String(o.level) : String(levelStr)
-}
-
-function applyDashboardExerciseListUrl(mergedFromPrefs) {
- try {
- const sp = new URLSearchParams(window.location.search)
- const mine = sp.get('mine') === '1' || sp.get('created_by_me') === '1'
- const statusDraft = sp.get('status') === 'draft'
-
- if (mine) {
- const next = { ...INITIAL_EXERCISE_LIST_FILTERS }
- if (statusDraft) {
- next.status_rules = [{ key: 'url-dashboard-draft', id: 'draft', mode: 'require' }]
- }
- return next
- }
-
- if (statusDraft) {
- return {
- ...mergedFromPrefs,
- status_rules: [{ key: 'url-dashboard-draft', id: 'draft', mode: 'require' }],
- }
- }
- return mergedFromPrefs
- } catch {
- return mergedFromPrefs
- }
-}
function ExercisesListPage() {
const { user, checkAuth } = useAuth()
@@ -89,18 +40,6 @@ function ExercisesListPage() {
}
})
- const [exercises, setExercises] = useState([])
- const [catalogs, setCatalogs] = useState({
- focusAreas: [],
- styleDirections: [],
- trainingTypes: [],
- targetGroups: [],
- skills: [],
- })
- const [catalogsReady, setCatalogsReady] = useState(false)
- const [listFetching, setListFetching] = useState(false)
- const [loadingMore, setLoadingMore] = useState(false)
- const [hasMore, setHasMore] = useState(false)
const [searchInput, setSearchInput] = useState('')
const [aiSearchInput, setAiSearchInput] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
@@ -164,6 +103,26 @@ function ExercisesListPage() {
return () => window.removeEventListener('keydown', onKey)
}, [filterModalOpen])
+ const queryBase = useMemo(
+ () => buildExerciseListQueryBase(filters, debouncedSearch, debouncedAiSearch, mineOnly),
+ [filters, debouncedSearch, debouncedAiSearch, mineOnly]
+ )
+
+ const {
+ catalogs,
+ catalogsReady,
+ exercises,
+ setExercises,
+ listFetching,
+ loadingMore,
+ hasMore,
+ loadMore,
+ } = useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey })
+
+ useEffect(() => {
+ setSelectedIds(new Set())
+ }, [queryBase])
+
const focusOptions = useMemo(
() =>
catalogs.focusAreas.map((fa) => ({
@@ -206,168 +165,33 @@ function ExercisesListPage() {
[]
)
- const filterChips = useMemo(() => {
- const chips = []
-
- if (mineOnly) {
- chips.push({
- key: 'mine-only',
- label: 'Nur von mir erstellt',
- onRemove: () => setMineOnly(false),
- })
- }
-
- pushCatalogRuleFilterChips(chips, 'focus_rules', filters.focus_rules, focusOptions, 'Fokus', setFilters)
-
- if (filters.focus_only_without) {
- chips.push({
- key: 'focus-only-none',
- label: 'Nur ohne Fokusbereich',
- onRemove: () => setFilters((prev) => ({ ...prev, focus_only_without: false })),
- })
- }
-
- ;(filters.focus_area_ids || []).forEach((id) => {
- const opt = focusOptions.find((o) => String(o.id) === String(id))
- chips.push({
- key: `fa-${id}`,
- label: `Fokus (ODER, älter): ${opt?.label ?? id}`,
- onRemove: () =>
- setFilters((prev) => ({
- ...prev,
- focus_area_ids: prev.focus_area_ids.filter((x) => String(x) !== String(id)),
- })),
- })
- })
-
- pushCatalogRuleFilterChips(
- chips,
- 'style_direction_rules',
- filters.style_direction_rules,
+ const filterChips = useMemo(
+ () =>
+ buildExerciseListFilterChips({
+ mineOnly,
+ setMineOnly,
+ filters,
+ setFilters,
+ focusOptions,
+ styleOptions,
+ trainingTypeOptions,
+ targetGroupOptions,
+ skillOptions,
+ visibilityOptions,
+ statusOptions,
+ }),
+ [
+ mineOnly,
+ filters,
+ focusOptions,
styleOptions,
- 'Stil',
- setFilters
- )
- pushCatalogRuleFilterChips(
- chips,
- 'training_type_rules',
- filters.training_type_rules,
trainingTypeOptions,
- 'Trainingsstil',
- setFilters
- )
- pushCatalogRuleFilterChips(
- chips,
- 'target_group_rules',
- filters.target_group_rules,
targetGroupOptions,
- 'Zielgruppe',
- setFilters
- )
-
- ;(filters.style_direction_ids || []).forEach((id) => {
- const opt = styleOptions.find((o) => String(o.id) === String(id))
- chips.push({
- key: `sd-${id}`,
- label: `Stil (ODER, älter): ${opt?.label ?? id}`,
- onRemove: () =>
- setFilters((prev) => ({
- ...prev,
- style_direction_ids: prev.style_direction_ids.filter((x) => String(x) !== String(id)),
- })),
- })
- })
- ;(filters.training_type_ids || []).forEach((id) => {
- const opt = trainingTypeOptions.find((o) => String(o.id) === String(id))
- chips.push({
- key: `tt-${id}`,
- label: `Trainingsstil (ODER, älter): ${opt?.label ?? id}`,
- onRemove: () =>
- setFilters((prev) => ({
- ...prev,
- training_type_ids: prev.training_type_ids.filter((x) => String(x) !== String(id)),
- })),
- })
- })
- ;(filters.target_group_ids || []).forEach((id) => {
- const opt = targetGroupOptions.find((o) => String(o.id) === String(id))
- chips.push({
- key: `tg-${id}`,
- label: `Zielgruppe (ODER, älter): ${opt?.label ?? id}`,
- onRemove: () =>
- setFilters((prev) => ({
- ...prev,
- target_group_ids: prev.target_group_ids.filter((x) => String(x) !== String(id)),
- })),
- })
- })
-
- ;(filters.skill_ids || []).forEach((id) => {
- const opt = skillOptions.find((o) => String(o.id) === String(id))
- chips.push({
- key: `sk-${id}`,
- label: `Fähigkeit: ${opt?.label ?? id}`,
- onRemove: () =>
- setFilters((prev) => ({
- ...prev,
- skill_ids: prev.skill_ids.filter((x) => String(x) !== String(id)),
- })),
- })
- })
-
- if (filters.skill_min_level || filters.skill_max_level) {
- const a = filters.skill_min_level ? levelOptionShort(filters.skill_min_level) : '…'
- const b = filters.skill_max_level ? levelOptionShort(filters.skill_max_level) : '…'
- chips.push({
- key: 'skill-levels',
- label: `Stufe ${a}–${b}`,
- onRemove: () =>
- setFilters((prev) => ({
- ...prev,
- skill_min_level: '',
- skill_max_level: '',
- })),
- })
- }
-
- pushCatalogRuleFilterChips(
- chips,
- 'visibility_rules',
- filters.visibility_rules,
+ skillOptions,
visibilityOptions,
- 'Sichtbarkeit',
- setFilters
- )
- pushCatalogRuleFilterChips(chips, 'status_rules', filters.status_rules, statusOptions, 'Status', setFilters)
-
- if (filters.exclude_without_focus) {
- chips.push({
- key: 'ex-no-focus',
- label: 'Ohne Fokus ausblenden',
- onRemove: () => setFilters((prev) => ({ ...prev, exclude_without_focus: false })),
- })
- }
- if (filters.include_archived) {
- chips.push({
- key: 'inc-arch',
- label: 'Archivierte anzeigen',
- onRemove: () => setFilters((prev) => ({ ...prev, include_archived: false })),
- })
- }
-
- return chips
- }, [
- mineOnly,
- filters,
- focusOptions,
- styleOptions,
- trainingTypeOptions,
- targetGroupOptions,
- skillOptions,
- visibilityOptions,
- statusOptions,
- setFilters,
- ])
+ statusOptions,
+ ]
+ )
/** Für Browser-/datalist-Vorschläge (aktuelle Treffer-Titel, begrenzt) */
const searchTitleSuggestions = useMemo(() => {
@@ -375,61 +199,6 @@ function ExercisesListPage() {
return [...new Set(titles)].slice(0, 80)
}, [exercises])
- const queryBase = useMemo(() => {
- const q = {}
- const n = (v) => (v === '' || v == null ? undefined : Number(v))
- const ids = (arr) =>
- Array.isArray(arr) && arr.length ? arr.map((x) => Number(x)).filter((x) => !Number.isNaN(x)) : undefined
- const fMn = splitMnCatalogRules(filters.focus_rules)
- if (fMn.includeIds.length) q.focus_area_must_include_ids = fMn.includeIds
- if (fMn.excludeIds.length) q.focus_area_must_exclude_ids = fMn.excludeIds
- if (filters.focus_only_without) q.focus_only_without_focus_areas = true
-
- const fa = ids(filters.focus_area_ids)
- if (fa?.length) q.focus_area_ids = fa
-
- const sdMn = splitMnCatalogRules(filters.style_direction_rules)
- if (sdMn.includeIds.length) q.style_direction_must_include_ids = sdMn.includeIds
- if (sdMn.excludeIds.length) q.style_direction_must_exclude_ids = sdMn.excludeIds
- const sdLegacy = ids(filters.style_direction_ids)
- if (sdLegacy?.length) q.style_direction_ids = sdLegacy
-
- const ttMn = splitMnCatalogRules(filters.training_type_rules)
- if (ttMn.includeIds.length) q.training_type_must_include_ids = ttMn.includeIds
- if (ttMn.excludeIds.length) q.training_type_must_exclude_ids = ttMn.excludeIds
- const ttLegacy = ids(filters.training_type_ids)
- if (ttLegacy?.length) q.training_type_ids = ttLegacy
-
- const tgMn = splitMnCatalogRules(filters.target_group_rules)
- if (tgMn.includeIds.length) q.target_group_must_include_ids = tgMn.includeIds
- if (tgMn.excludeIds.length) q.target_group_must_exclude_ids = tgMn.excludeIds
- const tgLegacy = ids(filters.target_group_ids)
- if (tgLegacy?.length) q.target_group_ids = tgLegacy
-
- const visMn = splitScalarCatalogRules(filters.visibility_rules)
- if (visMn.includeVals.length) q.visibility_any = visMn.includeVals
- if (visMn.excludeVals.length) q.visibility_exclude_any = visMn.excludeVals
-
- const stMn = splitScalarCatalogRules(filters.status_rules)
- if (stMn.includeVals.length) q.status_any = stMn.includeVals
- if (stMn.excludeVals.length) q.status_exclude_any = stMn.excludeVals
-
- const sk = ids(filters.skill_ids)
- if (sk?.length) q.skill_ids = sk
- if (filters.skill_min_level) q.skill_min_level = n(filters.skill_min_level)
- if (filters.skill_max_level) q.skill_max_level = n(filters.skill_max_level)
- if (filters.exclude_without_focus) q.exclude_without_focus = true
- if (filters.include_archived) q.include_archived = true
- if (debouncedSearch) q.search = debouncedSearch
- if (debouncedAiSearch) q.ai_search = debouncedAiSearch
- if (mineOnly) q.created_by_me = true
- return q
- }, [filters, debouncedSearch, debouncedAiSearch, mineOnly])
-
- useEffect(() => {
- setSelectedIds(new Set())
- }, [queryBase])
-
const clubNameById = useMemo(() => {
const m = {}
for (const c of activeClubMemberships(user?.clubs)) {
@@ -487,89 +256,6 @@ function ExercisesListPage() {
return base
}, [isSuperadmin])
- useEffect(() => {
- let cancelled = false
- ;(async () => {
- try {
- const [fa, sd, tt, tg, sk] = await Promise.all([
- api.listFocusAreas(),
- api.listStyleDirections(),
- api.listTrainingTypes(),
- api.listTargetGroups(),
- api.listSkills(),
- ])
- if (!cancelled) {
- setCatalogs({
- focusAreas: fa,
- styleDirections: sd,
- trainingTypes: tt,
- targetGroups: tg,
- skills: sk,
- })
- setCatalogsReady(true)
- }
- } catch (err) {
- if (!cancelled) {
- console.error(err)
- alert('Kataloge konnten nicht geladen werden: ' + err.message)
- setCatalogsReady(true)
- }
- }
- })()
- return () => {
- cancelled = true
- }
- }, [])
-
- useEffect(() => {
- if (!catalogsReady || pageTab !== 'list') return
- let cancelled = false
- const run = async () => {
- setListFetching(true)
- try {
- const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset: 0 })
- if (cancelled) return
- setExercises(batch)
- setHasMore(batch.length === PAGE_SIZE)
- } catch (err) {
- if (!cancelled) {
- console.error('Failed to load data:', err)
- alert('Fehler beim Laden: ' + err.message)
- }
- } finally {
- if (!cancelled) setListFetching(false)
- }
- }
- run()
- return () => {
- cancelled = true
- }
- }, [queryBase, catalogsReady, pageTab, tenantClubDepKey])
-
- const loadMore = async () => {
- if (loadingMore || !hasMore) return
- const last = exercises[exercises.length - 1]
- if (!last?.id || last.updated_at == null) return
- setLoadingMore(true)
- try {
- const batch = await api.listExercises({
- ...queryBase,
- limit: PAGE_SIZE,
- cursor_updated_at:
- typeof last.updated_at === 'string'
- ? last.updated_at
- : new Date(last.updated_at).toISOString(),
- cursor_id: last.id,
- })
- setExercises((prev) => [...prev, ...batch])
- setHasMore(batch.length === PAGE_SIZE)
- } catch (err) {
- alert('Fehler: ' + err.message)
- } finally {
- setLoadingMore(false)
- }
- }
-
const handleDelete = async (exercise) => {
if (!confirm(`Übung "${exercise.title}" wirklich löschen?`)) return
try {
@@ -772,93 +458,21 @@ function ExercisesListPage() {
) : (
<>
-
-
Volltextsuche (Titel, Ziel, …)
-
- {searchTitleSuggestions.map((t) => (
-
- ))}
-
-
setSearchInput(e.target.value)}
- autoComplete="on"
- name="exercise-fulltext-search"
- list="exercise-search-titles"
- enterKeyHint="search"
- />
-
Ergänzende Suche / KI-Vorbereitung (Beta)
-
setAiSearchInput(e.target.value)}
- autoComplete="on"
- name="exercise-ai-search"
- list="exercise-search-titles"
- enterKeyHint="search"
- />
-
-
- setMineOnly((v) => !v)}
- title="Nur Übungen, die mit deinem Profil als Ersteller gespeichert sind"
- >
- Meine Übungen
-
- setFilterModalOpen(true)}>
- Filter
- {filterChips.length > 0 ? (
-
- {filterChips.length}
-
- ) : null}
-
- {filterChips.length > 0 ? (
-
- Alle entfernen
-
- ) : null}
-
-
- {filterChips.length > 0 ? (
-
- {filterChips.map((c) => (
- c.onRemove()}
- >
- {c.label}
-
- ×
-
-
- ))}
-
- ) : null}
-
- Trefferliste aktualisiert sich kurz nach Eingabe. Titel der aktuellen Liste erscheinen als Vorschläge (Pfeil im
- Feld). Fachliche Filter über „Filter“ – zwischen Feldern UND, Auswahl mehrerer Werte je Feld mit ODER.
- {exercises.length > 0 ? (
- <>
- {' '}
-
- {allOnPageSelected ? 'Auswahl auf dieser Seite aufheben' : 'Alle auf dieser Seite auswählen'}
-
- >
- ) : null}
-
-
+ setMineOnly((v) => !v)}
+ onOpenFilter={() => setFilterModalOpen(true)}
+ filterChips={filterChips}
+ onResetAllFilters={resetAllFilters}
+ exerciseCount={exercises.length}
+ allOnPageSelected={allOnPageSelected}
+ onToggleSelectAllPage={toggleSelectAllPage}
+ />
{selectedIds.size > 0 ? (
@@ -877,426 +491,63 @@ function ExercisesListPage() {
) : null}
- {filterModalOpen && (
- {
- if (e.target === e.currentTarget) setFilterModalOpen(false)
- }}
- >
-
e.stopPropagation()}
- >
-
-
- Übungen filtern
-
- setFilterModalOpen(false)}
- >
- Schließen
-
-
-
-
- Zwischen den Bereichen gilt UND . Fokusbereiche: mehrere „+ mit“ bedeuten alle müssen
- gesetzt sein; „− ohne“ schließt Übungen aus, die diesen Fokus zusätzlich haben. Stilrichtung /
- Trainingsstil / Zielgruppe: mehrere „+“ = alle zutreffend (UND); „−“ verbietet die Zuordnung. Unter
- „Freigabe“: Sichtbarkeit / Status mit „+“ = eine davon (ODER); „−“ blendet aus.
-
+
setFilterModalOpen(false)}
+ filters={filters}
+ setFilters={setFilters}
+ focusOptions={focusOptions}
+ styleOptions={styleOptions}
+ trainingTypeOptions={trainingTypeOptions}
+ targetGroupOptions={targetGroupOptions}
+ skillOptions={skillOptions}
+ visibilityOptions={visibilityOptions}
+ statusOptions={statusOptions}
+ savingExercisePrefs={savingExercisePrefs}
+ onSaveStandard={handleSaveExerciseFilterPrefs}
+ onResetAll={resetAllFilters}
+ />
-
- Zuordnung
- setFilters((prev) => ({ ...prev, ...patch }))}
- />
-
- setFilters((prev) => ({ ...prev, ...patch }))}
- />
- setFilters((prev) => ({ ...prev, ...patch }))}
- />
- setFilters((prev) => ({ ...prev, ...patch }))}
- />
-
-
-
-
- Fähigkeit und zugehörige Stufe
-
-
Fähigkeit
-
setFilters({ ...filters, skill_ids: v })}
- options={skillOptions}
- placeholder="Fähigkeit suchen …"
- />
-
- Die Stufen filtern nach dem Niveau der Zuordnung Übung ↔ Fähigkeit (von–bis).
-
-
-
- von
- setFilters({ ...filters, skill_min_level: e.target.value })}
- >
- –
- {LEVEL_FILTER_OPTS.map((o) => (
-
- {o.level}
-
- ))}
-
-
-
- –
-
-
- bis
- setFilters({ ...filters, skill_max_level: e.target.value })}
- >
- –
- {LEVEL_FILTER_OPTS.map((o) => (
-
- {o.level}
-
- ))}
-
-
-
-
-
-
-
-
-
- Freigabe
-
- Pro Übung nur ein Wert: mehrere „+“ bedeuten „eine davon“ (ODER). „−“ blendet Werte aus.
-
-
- setFilters((prev) => ({ ...prev, ...patch }))}
- />
- setFilters((prev) => ({ ...prev, ...patch }))}
- />
-
-
-
-
-
- {savingExercisePrefs ? 'Speichern…' : 'Als Standard speichern'}
-
-
- Alle Filter zurücksetzen
-
- setFilterModalOpen(false)}>
- Fertig
-
-
-
-
- )}
-
- {bulkModalOpen ? (
- {
- if (e.target === e.currentTarget) setBulkModalOpen(false)
- }}
- >
-
e.stopPropagation()}
- >
-
-
- Massenänderung
-
- setBulkModalOpen(false)}
- >
- Schließen
-
-
-
-
- Es werden {selectedIds.size} Übung(en) aus der aktuellen Auswahl bearbeitet. Pro Durchlauf
- höchstens {BULK_MAX_IDS}. Ohne Berechtigung bleiben Einzelübungen unverändert (siehe Hinweis nach dem
- Speichern).
-
-
- Unter „Zuordnung ersetzen“: die gewählte Liste ersetzt die bisherige Zuordnung bei allen betroffenen
- Übungen vollständig (leere Auswahl = alle Zuordnungen dieser Kategorie entfernen). Die erste Auswahl gilt
- als Primärzuordnung.
-
-
- Sichtbarkeit
- setBulkVisibility(e.target.value)}
- >
- {bulkVisibilityOptions.map((o) => (
-
- {o.label}
-
- ))}
-
-
- {bulkVisibility === 'club' ? (
-
- Verein zuordnen
- setBulkClubSelect(e.target.value)}
- >
- Aktiver Verein (Vereins-Umschalter / Header)
- {activeClubMemberships(user?.clubs).map((c) => (
-
- {c.name || `#${c.id}`}
-
- ))}
-
- {isPlatformAdmin ? (
- <>
-
- Oder Vereins-ID (Plattform-Admin)
-
- setBulkClubManual(e.target.value)}
- />
- >
- ) : null}
-
- ) : null}
-
- Status
- setBulkStatus(e.target.value)}
- >
- — nicht ändern —
- {statusOptions.map((o) => (
-
- {o.label}
-
- ))}
-
-
-
-
- Zuordnung (optional)
-
-
-
-
- setBulkModalOpen(false)}
- >
- Abbrechen
-
-
- {bulkSubmitting ? 'Speichern…' : 'Anwenden'}
-
-
-
-
- ) : null}
+ setBulkModalOpen(false)}
+ onSubmit={handleBulkSubmit}
+ bulkSubmitting={bulkSubmitting}
+ selectedCount={selectedIds.size}
+ bulkMaxIds={BULK_MAX_IDS}
+ user={user}
+ isPlatformAdmin={isPlatformAdmin}
+ statusOptions={statusOptions}
+ bulkVisibilityOptions={bulkVisibilityOptions}
+ focusOptions={focusOptions}
+ styleOptions={styleOptions}
+ trainingTypeOptions={trainingTypeOptions}
+ targetGroupOptions={targetGroupOptions}
+ bulkVisibility={bulkVisibility}
+ setBulkVisibility={setBulkVisibility}
+ bulkStatus={bulkStatus}
+ setBulkStatus={setBulkStatus}
+ bulkClubSelect={bulkClubSelect}
+ setBulkClubSelect={setBulkClubSelect}
+ bulkClubManual={bulkClubManual}
+ setBulkClubManual={setBulkClubManual}
+ bulkPatchFocusAreas={bulkPatchFocusAreas}
+ setBulkPatchFocusAreas={setBulkPatchFocusAreas}
+ bulkFocusAreaIds={bulkFocusAreaIds}
+ setBulkFocusAreaIds={setBulkFocusAreaIds}
+ bulkPatchStyleDirections={bulkPatchStyleDirections}
+ setBulkPatchStyleDirections={setBulkPatchStyleDirections}
+ bulkStyleDirectionIds={bulkStyleDirectionIds}
+ setBulkStyleDirectionIds={setBulkStyleDirectionIds}
+ bulkPatchTrainingTypes={bulkPatchTrainingTypes}
+ setBulkPatchTrainingTypes={setBulkPatchTrainingTypes}
+ bulkTrainingTypeIds={bulkTrainingTypeIds}
+ setBulkTrainingTypeIds={setBulkTrainingTypeIds}
+ bulkPatchTargetGroups={bulkPatchTargetGroups}
+ setBulkPatchTargetGroups={setBulkPatchTargetGroups}
+ bulkTargetGroupIds={bulkTargetGroupIds}
+ setBulkTargetGroupIds={setBulkTargetGroupIds}
+ />
{listFetching && exercises.length === 0 ? (
diff --git a/frontend/src/utils/exerciseListFilterChips.js b/frontend/src/utils/exerciseListFilterChips.js
new file mode 100644
index 0000000..a4293ac
--- /dev/null
+++ b/frontend/src/utils/exerciseListFilterChips.js
@@ -0,0 +1,188 @@
+import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
+
+const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
+
+function pushCatalogRuleFilterChips(chips, field, rules, options, topicLabel, setFilters) {
+ ;(rules || []).forEach((r) => {
+ const rid = String(r.id ?? r.focus_area_id ?? '')
+ const opt = options.find((o) => String(o.id) === rid)
+ chips.push({
+ key: `${field}-${r.key}`,
+ label: `${topicLabel}: ${r.mode === 'forbid' ? '−' : '+'} ${opt?.label ?? rid}`,
+ onRemove: () =>
+ setFilters((prev) => ({
+ ...prev,
+ [field]: (prev[field] || []).filter((x) => x.key !== r.key),
+ })),
+ })
+ })
+}
+
+function levelOptionShort(levelStr) {
+ const o = LEVEL_FILTER_OPTS.find((x) => String(x.level) === String(levelStr))
+ return o ? String(o.level) : String(levelStr)
+}
+
+export function buildExerciseListFilterChips({
+ mineOnly,
+ setMineOnly,
+ filters,
+ setFilters,
+ focusOptions,
+ styleOptions,
+ trainingTypeOptions,
+ targetGroupOptions,
+ skillOptions,
+ visibilityOptions,
+ statusOptions,
+}) {
+ const chips = []
+
+ if (mineOnly) {
+ chips.push({
+ key: 'mine-only',
+ label: 'Nur von mir erstellt',
+ onRemove: () => setMineOnly(false),
+ })
+ }
+
+ pushCatalogRuleFilterChips(chips, 'focus_rules', filters.focus_rules, focusOptions, 'Fokus', setFilters)
+
+ if (filters.focus_only_without) {
+ chips.push({
+ key: 'focus-only-none',
+ label: 'Nur ohne Fokusbereich',
+ onRemove: () => setFilters((prev) => ({ ...prev, focus_only_without: false })),
+ })
+ }
+
+ ;(filters.focus_area_ids || []).forEach((id) => {
+ const opt = focusOptions.find((o) => String(o.id) === String(id))
+ chips.push({
+ key: `fa-${id}`,
+ label: `Fokus (ODER, älter): ${opt?.label ?? id}`,
+ onRemove: () =>
+ setFilters((prev) => ({
+ ...prev,
+ focus_area_ids: prev.focus_area_ids.filter((x) => String(x) !== String(id)),
+ })),
+ })
+ })
+
+ pushCatalogRuleFilterChips(
+ chips,
+ 'style_direction_rules',
+ filters.style_direction_rules,
+ styleOptions,
+ 'Stil',
+ setFilters
+ )
+ pushCatalogRuleFilterChips(
+ chips,
+ 'training_type_rules',
+ filters.training_type_rules,
+ trainingTypeOptions,
+ 'Trainingsstil',
+ setFilters
+ )
+ pushCatalogRuleFilterChips(
+ chips,
+ 'target_group_rules',
+ filters.target_group_rules,
+ targetGroupOptions,
+ 'Zielgruppe',
+ setFilters
+ )
+
+ ;(filters.style_direction_ids || []).forEach((id) => {
+ const opt = styleOptions.find((o) => String(o.id) === String(id))
+ chips.push({
+ key: `sd-${id}`,
+ label: `Stil (ODER, älter): ${opt?.label ?? id}`,
+ onRemove: () =>
+ setFilters((prev) => ({
+ ...prev,
+ style_direction_ids: prev.style_direction_ids.filter((x) => String(x) !== String(id)),
+ })),
+ })
+ })
+ ;(filters.training_type_ids || []).forEach((id) => {
+ const opt = trainingTypeOptions.find((o) => String(o.id) === String(id))
+ chips.push({
+ key: `tt-${id}`,
+ label: `Trainingsstil (ODER, älter): ${opt?.label ?? id}`,
+ onRemove: () =>
+ setFilters((prev) => ({
+ ...prev,
+ training_type_ids: prev.training_type_ids.filter((x) => String(x) !== String(id)),
+ })),
+ })
+ })
+ ;(filters.target_group_ids || []).forEach((id) => {
+ const opt = targetGroupOptions.find((o) => String(o.id) === String(id))
+ chips.push({
+ key: `tg-${id}`,
+ label: `Zielgruppe (ODER, älter): ${opt?.label ?? id}`,
+ onRemove: () =>
+ setFilters((prev) => ({
+ ...prev,
+ target_group_ids: prev.target_group_ids.filter((x) => String(x) !== String(id)),
+ })),
+ })
+ })
+
+ ;(filters.skill_ids || []).forEach((id) => {
+ const opt = skillOptions.find((o) => String(o.id) === String(id))
+ chips.push({
+ key: `sk-${id}`,
+ label: `Fähigkeit: ${opt?.label ?? id}`,
+ onRemove: () =>
+ setFilters((prev) => ({
+ ...prev,
+ skill_ids: prev.skill_ids.filter((x) => String(x) !== String(id)),
+ })),
+ })
+ })
+
+ if (filters.skill_min_level || filters.skill_max_level) {
+ const a = filters.skill_min_level ? levelOptionShort(filters.skill_min_level) : '…'
+ const b = filters.skill_max_level ? levelOptionShort(filters.skill_max_level) : '…'
+ chips.push({
+ key: 'skill-levels',
+ label: `Stufe ${a}–${b}`,
+ onRemove: () =>
+ setFilters((prev) => ({
+ ...prev,
+ skill_min_level: '',
+ skill_max_level: '',
+ })),
+ })
+ }
+
+ pushCatalogRuleFilterChips(
+ chips,
+ 'visibility_rules',
+ filters.visibility_rules,
+ visibilityOptions,
+ 'Sichtbarkeit',
+ setFilters
+ )
+ pushCatalogRuleFilterChips(chips, 'status_rules', filters.status_rules, statusOptions, 'Status', setFilters)
+
+ if (filters.exclude_without_focus) {
+ chips.push({
+ key: 'ex-no-focus',
+ label: 'Ohne Fokus ausblenden',
+ onRemove: () => setFilters((prev) => ({ ...prev, exclude_without_focus: false })),
+ })
+ }
+ if (filters.include_archived) {
+ chips.push({
+ key: 'inc-arch',
+ label: 'Archivierte anzeigen',
+ onRemove: () => setFilters((prev) => ({ ...prev, include_archived: false })),
+ })
+ }
+
+ return chips
+}
diff --git a/frontend/src/utils/exerciseListQuery.js b/frontend/src/utils/exerciseListQuery.js
new file mode 100644
index 0000000..3564db6
--- /dev/null
+++ b/frontend/src/utils/exerciseListQuery.js
@@ -0,0 +1,83 @@
+import {
+ INITIAL_EXERCISE_LIST_FILTERS,
+ splitMnCatalogRules,
+ splitScalarCatalogRules,
+} from '../constants/exerciseListFilters'
+
+/** Dashboard-Deep-Link: ?mine=1, optional ?status=draft — überschreibt gespeicherte Prefs-Kombination. */
+export function applyDashboardExerciseListUrl(mergedFromPrefs) {
+ try {
+ const sp = new URLSearchParams(window.location.search)
+ const mine = sp.get('mine') === '1' || sp.get('created_by_me') === '1'
+ const statusDraft = sp.get('status') === 'draft'
+
+ if (mine) {
+ const next = { ...INITIAL_EXERCISE_LIST_FILTERS }
+ if (statusDraft) {
+ next.status_rules = [{ key: 'url-dashboard-draft', id: 'draft', mode: 'require' }]
+ }
+ return next
+ }
+
+ if (statusDraft) {
+ return {
+ ...mergedFromPrefs,
+ status_rules: [{ key: 'url-dashboard-draft', id: 'draft', mode: 'require' }],
+ }
+ }
+ return mergedFromPrefs
+ } catch {
+ return mergedFromPrefs
+ }
+}
+
+export function buildExerciseListQueryBase(filters, debouncedSearch, debouncedAiSearch, mineOnly) {
+ const q = {}
+ const n = (v) => (v === '' || v == null ? undefined : Number(v))
+ const ids = (arr) =>
+ Array.isArray(arr) && arr.length ? arr.map((x) => Number(x)).filter((x) => !Number.isNaN(x)) : undefined
+ const fMn = splitMnCatalogRules(filters.focus_rules)
+ if (fMn.includeIds.length) q.focus_area_must_include_ids = fMn.includeIds
+ if (fMn.excludeIds.length) q.focus_area_must_exclude_ids = fMn.excludeIds
+ if (filters.focus_only_without) q.focus_only_without_focus_areas = true
+
+ const fa = ids(filters.focus_area_ids)
+ if (fa?.length) q.focus_area_ids = fa
+
+ const sdMn = splitMnCatalogRules(filters.style_direction_rules)
+ if (sdMn.includeIds.length) q.style_direction_must_include_ids = sdMn.includeIds
+ if (sdMn.excludeIds.length) q.style_direction_must_exclude_ids = sdMn.excludeIds
+ const sdLegacy = ids(filters.style_direction_ids)
+ if (sdLegacy?.length) q.style_direction_ids = sdLegacy
+
+ const ttMn = splitMnCatalogRules(filters.training_type_rules)
+ if (ttMn.includeIds.length) q.training_type_must_include_ids = ttMn.includeIds
+ if (ttMn.excludeIds.length) q.training_type_must_exclude_ids = ttMn.excludeIds
+ const ttLegacy = ids(filters.training_type_ids)
+ if (ttLegacy?.length) q.training_type_ids = ttLegacy
+
+ const tgMn = splitMnCatalogRules(filters.target_group_rules)
+ if (tgMn.includeIds.length) q.target_group_must_include_ids = tgMn.includeIds
+ if (tgMn.excludeIds.length) q.target_group_must_exclude_ids = tgMn.excludeIds
+ const tgLegacy = ids(filters.target_group_ids)
+ if (tgLegacy?.length) q.target_group_ids = tgLegacy
+
+ const visMn = splitScalarCatalogRules(filters.visibility_rules)
+ if (visMn.includeVals.length) q.visibility_any = visMn.includeVals
+ if (visMn.excludeVals.length) q.visibility_exclude_any = visMn.excludeVals
+
+ const stMn = splitScalarCatalogRules(filters.status_rules)
+ if (stMn.includeVals.length) q.status_any = stMn.includeVals
+ if (stMn.excludeVals.length) q.status_exclude_any = stMn.excludeVals
+
+ const sk = ids(filters.skill_ids)
+ if (sk?.length) q.skill_ids = sk
+ if (filters.skill_min_level) q.skill_min_level = n(filters.skill_min_level)
+ if (filters.skill_max_level) q.skill_max_level = n(filters.skill_max_level)
+ if (filters.exclude_without_focus) q.exclude_without_focus = true
+ if (filters.include_archived) q.include_archived = true
+ if (debouncedSearch) q.search = debouncedSearch
+ if (debouncedAiSearch) q.ai_search = debouncedAiSearch
+ if (mineOnly) q.created_by_me = true
+ return q
+}
diff --git a/tests/dev-smoke-test.spec.js b/tests/dev-smoke-test.spec.js
index e498118..58a61b9 100644
--- a/tests/dev-smoke-test.spec.js
+++ b/tests/dev-smoke-test.spec.js
@@ -220,6 +220,23 @@ test('9. Übungsliste: nach Laden entweder Treffer-Gitter oder Leerhinweis', asy
console.log('✓ Übungsliste: Endzustand sichtbar (Gitter oder leer)');
});
+test('10. Übungsliste: Filter-Dialog öffnet und schließt', async ({ page }) => {
+ await login(page);
+ await page.goto('/exercises', { waitUntil: 'networkidle' });
+ const main = page.locator('.app-main');
+ await expect(main.getByRole('heading', { level: 1, name: /Übungen/i })).toBeVisible({
+ timeout: 15000,
+ });
+ await expect(main.locator('.spinner')).toHaveCount(0, { timeout: 20000 });
+ await main.getByRole('button', { name: /^Filter$/i }).click();
+ const dlg = page.getByTestId('exercise-list-filter-modal');
+ await expect(dlg).toBeVisible({ timeout: 10000 });
+ await expect(dlg.getByRole('heading', { name: 'Übungen filtern' })).toBeVisible();
+ await dlg.getByRole('button', { name: 'Schließen' }).click();
+ await expect(dlg).toHaveCount(0);
+ console.log('✓ Übungsliste: Filter-Dialog Smoke');
+});
+
test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await login(page);