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
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:
commit
639392e133
|
|
@ -1,7 +1,7 @@
|
|||
# Shinkan Jinkendo - Fachliches Domänenmodell
|
||||
|
||||
**Version:** 0.4.5
|
||||
**Stand:** 2026-05-12 (Fachlicher Nutzerüberblick: `docs/FACHLICHE_NUTZERFUNKTIONEN.md`)
|
||||
**Version:** 0.4.6
|
||||
**Stand:** 2026-05-14 (Fachlicher Nutzerüberblick: `docs/FACHLICHE_NUTZERFUNKTIONEN.md`)
|
||||
**Basis:** `shinkan_anforderungsdokument_entwurf.md` + Fähigkeitsmatrix
|
||||
|
||||
---
|
||||
|
|
@ -474,6 +474,14 @@ skill_level_definitions (
|
|||
|
||||
**Konkretisierung (037/API):** `POST /api/training-units/from-framework-slot` legt eine geplante Einheit aus dem Slot‑Blueprint an; **`origin_framework_slot_id`** dient als Herkunftsreferenz (**Lineage light**; weiteres Feedback/Lineage‑Konzept: Konzeptpapier Schritt **E**).
|
||||
|
||||
### Parallele Trainingsstreams (Breakout, Entwurf)
|
||||
|
||||
**Fachlich:** Eine Kalender‑**Einheit** kann aus **Phasen** bestehen — z. B. gemeinsamer Block, dann **beliebig viele parallele** „Teilstrecken“ (**Streams**) mit je eigenem Miniplan (Abschnitte/Übungen), erneut gemeinsamer Block. Das ist **nicht** dasselbe wie ein **Rahmenprogramm‑Slot** (Serien‑Session über Wochen): Slots strukturieren **mehrere Einheiten** in einem Programm; **Streams** strukturieren **gleichzeitige** Abläufe **innerhalb einer** Einheit.
|
||||
|
||||
**Sonderfall Stationen:** Rotation kann **innerhalb** einer Stream‑Planung über **Kombinationsübungen** (Methodenprofil/Archetyp) abgebildet werden; hallenweit **synchron** getaktete Rotation ist eine **erweiterte** Ausbaustufe (siehe Fachkonzept).
|
||||
|
||||
**Dokumentation:** `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, Umsetzung `technical/PARALLEL_TRAINING_STREAMS_SPEC.md`.
|
||||
|
||||
---
|
||||
|
||||
## Medien-Archiv & Übungs-Anhänge (Stand 2026-05-07)
|
||||
|
|
|
|||
106
.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md
Normal file
106
.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md
Normal 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 |
|
||||
|----|------|
|
||||
| PT‑01 | Eine **Kalender-Einheit** bleibt **ein** Termin (eine Halle, eine Gruppe, ein Datum) — kein Splitten in künstlich mehrere Kalendereinträge nur für Parallelität. |
|
||||
| PT‑02 | **Unbegrenzte** Anzahl paralleler **Streams** (Teilstrecken) in einer oder mehreren **Parallelphasen**. |
|
||||
| PT‑03 | **Phasenmodell:** klar erkennbar **Gemeinsam** vs. **Parallel** vs. wieder **Gemeinsam** (auch mehrfach hintereinander möglich). |
|
||||
| PT‑04 | **Rollen:** Leitung (Haupttrainer) und Co-Trainer; Zuordnung der Co-Trainer **soll** an konkrete Streams anschließbar sein (heute: nur flache Liste pro Einheit — siehe technische Spec). |
|
||||
| PT‑05 | **Sonderfall Stationen:** rotierender Ablauf (z. B. Wechsel alle 20 Min.) **inhaltlich** unterscheiden zwischen (a) Rotation **innerhalb** einer Teilstrecke und (b) **synchron** getakteter Hallen-Rotation — siehe §5. |
|
||||
| PT‑06 | **Durchführung:** Trainer können „ihre“ Spur auf dem Gerät abarbeiten; Fortschritt pro Spur nachvollziehbar. |
|
||||
|
||||
**Nicht-Ziel (frühe Stufen):** Echtzeit-Synchronisation mehrerer Geräte; individuelles Athleten-Tracking; automatische Raumbelegung.
|
||||
|
||||
---
|
||||
|
||||
## 3. Begriffe
|
||||
|
||||
| Begriff | Definition |
|
||||
|---------|------------|
|
||||
| **Einheit / Termin** | Geplante `training_unit` für Gruppe und Datum — übergeordneter Rahmen des Abends. |
|
||||
| **Phase** | Organisatorischer Block innerhalb der Einheit: entweder **ganze Gruppe** oder **parallel**. |
|
||||
| **Stream / Teilstrecke** | Innerhalb einer Parallelphase: eine von N **gleichzeitig** stattfindenden Unter-Abläufen mit **eigenem** Miniplan (Abschnitte, Übungen, Notizen — analog heutiger Planung). |
|
||||
| **Synchronisationspunkt** | Fachlich: alle treffen sich wieder (Beginn einer **Gemeinschaftsphase** nach Parallelität). |
|
||||
| **Station (Rotation)** | Inhaltlicher Fokus oder Platz, den Teilnehmer **wechselnd** anlaufen; kann als Kombinations-/Zirkellogik oder als koordinierter Hallenrhythmus modelliert werden (§5). |
|
||||
|
||||
**Abgrenzung „Rahmenprogramm-Slot“:** Ein Slot im **Rahmenprogramm** ist eine **Session in einer Serie** (z. B. Woche 1 vs. Woche 2), **nicht** „Teilgruppe A gleichzeitig mit Teilgruppe B in derselben Stunde“. Parallele Streams sind **innerhalb einer Einheit**, orthogonal zum Rahmen-Slot.
|
||||
|
||||
**Abgrenzung **Kombinationsübung**:** Eine Kombi-Übung bündelt **mehrere Einzelübungen** mit Methodenprofil (Archetyp, ggf. Rotation) **in einem Plan-Item**. Sie ersetzt **nicht** mehrere Trainer mit **jeweils eigenem Gesamtablauf**, kann aber **pro Stream** für Stationslogik genutzt werden.
|
||||
|
||||
---
|
||||
|
||||
## 4. Szenarien
|
||||
|
||||
### 4.1 Klassischer Breakout
|
||||
|
||||
30 Min. gemeinsam → 25 Min. drei parallele Streams (Gruppe an Matte / an Schlagsack / Fußarbeit) → 15 Min. gemeinsam.
|
||||
|
||||
### 4.2 Viele Kinder, mehrere Co-Trainer
|
||||
|
||||
Haupttrainer plant die Gesamtstruktur; jeder Co-Trainer sieht in der Durchführung primär die zugewiesene Teilstrecke.
|
||||
|
||||
### 4.3 Rollierendes Stationssystem
|
||||
|
||||
Alle Gruppen arbeiten an **verschiedenen Schwerpunkten** und **wechseln** nach festem Intervall die Station — entweder **nur innerhalb einer Spur** oder **hallenweit synchron** (offene fachliche Präzisierung in MVP vs. später, §5).
|
||||
|
||||
---
|
||||
|
||||
## 5. Sonderfall: Stationen und Kombinationsübungen
|
||||
|
||||
### 5.1 Variante A — Rotation innerhalb einer Teilstrecke
|
||||
|
||||
Eine Teilgruppe rotiert durch mehrere Übungen (Zeit oder Runden). Das liegt nah an einer **Kombinationsübung** mit Archetyp z. B. „Zirkel / zeitgesteuerte Rotation“ und Parametern (Wechselintervall). **Empfehlung:** Diese Variante über **bestehendes** Kombinationsübungs-Konzept in der jeweiligen **Stream-Planung** abbilden (`planning_method_profile`).
|
||||
|
||||
### 5.2 Variante B — Synchron getaktete Hallen-Rotation
|
||||
|
||||
Alle Streams (oder alle Kinder insgesamt) **wechseln gleichzeitig** zur nächsten Station; Startstation kann pro Teilgruppe **versetzt** sein. Das ist **organisatorisch** schwerer: es braucht entweder **Phasen-Metadaten** (globaler Takt) oder eine explizite **Rot/Matrix**. **Empfehlung:** In einer **zweiten Ausbaustufe** abbilden; MVP kann bei Variante A starten, sofern fachlich ausreichend.
|
||||
|
||||
---
|
||||
|
||||
## 6. Rollen und Verantwortlichkeiten
|
||||
|
||||
- **Leitungstrainer:** Hält den Faden, startet Gemeinschaftsphasen, koordiniert Parallelbeginn/-ende (fachlich; ggf. später UI-Hinweise).
|
||||
- **Co-Trainer:** Verantwortlich für **zugeteilte** Streams; Zuordnung soll **pro Stream** möglich werden (Erweiterung gegenüber reiner Einheits-Co-Trainer-Liste).
|
||||
|
||||
---
|
||||
|
||||
## 7. Offene fachliche Entscheidungen
|
||||
|
||||
1. **MVP Umfang:** Reicht **freie Parallelität** ohne **synchronen** Hallenwechsel (Variante B)?
|
||||
2. **Dauer:** Sollen Phasen oder Streams **Soll-Minuten** tragen (nur Anzeige vs. später Timer)?
|
||||
3. **Vorlagen:** Müssen `training_plan_templates` parallel-fähig werden **vor** oder **mit** der ersten Implementierung?
|
||||
4. **Sichtbarkeit:** Dürfen alle Co-Trainer alle Streams sehen, oder „nur meine Spur“?
|
||||
|
||||
---
|
||||
|
||||
## 8. Verwandte Dokumente
|
||||
|
||||
| Dokument | Bezug |
|
||||
|----------|--------|
|
||||
| `technical/TRAINING_FRAMEWORK_SPEC.md` | Rahmen-Slots = Serien-Sessions, **nicht** Intra-Einheit-Parallelität |
|
||||
| `technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md` | Kombinationsübungen, Archetypen, Stationslogik **im Item** |
|
||||
| `functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` | Fachliche Tiefe Kombi |
|
||||
| `docs/FACHLICHE_NUTZERFUNKTIONEN.md` | Nutzerüberblick |
|
||||
| `technical/DATABASE_SCHEMA.md` | Aktueller Stand Tabellen |
|
||||
130
.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md
Normal file
130
.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md
Normal 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 |
|
||||
|
|
@ -15,6 +15,7 @@
|
|||
| `DATABASE_SCHEMA.md` | **Nachgeordnete** Übersicht: Migrationshistorie und Tabellenliste; Detail-DDL primär **hier §2–§3** + SQL unter `backend/migrations/`. |
|
||||
| `functional/DOMAIN_MODEL.md` | Fachliche Begriffe; Kurzverweis auf Progressionsgraph ergänzt. |
|
||||
| `TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` | **Was** und **warum** (Bibliothek vs. Instanz, Governance, CURR‑Tabelle). |
|
||||
| `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, `technical/PARALLEL_TRAINING_STREAMS_SPEC.md` | **Parallele Streams / Breakout innerhalb einer Einheit** — orthogonale Domäne zu **Rahmen‑Slots** (Serien‑Sessions). |
|
||||
|
||||
**Konsequenz:** Diese Datei bleibt der **technische Arbeitspool** für Rahmenprogramm Stufe 1–2. Abschnitt **§4** beschreibt explizit den **aktuellen Produktfreigabe-Umfang** und **bekannte Lücken** (damit Trainingsplanung weiter gebaut werden kann ohne falscher Erwartung an „Alternative‑Pakete“ in der UI).
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
16
backend/fastapi_param_unwrap.py
Normal file
16
backend/fastapi_param_unwrap.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||
|
||||
**Stand:** 2026-05-13
|
||||
**App-Version / DB-Schema:** App **0.8.119**, DB-Schema **`20260514062`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`)
|
||||
**App-Version / DB-Schema:** App **0.8.120**, DB-Schema **`20260514062`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`)
|
||||
|
||||
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
|
||||
|
||||
|
|
@ -76,7 +76,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
|||
- **036 / 037:** Bibliotheks-Rahmen, Slot-Inhalt als **`training_units`** mit **`framework_slot_id`**; **`POST /api/training-units/from-framework-slot`**.
|
||||
- **Code:** `training_framework_programs.py`, `training_planning.py`; Frontend **`TrainingFrameworkProgramEditPage.jsx`**, **`createTrainingUnitFromFrameworkSlot`** in `api.js`.
|
||||
|
||||
### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.119**)
|
||||
### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.120**)
|
||||
|
||||
- **Fachspez & Drift-Schutz:** `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (**§ 10.2.1** IDs, **§ 10.4** Coaching-Stufen, **§ 10.6** Produkt-Backlog, **Anhang A** Abgleich).
|
||||
- **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4a–g** — u. a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung).
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@
|
|||
- **Phase 1 (Teil):** Org-Inbox: **ein** gemeinsamer Ladepfad `fetchOrgInboxSnapshot` für Mount-`useEffect` und `refreshOrgInbox` (gleiche Requests, weniger Drift-Risiko; Verhalten unverändert).
|
||||
- **Phase 2:** **abgeschlossen** (2026-05-14) — Indizes 058–062, Keyset `/api/exercises` + `/api/training-units`, **`/api/dashboard/kpis`** inkl. `training_home`, EXPLAIN-Vorlagen **`scripts/load/explain-readpaths.sql`**.
|
||||
- **Offen Phase 1:** Inbox optional **TTL** / nur bei sichtbarem Widget.
|
||||
- **Phase 3 (gestartet 2026-05-13):** Übungsliste — extrahierte Karte, **virtualisierter** Picker, **lazy** Progressions-Panel; Playwright **Test 9**; Grid `data-testid`. Weiter: God-Pages (Planung/Formular) zerteilen. Nach MVP eine **nachhaltige** Architektur für Wachstum, **Performance** (Server + schwache Clients) und **sichere Feature-Erweiterung**.
|
||||
- **Phase 3 (gestartet 2026-05-13):** Übungsliste modularisiert (Karte, Filter-/Bulk-Modals, virtualisierter Picker, lazy Progression); Playwright **Tests 9–10**. Weiter: God-Pages (Planung/Formular).
|
||||
|
||||
**Ziel:** Nach MVP eine **nachhaltige** Architektur für Wachstum, **Performance** (Server + schwache Clients) und **sichere Feature-Erweiterung**.
|
||||
**Leitdokumente:** [ZIELBILD_ARCHITEKTUR.md](./ZIELBILD_ARCHITEKTUR.md), [SCHULDEN_UND_REMEDIATION.md](./SCHULDEN_UND_REMEDIATION.md), [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md).
|
||||
|
||||
---
|
||||
|
|
@ -80,7 +82,7 @@
|
|||
| Virtualisierung für die längste produktive Liste | A1, S2 |
|
||||
| Schwere Imports auf `import()` umziehen (gezielt) | A4 |
|
||||
|
||||
**Teil umgesetzt (2026-05-13):** `ExercisesListPage` — Karten in `components/exercises/ExerciseListCard.jsx`; Tab „Progressionsgraphen“ lädt **`ExerciseProgressionGraphPanel`** per `React.lazy` + `Suspense`; **`ExercisePickerModal`** virtualisiert (`@tanstack/react-virtual`, Scroll-Container `data-testid="exercise-picker-scroll"`); Gitter `data-testid="exercises-list-grid"` + `content-visibility` in `app.css`; Playwright **Test 9**. Offen: Seite unter Soft-Limit (~500 Zeilen), vollständige Zerteilung `TrainingPlanningPage` / `ExerciseFormPage`.
|
||||
**Teil umgesetzt (2026-05-13):** `ExercisesListPage` — Karten `ExerciseListCard.jsx`; Filter/Massenänderung `ExerciseListFilterModal.jsx` / `ExerciseListBulkModal.jsx`; Tab „Progressionsgraphen“ **lazy**; **Picker** virtualisiert; Gitter `data-testid` + `content-visibility`; Playwright **Tests 9–10**. Offen: Seite unter Soft-Limit (~500 Zeilen, derzeit ~918 LOC), Zerteilung Planung/Übungsformular.
|
||||
|
||||
**Abnahme:** Referenz-Page unter Soft-Limit; Regel S1 für neue Änderungen durchsetzbar.
|
||||
|
||||
|
|
|
|||
240
frontend/src/components/exercises/ExerciseListBulkModal.jsx
Normal file
240
frontend/src/components/exercises/ExerciseListBulkModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
236
frontend/src/components/exercises/ExerciseListFilterModal.jsx
Normal file
236
frontend/src/components/exercises/ExerciseListFilterModal.jsx
Normal 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 (von–bis).
|
||||
</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>
|
||||
)
|
||||
}
|
||||
107
frontend/src/components/exercises/ExerciseListSearchBar.jsx
Normal file
107
frontend/src/components/exercises/ExerciseListSearchBar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
120
frontend/src/hooks/useExerciseListCatalogsAndQuery.js
Normal file
120
frontend/src/hooks/useExerciseListCatalogsAndQuery.js
Normal 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
188
frontend/src/utils/exerciseListFilterChips.js
Normal file
188
frontend/src/utils/exerciseListFilterChips.js
Normal 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
|
||||
}
|
||||
83
frontend/src/utils/exerciseListQuery.js
Normal file
83
frontend/src/utils/exerciseListQuery.js
Normal 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
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user