Merge pull request 'Bug Fix Dashboard + Performance Phase 3a' (#34) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m1s

Reviewed-on: #34
This commit is contained in:
Lars 2026-05-14 11:51:53 +02:00
commit 639392e133
20 changed files with 1441 additions and 877 deletions

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo - Fachliches Domänenmodell # Shinkan Jinkendo - Fachliches Domänenmodell
**Version:** 0.4.5 **Version:** 0.4.6
**Stand:** 2026-05-12 (Fachlicher Nutzerüberblick: `docs/FACHLICHE_NUTZERFUNKTIONEN.md`) **Stand:** 2026-05-14 (Fachlicher Nutzerüberblick: `docs/FACHLICHE_NUTZERFUNKTIONEN.md`)
**Basis:** `shinkan_anforderungsdokument_entwurf.md` + Fähigkeitsmatrix **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 SlotBlueprint an; **`origin_framework_slot_id`** dient als Herkunftsreferenz (**Lineage light**; weiteres Feedback/LineageKonzept: Konzeptpapier Schritt **E**). **Konkretisierung (037/API):** `POST /api/training-units/from-framework-slot` legt eine geplante Einheit aus dem SlotBlueprint an; **`origin_framework_slot_id`** dient als Herkunftsreferenz (**Lineage light**; weiteres Feedback/LineageKonzept: 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 **RahmenprogrammSlot** (SerienSession über Wochen): Slots strukturieren **mehrere Einheiten** in einem Programm; **Streams** strukturieren **gleichzeitige** Abläufe **innerhalb einer** Einheit.
**Sonderfall Stationen:** Rotation kann **innerhalb** einer StreamPlanung ü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) ## Medien-Archiv & Übungs-Anhänge (Stand 2026-05-07)

View File

@ -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 |
|----|------|
| PT01 | 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. |
| PT02 | **Unbegrenzte** Anzahl paralleler **Streams** (Teilstrecken) in einer oder mehreren **Parallelphasen**. |
| PT03 | **Phasenmodell:** klar erkennbar **Gemeinsam** vs. **Parallel** vs. wieder **Gemeinsam** (auch mehrfach hintereinander möglich). |
| PT04 | **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). |
| PT05 | **Sonderfall Stationen:** rotierender Ablauf (z.B. Wechsel alle 20Min.) **inhaltlich** unterscheiden zwischen (a) Rotation **innerhalb** einer Teilstrecke und (b) **synchron** getakteter Hallen-Rotation — siehe §5. |
| PT06 | **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
30Min. gemeinsam → 25Min. drei parallele Streams (Gruppe an Matte / an Schlagsack / Fußarbeit) → 15Min. 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 |

View File

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

View File

@ -15,6 +15,7 @@
| `DATABASE_SCHEMA.md` | **Nachgeordnete** Übersicht: Migrationshistorie und Tabellenliste; Detail-DDL primär **hier §2§3** + SQL unter `backend/migrations/`. | | `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. | | `functional/DOMAIN_MODEL.md` | Fachliche Begriffe; Kurzverweis auf Progressionsgraph ergänzt. |
| `TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` | **Was** und **warum** (Bibliothek vs. Instanz, Governance, CURRTabelle). | | `TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` | **Was** und **warum** (Bibliothek vs. Instanz, Governance, CURRTabelle). |
| `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, `technical/PARALLEL_TRAINING_STREAMS_SPEC.md` | **Parallele Streams / Breakout innerhalb einer Einheit** — orthogonale Domäne zu **RahmenSlots** (SerienSessions). |
**Konsequenz:** Diese Datei bleibt der **technische Arbeitspool** für Rahmenprogramm Stufe 12. Abschnitt **§4** beschreibt explizit den **aktuellen Produktfreigabe-Umfang** und **bekannte Lücken** (damit Trainingsplanung weiter gebaut werden kann ohne falscher Erwartung an „AlternativePakete“ in der UI). **Konsequenz:** Diese Datei bleibt der **technische Arbeitspool** für Rahmenprogramm Stufe 12. Abschnitt **§4** beschreibt explizit den **aktuellen Produktfreigabe-Umfang** und **bekannte Lücken** (damit Trainingsplanung weiter gebaut werden kann ohne falscher Erwartung an „AlternativePakete“ in der UI).

View File

