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
**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 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)

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/`. |
| `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). |
| `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).

View File

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

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

View File

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

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

View File

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

View File

@ -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 **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 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.
- **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).
---
@ -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 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.

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)');
});
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);