@ -15,6 +15,7 @@
| Dokument | Bezug | | Dokument | Bezug |
|----------|--------| |----------|--------|
| `TRAINING_FRAMEWORK_SPEC.md` | Rahmen-Bibliothek, Slot-Blueprint, Kopiersemantik (`from-framework-slot`) | | `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 | | `DATABASE_SCHEMA.md` | Aktueller Stand `training_units`, Sektionen, Items |
| `functional/DOMAIN_MODEL.md` | Domänenbegriffe (bei Bedarf zu erweitern) | | `functional/DOMAIN_MODEL.md` | Domänenbegriffe (bei Bedarf zu erweitern) |
| `EXERCISES_*` (Katalog) | Einzelübungen, Varianten | | `EXERCISES_*` (Katalog) | Einzelübungen, Varianten |

View File

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

View File

@ -19,6 +19,8 @@ from fastapi.responses import FileResponse, Response, StreamingResponse
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
from psycopg2.extras import Json from psycopg2.extras import Json
from fastapi_param_unwrap import unwrap_query_default
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from club_tenancy import ( from club_tenancy import (
assert_valid_governance_visibility, assert_valid_governance_visibility,
@ -1773,6 +1775,11 @@ def list_exercises(
Optional include_variants für Variantenauswahl in der Trainingsplanung. 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). 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 profile_id = tenant.profile_id
c_ts_raw = (cursor_updated_at or "").strip() or None c_ts_raw = (cursor_updated_at or "").strip() or None

View File

@ -10,6 +10,8 @@ from typing import Any, Dict, List, Optional, Tuple
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from psycopg2.extras import Json as PsycopgJson from psycopg2.extras import Json as PsycopgJson
from fastapi_param_unwrap import unwrap_query_default
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
from club_tenancy import ( from club_tenancy import (
@ -1341,6 +1343,19 @@ def list_training_units(
), ),
tenant: TenantContext = Depends(get_tenant_context), 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 profile_id = tenant.profile_id
role = tenant.global_role role = tenant.global_role

View File

@ -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 from __future__ import annotations
import os import os
import pytest import pytest
from fastapi import Query
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
os.environ.setdefault("SKIP_DB_MIGRATE", "1") os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from fastapi_param_unwrap import unwrap_query_default
from main import app from main import app
@ -16,6 +18,12 @@ def client() -> TestClient:
return TestClient(app) 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: def test_dashboard_kpis_unauthenticated_401(client: TestClient) -> None:
r = client.get("/api/dashboard/kpis") r = client.get("/api/dashboard/kpis")
assert r.status_code == 401 assert r.status_code == 401

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.119" APP_VERSION = "0.8.123"
BUILD_DATE = "2026-05-12" BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260514062" DB_SCHEMA_VERSION = "20260514062"
@ -36,6 +36,34 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ 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", "version": "0.8.119",
"date": "2026-05-13", "date": "2026-05-13",

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover # Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-13 **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**. 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`**. - **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`. - **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). - **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 **4ag** — u.a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung). - **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4ag** — u.a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung).

View File

@ -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 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 058062, Keyset `/api/exercises` + `/api/training-units`, **`/api/dashboard/kpis`** inkl. `training_home`, EXPLAIN-Vorlagen **`scripts/load/explain-readpaths.sql`**. - **Phase 2:** **abgeschlossen** (2026-05-14) — Indizes 058062, 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. - **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 910**. 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). **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 | | Virtualisierung für die längste produktive Liste | A1, S2 |
| Schwere Imports auf `import()` umziehen (gezielt) | A4 | | 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 910**. 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. **Abnahme:** Referenz-Page unter Soft-Limit; Regel S1 für neue Änderungen durchsetzbar.

View File

@ -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 (
<div
className="admin-modal-backdrop"
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget) onClose()
}}
>
<div
className="admin-modal-sheet exercise-filter-modal"
data-testid="exercise-list-bulk-modal"
role="dialog"
aria-modal="true"
aria-labelledby="exercise-bulk-modal-title"
onClick={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
<h3 id="exercise-bulk-modal-title" className="admin-modal-sheet__title">
Massenänderung
</h3>
<button type="button" className="btn btn-secondary admin-modal-sheet__close" onClick={onClose}>
Schließen
</button>
</div>
<div className="admin-modal-sheet__body exercise-filter-modal__scroll">
<p className="muted" style={{ marginTop: 0 }}>
Es werden <strong>{selectedCount}</strong> Übung(en) aus der aktuellen Auswahl bearbeitet. Pro Durchlauf
höchstens {bulkMaxIds}. Ohne Berechtigung bleiben Einzelübungen unverändert (siehe Hinweis nach dem
Speichern).
</p>
<p className="form-sub" style={{ marginTop: 0, marginBottom: '14px' }}>
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.
</p>
<div className="form-row">
<label className="form-label">Sichtbarkeit</label>
<select className="form-input" value={bulkVisibility} onChange={(e) => setBulkVisibility(e.target.value)}>
{bulkVisibilityOptions.map((o) => (
<option key={o.id === '' ? '_unchanged' : o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</div>
{bulkVisibility === 'club' ? (
<div className="form-row">
<label className="form-label">Verein zuordnen</label>
<select className="form-input" value={bulkClubSelect} onChange={(e) => setBulkClubSelect(e.target.value)}>
<option value="">Aktiver Verein (Vereins-Umschalter / Header)</option>
{activeClubMemberships(user?.clubs).map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || `#${c.id}`}
</option>
))}
</select>
{isPlatformAdmin ? (
<>
<label className="form-label" style={{ marginTop: '10px' }}>
Oder Vereins-ID (Plattform-Admin)
</label>
<input
type="number"
min={1}
className="form-input"
placeholder="Leer = wie Dropdown / aktiver Verein"
value={bulkClubManual}
onChange={(e) => setBulkClubManual(e.target.value)}
/>
</>
) : null}
</div>
) : null}
<div className="form-row">
<label className="form-label">Status</label>
<select className="form-input" value={bulkStatus} onChange={(e) => setBulkStatus(e.target.value)}>
<option value=""> nicht ändern </option>
{statusOptions.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</div>
<section className="exercise-filter-section" style={{ marginTop: '8px', paddingTop: '12px' }}>
<h4 className="exercise-filter-section-title">Zuordnung (optional)</h4>
<div className="exercise-filters-modal-grid">
<div>
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={bulkPatchFocusAreas}
onChange={(e) => {
const on = e.target.checked
setBulkPatchFocusAreas(on)
if (!on) setBulkFocusAreaIds([])
}}
/>
Fokusbereiche ersetzen
</label>
{bulkPatchFocusAreas ? (
<MultiSelectCombo
value={bulkFocusAreaIds}
onChange={setBulkFocusAreaIds}
options={focusOptions}
placeholder="Fokusbereiche wählen …"
/>
) : null}
</div>
<div>
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={bulkPatchStyleDirections}
onChange={(e) => {
const on = e.target.checked
setBulkPatchStyleDirections(on)
if (!on) setBulkStyleDirectionIds([])
}}
/>
Stilrichtungen ersetzen
</label>
{bulkPatchStyleDirections ? (
<MultiSelectCombo
value={bulkStyleDirectionIds}
onChange={setBulkStyleDirectionIds}
options={styleOptions}
placeholder="Stilrichtungen wählen …"
/>
) : null}
</div>
<div>
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={bulkPatchTrainingTypes}
onChange={(e) => {
const on = e.target.checked
setBulkPatchTrainingTypes(on)
if (!on) setBulkTrainingTypeIds([])
}}
/>
Trainingsstile ersetzen
</label>
{bulkPatchTrainingTypes ? (
<MultiSelectCombo
value={bulkTrainingTypeIds}
onChange={setBulkTrainingTypeIds}
options={trainingTypeOptions}
placeholder="Trainingsstile wählen …"
/>
) : null}
</div>
<div>
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={bulkPatchTargetGroups}
onChange={(e) => {
const on = e.target.checked
setBulkPatchTargetGroups(on)
if (!on) setBulkTargetGroupIds([])
}}
/>
Zielgruppen ersetzen
</label>
{bulkPatchTargetGroups ? (
<MultiSelectCombo
value={bulkTargetGroupIds}
onChange={setBulkTargetGroupIds}
options={targetGroupOptions}
placeholder="Zielgruppen wählen …"
/>
) : null}
</div>
</div>
</section>
</div>
<div className="exercise-filter-modal__footer">
<button type="button" className="btn" disabled={bulkSubmitting} onClick={onClose}>
Abbrechen
</button>
<button type="button" className="btn btn-primary" disabled={bulkSubmitting} onClick={onSubmit}>
{bulkSubmitting ? 'Speichern…' : 'Anwenden'}
</button>
</div>
</div>
</div>
)
}

View File

@ -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 (
<div
className="admin-modal-backdrop"
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget) onClose()
}}
>
<div
className="admin-modal-sheet exercise-filter-modal"
data-testid="exercise-list-filter-modal"
role="dialog"
aria-modal="true"
aria-labelledby="exercise-filter-modal-title"
onClick={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
<h3 id="exercise-filter-modal-title" className="admin-modal-sheet__title">
Übungen filtern
</h3>
<button type="button" className="btn btn-secondary admin-modal-sheet__close" onClick={onClose}>
Schließen
</button>
</div>
<div className="admin-modal-sheet__body exercise-filter-modal__scroll">
<p className="muted" style={{ marginTop: 0, marginBottom: '14px' }}>
Zwischen den Bereichen gilt <strong>UND</strong>. 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.
</p>
<section className="exercise-filter-section">
<h4 className="exercise-filter-section-title">Zuordnung</h4>
<ExerciseFocusRulePicker
focusOptions={focusOptions}
focusRules={filters.focus_rules}
focusOnlyWithout={filters.focus_only_without}
legacyFocusAreaIds={filters.focus_area_ids}
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--catalog" style={{ marginTop: '12px' }}>
<CatalogRulePicker
label="Stilrichtung"
hint="+ alle nötig (UND). verbietet Zuordnung."
options={styleOptions}
rules={filters.style_direction_rules}
rulesFieldName="style_direction_rules"
placeholder="Stil …"
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/>
<CatalogRulePicker
label="Trainingsstil"
hint="+ alle nötig (UND). verbietet Zuordnung."
options={trainingTypeOptions}
rules={filters.training_type_rules}
rulesFieldName="training_type_rules"
placeholder="Trainingsstil …"
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/>
<CatalogRulePicker
label="Zielgruppe"
hint="+ alle nötig (UND). verbietet Zuordnung."
options={targetGroupOptions}
rules={filters.target_group_rules}
rulesFieldName="target_group_rules"
placeholder="Gruppe …"
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/>
</div>
</section>
<section className="exercise-filter-section">
<h4 className="exercise-filter-section-title">Fähigkeit und zugehörige Stufe</h4>
<div className="exercise-filter-skill-block">
<label className="form-label">Fähigkeit</label>
<MultiSelectCombo
value={filters.skill_ids}
onChange={(v) => setFilters({ ...filters, skill_ids: v })}
options={skillOptions}
placeholder="Fähigkeit suchen …"
/>
<p className="exercise-filter-skill-hint">
Die Stufen filtern nach dem Niveau der Zuordnung Übung Fähigkeit (vonbis).
</p>
<div className="exercise-filter-skill-levels-row">
<label className="exercise-filter-skill-level-field">
<span className="exercise-filter-skill-level-caption">von</span>
<select
className="form-input exercise-filter-level-select"
title="Mindest-Stufe"
value={filters.skill_min_level}
onChange={(e) => setFilters({ ...filters, skill_min_level: e.target.value })}
>
<option value=""></option>
{LEVEL_FILTER_OPTS.map((o) => (
<option key={o.value} value={String(o.level)} title={o.label}>
{o.level}
</option>
))}
</select>
</label>
<span className="exercise-filter-skill-dash" aria-hidden>
</span>
<label className="exercise-filter-skill-level-field">
<span className="exercise-filter-skill-level-caption">bis</span>
<select
className="form-input exercise-filter-level-select"
title="Höchst-Stufe"
value={filters.skill_max_level}
onChange={(e) => setFilters({ ...filters, skill_max_level: e.target.value })}
>
<option value=""></option>
{LEVEL_FILTER_OPTS.map((o) => (
<option key={`m-${o.value}`} value={String(o.level)} title={o.label}>
{o.level}
</option>
))}
</select>
</label>
</div>
</div>
</section>
<section className="exercise-filter-section">
<h4 className="exercise-filter-section-title">Ausblenden / Liste</h4>
<p className="muted" style={{ marginTop: 0, marginBottom: '12px', fontSize: '13px' }}>
Sichtbarkeit und Status steuern Sie unter Freigabe mit + und . Hier nur globale Listen-Optionen.
</p>
<div style={{ marginTop: '6px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
cursor: filters.focus_only_without ? 'not-allowed' : 'pointer',
opacity: filters.focus_only_without ? 0.55 : 1,
}}
>
<input
type="checkbox"
disabled={!!filters.focus_only_without}
checked={!!filters.exclude_without_focus}
onChange={(e) =>
setFilters((prev) => ({
...prev,
exclude_without_focus: e.target.checked,
...(e.target.checked ? { focus_only_without: false } : {}),
}))
}
/>
<span>Übungen ohne Fokusbereich ausblenden</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={!!filters.include_archived}
onChange={(e) => setFilters({ ...filters, include_archived: e.target.checked })}
/>
<span>Archivierte Übungen einblenden (ohne Haken werden sie standardmäßig ausgeblendet)</span>
</label>
</div>
</section>
<section className="exercise-filter-section exercise-filter-section--last">
<h4 className="exercise-filter-section-title">Freigabe</h4>
<p className="muted" style={{ marginTop: 0, marginBottom: '10px', fontSize: '12px' }}>
Pro Übung nur ein Wert: mehrere + bedeuten eine davon (ODER). blendet Werte aus.
</p>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two exercise-filters-modal-grid--catalog">
<CatalogRulePicker
label="Sichtbarkeit"
options={visibilityOptions}
rules={filters.visibility_rules}
rulesFieldName="visibility_rules"
idKind="string"
placeholder="Sichtbarkeit …"
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/>
<CatalogRulePicker
label="Status"
options={statusOptions}
rules={filters.status_rules}
rulesFieldName="status_rules"
idKind="string"
placeholder="Status …"
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/>
</div>
</section>
</div>
<div className="exercise-filter-modal__footer">
<button type="button" className="btn btn-secondary" disabled={savingExercisePrefs} onClick={onSaveStandard}>
{savingExercisePrefs ? 'Speichern…' : 'Als Standard speichern'}
</button>
<button type="button" className="btn" onClick={onResetAll}>
Alle Filter zurücksetzen
</button>
<button type="button" className="btn btn-primary" onClick={onClose}>
Fertig
</button>
</div>
</div>
</div>
)
}

View File

@ -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 (
<div className="card exercise-search-bar">
<label className="form-label">Volltextsuche (Titel, Ziel, )</label>
<datalist id="exercise-search-titles">
{searchTitleSuggestions.map((t) => (
<option key={t} value={t} />
))}
</datalist>
<input
type="search"
className="form-input exercise-search-bar__primary"
placeholder="Suchbegriffe…"
value={searchInput}
onChange={(e) => onSearchInputChange(e.target.value)}
autoComplete="on"
name="exercise-fulltext-search"
list="exercise-search-titles"
enterKeyHint="search"
/>
<label className="form-label">Ergänzende Suche / KI-Vorbereitung (Beta)</label>
<input
type="search"
className="form-input"
placeholder="zweiter Begriff — zusätzliche Volltextsuche (ODER)"
value={aiSearchInput}
onChange={(e) => onAiSearchInputChange(e.target.value)}
autoComplete="on"
name="exercise-ai-search"
list="exercise-search-titles"
enterKeyHint="search"
/>
<div className="exercise-search-bar__actions exercise-search-bar__actions--split">
<div className="exercise-search-bar__actions-main">
<button
type="button"
className={'btn btn-secondary exercise-mine-toggle' + (mineOnly ? ' exercise-mine-toggle--active' : '')}
onClick={onToggleMineOnly}
title="Nur Übungen, die mit deinem Profil als Ersteller gespeichert sind"
>
Meine Übungen
</button>
<button type="button" className="btn btn-secondary exercise-filter-trigger" onClick={onOpenFilter}>
Filter
{filterChips.length > 0 ? (
<span className="exercise-filter-badge" aria-hidden>
{filterChips.length}
</span>
) : null}
</button>
{filterChips.length > 0 ? (
<button type="button" className="btn" onClick={onResetAllFilters}>
Alle entfernen
</button>
) : null}
</div>
</div>
{filterChips.length > 0 ? (
<div className="exercise-filter-chips-row" role="list" aria-label="Aktive Filter">
{filterChips.map((c) => (
<button
key={c.key}
type="button"
role="listitem"
className="exercise-filter-chip"
title="Filter entfernen"
onClick={() => c.onRemove()}
>
<span className="exercise-filter-chip__text">{c.label}</span>
<span className="exercise-filter-chip__x" aria-hidden>
×
</span>
</button>
))}
</div>
) : null}
<p className="exercise-search-hint">
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 ? (
<>
{' '}
<button type="button" className="btn btn-secondary btn-small" onClick={onToggleSelectAllPage}>
{allOnPageSelected ? 'Auswahl auf dieser Seite aufheben' : 'Alle auf dieser Seite auswählen'}
</button>
</>
) : null}
</p>
</div>
)
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -220,6 +220,23 @@ test('9. Übungsliste: nach Laden entweder Treffer-Gitter oder Leerhinweis', asy
console.log('✓ Übungsliste: Endzustand sichtbar (Gitter oder leer)'); 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 }) => { test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 }); await page.setViewportSize({ width: 1280, height: 800 });
await login(page); await login(page);