feat: add exercise progression graph functionality and update versioning
Some checks failed
Deploy Development / deploy (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Failing after 41s

- Integrated exercise progression graphs into the backend and frontend, allowing users to visualize relationships between exercises.
- Updated API endpoints for managing exercise progression graphs and edges, enhancing the exercise management capabilities.
- Added a new tab in the ExercisesListPage for displaying progression graphs and included a panel in the ExerciseFormPage for editing.
- Incremented application version to 0.8.6 and updated changelog to reflect new features and improvements.
This commit is contained in:
Lars 2026-04-30 11:47:50 +02:00
parent 362eb4145f
commit 1b7a0405e9
11 changed files with 1436 additions and 11 deletions

View File

@ -0,0 +1,221 @@
# Konzept: Trainingsplanung über Einheiten hinweg, Kurspläne, Governance, Assessments
**Status:** Arbeitspapier (lebend)
**Stand:** 2026-04-29 (Schritt C Kernentscheide CURR009013)
**Zweck:** Erkenntnisse und **getroffene Entscheidungen** festhalten, um Spec- und Implementierungsdrift zu vermeiden.
**Kanons:** Bei Widersprüchen mit produktiven Specs zuerst diese Datei mit dem Team abstimmen; technische Details ergänzen später in `technical/`.
---
## 1. Kurz-Zielbild (Problem)
- **Heute:** Planung denkt stark pro **einer** Trainingseinheit (Struktur + Übungen).
- **Gewünscht:**
- **Trainingsrahmenprogramm** (über **mehrere** Session-Slots): **mehrere** **Entwicklungsziele** über denselben Zeitraum sowie **Zuordnung von Übungen** zu Sessions; unterstützt **manuelle** Verteilung (ohne Pflicht-Progressionsgraph) und optional **persistente Progressionsbäume** (v. a. Verwalter) — siehe CURR010, CURR011, CURR013.
- **Mehrwöchige / periodische Planung** (z.B. Monat) mit **Entwicklungszielen** und Verteilung aufbauender Elemente über **mehrere Einheiten** auch **ohne** vollständiges „Kursprogramm“-Produkt.
- **Standard-Kurs-/Stufenpläne** (zeitlos, mehrschrittig) als Basis für konkrete Durchführung; Instanzen **editierbar ohne** Änderung der Vorlage.
- **Governance** einheitlich über Domänen (Übungen, Verein, Trainingspläne, Kurspläne …), ohne spätere Modellbrüche.
- **Nachvollziehbarkeit:** real durchgeführte Einheiten an Pläne/Vorlagen zurückführen; **Feedback** zur Verbesserung von Standardplänen.
- **Assessments** als Spezialfall eines Plans (Tests, z.B. Gürtel), später ggf. Teilnehmerbezug und Erwartungsniveau.
---
## 2. Geführte Konzepterstellung (Arbeitsschritte)
**Konzept-Arbeit:** Ziele klären → Optionen → **Entscheidung** in §5 → Glossar §4 pflegen.
### 2.a Umsetzungs-Reihenfolge „Rahmenprogramm“ (product / binding laut CURR-001002)
| Stufe | Inhalt | Status |
|--------|--------|--------|
| **1** | **Progressionsbezüge** zwischen Übungen **persistent speicherbar** (Progressionsbaum / -graph zwischen Übungseinheiten, nicht nur UI) | 📋 nächste fachliche + technische Ausarbeitung |
| **2** | **Planungs-/Rahmenmodus:** Übungen (beliebig oder aus Progression) auf **mehrere** Session-Slots / Trainingseinheiten **verteilen**, **mehrere Ziele**; speicherbare Rahmen-Vorlage (CURR002(2) i.V.m. **CURR010013**) | nach Stufe1 möglich, Logik bereits teils fachlich festgelegt |
| **3** | **Konkrete Einheit:** aus Rahmen-/Verteilungsplan **Vorschläge** beim Ausarbeiten laden; Bezug zur Idee **„Warenkorb“** bei der Übungsplanung | folgt nach 2 |
### 2.b Übrige Konzept-Schritte (noch durchzuarbeiten)
| Schritt | Thema | Status |
|--------|--------|--------|
| **A** | Scope & Reihenfolge relativ zu Kursprogramm, Backlog-Themen | ✅ siehe §5 CURR-001004 |
| **B** | **Governance-Muster** (einheitliche Sichtbarkeit; Bibliothek vs. Instanz) | ✅ Leitplan §2.c; Entscheidungen §5 CURR-005007 |
| **C** | **Rahmenprogramm** — §2.d (**C1C4** ✅ · **C5** Leitplan) | ✅ Kern i.V.m. **CURR009013** |
| **D** | **Kurs-/Stufenprogramm:** nach Rahmenprogramm; plantechnisch ähnlich | 📌 zeitlich nachgelagert (CURR-003) |
| **E** | **Lineage & Feedback** (Einheit ↔ Vorlage/Rahmen; Issues zur Nachbesserung) | ⬜ offen |
| **F** | **Assessments** | 📌 Backlog (CURR-003) |
| **G** | **Progressions-Automatik** (KI, komplexe Vorschläge) | 📌 Backlog (CURR-003) |
**Aktueller Fokus:** Technische Ausarbeitung (ModusFlags Felder zweier Nutzungsbilder, Datenmodell **mehrere Ziele**, SlotÜbungZuordnung; Progressionsgraph Stufe1 parallel). Schritt **E** (Lineage) als nächstes Konzeptpaket möglich.
---
### 2.c Schritt B Governance-Muster (Leitplan)
#### B.1 Ziel
Ein **wiedererkennbares Muster** für alle **Bibliotheksobjekte** (Übung, Trainingsplan-Vorlage, Übungsblock, künftig Rahmen-/Kurs-Vorlage, Progressions-Paket …), damit **CURR-004** (spätere Rechte nach Rolle/Zugehörigkeit) **ohne Modellbruch** nachrüstbar ist.
#### B.2 Ist-Stand im Produkt (kurz, Stand Code-Review)
| Objekt | Relevante Felder / Muster |
|--------|---------------------------|
| `exercises`, `exercise_blocks` | `visibility``private` \| `club` \| `official`, `club_id`, `created_by`**Referenzmuster** |
| `training_plan_templates` | `club_id`, `created_by`, **kein** `visibility` — Abweichung; nachziehen oder Semantik explizit festlegen (siehe CURR-007) |
| `training_units` | `group_id`, `created_by`, `plan_template_id`**Instanz**; Zugriff fachlich über Gruppe/Trainer |
#### B.3 Prinzipien (binding mit §5)
1. **Bibliothek vs. Instanz**
- **Bibliothek:** zeitlose Objekte (Übung, Vorlage, Rahmen-Template, Graph-Definition …). Tragen **Sichtbarkeits-Metadaten** nach gemeinsamem Muster.
- **Instanz:** z.B. `training_unit` am Termin; **inhaltliche** Bearbeitung **entkoppelt** von der Vorlage (**Kopie** der Struktur, nicht „Live-Link“ zur Überschreibung der Vorlage).
- Zugriff auf Instanzen: primär über **Trainingsgruppe / Rolle** (Trainer, Co-Trainer); **nicht** zwingend über `visibility` der Vorlage dublett führen in Phase 1.
2. **Einheitlicher Governance-Kern für neue & nachzuziehende Bibliothekstypen**
- Minimal: **`visibility`** (gleiche Semantik wie bei Übungen), **`club_id`** (optional, NULL = nicht vereinsgebunden / global nutzbar je nach Policy), **`created_by`**.
- **Sparte (`division`):** optional später **`division_id`** oder M:N — **nicht** im MVP-Kern erzwingen, damit keine parallele „Rights-Welt“ pro Objekttyp entsteht.
3. **Policy vs. Speicherung**
- DB-Felder beschreiben **„wem gehört es / welche Lesestufe“** intentionell; **durchsetzende** Filter in API/UI folgen schrittweise (CURR-004).
- Vermeiden: Objekttypen mit völlig anderen Spaltennamen für dieselbe Idee (`owner` vs `created_by` ohne Konvention).
4. **Herkunft / Lineage (nur Metadaten)**
- Wo sinnvoll: `plan_template_id`, später `framework_program_template_id`, optional `forked_from_*` für **Nachvollziehbarkeit** ohne Kopplung für Writes.
- Detail Arbeitspaket **Schritt E**; nicht mit Governance-Kern vermischen.
5. **Progressionsgraph**
- Als eigener Bibliotheks-**Kontainer** oder als **Annotion** an Übung(en): gleicher Governance-Kern; **Ausnahmen** nicht vorwegnahmen ohne technische Spec (§6 offene Fragen).
#### B.4 Bewusste Nicht-Ziele in diesem Schritt
- Keine endgültige **Matrix** „Welche Rolle sieht welches `official`“.
- Keine Pflicht-Anbindung Sportler/Lehrender außerhalb bestehender Gruppenmitgliedschaft.
---
### 2.d Schritt C Trainingsrahmenprogramm (Ausarbeitung · Status **Kern geklärt**)
#### Checkliste — Abgleich Produktfeedback 20260429
| Check | Ergebnis | Verweis |
|-------|----------|---------|
| **C1** | ✅ **Eigene Rahmen-Entität** (C1a) — nicht die Einheitenvorlage überladen | **CURR009** |
| **C2** | ✅ Slots erlauben **beliebige Übungen** über Trainings-/Slots zu verteilen; **persistenter Progressionsgraph** ist **unterstützend**, **PflichtPflege** im Graph gilt **nicht** (AdminAufwand; v.a. global) | **CURR010**, **CURR013** |
| **C3** | ✅ Über denselben Planungszeitraum **mehrere** gleichzeitige **Entwicklungsziele** (nicht nur ein Sammelfeld) | **CURR011** |
| **C4** | ✅ **Zwei Nutzungsbilder**, kein „entweder C4a oder C4b global“ — siehe Ausführungen unten zu **Konkret** vs. **Bibliothek** | **CURR012** |
| **C5** | ✅ `training_plan_template` bleibt **eineEinheitMikrovorlage**; Rahmen adressiert **n** Sessions; pro Slot weiterhin möglich: **optional** `training_plan_template_id` (Technical Spec entscheidet MVPPflicht) | Glossar |
---
#### C4 verständlich: **„Materialisierung“** = zwei echte Situationen
| Nutzungsbild | Kontext | Gruppe / Datum | Typische PersistenzSchicht |
|--------------|---------|----------------|----------------------------|
| **A Kurzfrist / Basisplanung („nächste Wochen“)** | Konkret für **eine Trainingsgruppe** geplant | **Immer:** Gruppe + Termin(e) wie heute beim Training | Existierende oder neu angelegte **`training_units`**; Rahmen fungiert als **Planhilfe**/Vorlage **über mehrere dieser Einheiten** |
| **B Kursprogramm-/LehrplanBibliothek** | Übergeordnete **Struktur** ohne laufenden Kurs | **In der Bibliotheksvorlage selbst oft:** keine Gruppe/Zeit | Rahmen-/KursVorlage **zeit und gruppenlos** gespeichert; **Übertrag** (`Instanziierung`) erst bei „wir machen einen Kurs daraus“: dann Wahl **Gruppe + Zeitraum** → Anlegen oder Befüllen von **`training_units`** |
**Klartext zur früheren Frage „C4a vs. C4b“:** Bei **ModusA** passen **automatisches Anlegen n Einheiten** (früheres C4a) **oder** Zuordnung zu **bereits geplanten** Einheiten (C4b) — je nach Produkt/UI. Bei **ModusB** existieren erst bei der Übernahme überhaupt Gruppe/Zeiten; die Bibliotheksvorlage bleibt **neutral**.
---
#### C2 (Klärung fürs Team)
„**C2**“ im Entwurf bezog sich auf „**wie** weiß ich pro Slot welche Übungen (nur Progression oder auch MikroVorlage)?“ — **aktueller Beschluss:**
Pro Slot: **Zuordnung von Übung(en)** **direkt** (wie „Stückliste“) ist **tragend**; **Progressionsgraph** liefert **Vorschläge / Pakete**, wenn admins sie pflegen — **Trainer** können **ohne** Graph planen.
---
#### C1/Erinnerung Checkbox (historisch)
- **`[x] C1a`** eigene RahmenEntität bestätigt (Chat 20260429).
---
*Konkretisierung technischer Felder (ein Objekt zwei Modi vs. zwei Typen) → **Technical Spec**; keine neuen Contradicts zu CURR001 ohne expliziten Beschluss.*
---
## 3. Richtungen aus Diskussion (nicht-binding, wenn nicht in §5)
| Thema | Richtung |
|--------|----------|
| Assessments als Plantyp | **Spezielle Form eines Trainingsplans**; Test-Übungen; später **Sportlerbezug**/Erwartungsniveau (Gürtel) — aktuell **Backlog**, siehe CURR-003. |
| Bibliothek vs. Durchführung | Konkrete Pläne/Einheiten editierbar **ohne** Änderung am Standard-/Rahmen-Template (**Kopien / Instanzen**). |
*Scope-Reihenfolge und Governance-Startpunkt sind ab 2026-04-29 in §5 festgehalten (CURR-001 bis CURR-004).*
---
## 4. Glossar (wird ergänzt)
| Begriff | Bedeutung (vorläufig) |
|---------|------------------------|
| **Bibliotheksobjekt** | Zeitlose Vorlage (Übung, Trainingsplan-Vorlage, Kursrahmen, später Assessment-Vorlage …) |
| **Governance-Kern** | Einheitliches Minimalset an Metadaten für Bibliotheksobjekte: v.a. `visibility`, `club_id`, `created_by` (siehe §2.c) |
| **Instanz (Training)** | `training_unit` o. Ä.; Zugriff über Gruppe/Rolle; Inhalt als **Kopie** aus Vorlagen, nicht schreibend an Vorlage gekoppelt |
| **Trainingsrahmenprogramm** | Über **mehrere Session-Slots**: **mehrere gleichzeitige Entwicklungsziele** und **Zuordnung von Übungen** zu Slots/Einheiten; **manuelle** Zuordnung + optional **Progression** (**CURR010**, **CURR011**, **CURR013**) |
| **Progressionsbaum / -graph** | Optionale gerichtete Beziehungen **zwischen Übungen** zur **Unterstützung** beim Planen (**CURR010** — **kein PflichtPflegeschritt für jede Zuordnung**); v. a. für globale Pflege |
| **Rahmen-Vorlage** („Framework“) | Bibliothekskontainer mit **ordered Slots**, **zeitlos möglich** (Modus B) oder im Konkretkontext an Gruppe geknüpfte Planung (**CURR012**); eigene Entität (**CURR009**) |
| **Slot** | Position in der Reihenfolge eines Rahmens; trägt **Übungszuweisungen** („Stückliste“), optional Hinweise/Text; Datum optional bis Materialisierung |
| **Materialisierung / Instanziierung** | Überführung aus (ggf. zeit/gruppenloser) **Rahmen-Bibliothek** in konkrete **`training_units`** mit Gruppe/Zeitraum — **CURR012 ModusB**. ModusA bleibt nahe bestehender Einheiten-Planung |
| **Konkret-Planung (Modus A)** | MehrWochen für **bekannte** Trainingsgruppe + Terminen — **`training_units`** |
| **Bibliotheks-Rahmen (Modus B)** | Strukturierte Vorlage ohne Gruppe/Uhrzeit („Kursprogramm“Wurzel bis zum Import in einen Kurs) |
| **Kursprogramm** | Wie Curriculum-/Stufen-Standard; **planerisch nachgelagert** an Rahmenprogramm (CURR-003) |
---
## 5. Entscheidungsprotokoll (binding)
| ID | Datum | Entscheidung | Begründung / Kontext |
|----|--------|---------------|---------------------|
| **CURR-013** | 2026-04-29 | Präzisierung zu **CURR002 (1)+(2)**: **Persistenter Progressionsgraph** ist **unterstützend**, **nicht** die alleinige Quelle für Slotgeplante Übungen. Der Planungsmodus **muss beliebige Übungen** auf Slots verteilen **ohne** Pflicht zur GraphPflege im Alltag. Reichhaltiges Pflegen von Progressionsbezügen v.a. durch **globale Verwalter** erwünscht, nicht verpflichtend pro Übungszuordnung. | Chat C2 |
| **CURR-012** | 2026-04-29 | **Zwei Nutzungsbilder:** **ModusA (Konkret)** — MehrWochenplan für **bekannte Trainingsgruppe + Termine** → bestehende/neue **`training_units`**; Rahmen als Planhilfe über mehrere Einheiten. **ModusB (Bibliothek)****Kurs-/StufenStruktur ohne** Gruppe/Uhrzeit bis zur **Übernahme**; dann Zuordnung von Gruppe+Zeitraum und **Instanziierung** in **`training_units`**. BulkAnlegen (**C4a**) und Verknüpfen existierender (**C4b**) sind **ModusAAlternativen**. | Chat C4 |
| **CURR-011** | 2026-04-29 | **Mehrere parallele Entwicklungsziele** im selben Planungszeitraum → Datenmodell: **Zielliste mit ≥1 Einträgen** auf RahmenEbene (Details Technical Spec). **Nicht** nur ein einziges SammelzielFeld. | Chat C3 |
| **CURR-010** | 2026-04-29 | **SlotInhalt:** tragend **direkte Zuordnung beliebiger Übungen** („Stückliste“); Option **Graph** für Vorschläge/Anreicherung. **`training_plan_template_id` pro Slot** weiterhin **optional** (MVP offen). | Chat C2 |
| **CURR-009** | 2026-04-29 | **C1a:** **Neue eigene BibliotheksEntität** für MehrSlotRahmen (**Framework**/`training_framework_*`-Arbeitscode); **`training_plan_template`** bleibt **eine Einheit**Mikrovorlage (**C5**). | Chat C1 |
| **CURR-008** | 2026-04-29 | **Migration / Backfill (Early-Installation):** Migrationen betreffen aktuell nur **frühe Systeme ohne weitere Nutzer**. VereinsZuordnung für Bestands-/DefaultZeilen erfolgt beim Backfill mit dem **StandardVerein der Installation** (konkret: ClubID bzw. Konvention im MigrateSkript dokumentieren — z.B. erster Verein oder `DEFAULT_CLUB_ID` in Env). **`visibility`**-Default beim Hinzufügen der Spalte: **`club`**, wenn fachlich alles diesem Vereinskontext zugeordnet wird; anderenfalls bei MultiTenant eigene MigrateAnweisung. | Nutzerfestlegung; pragmatisches Backfill ohne MehrMandantenHeuristik; §6 entsprechend vereinfacht. |
| **CURR-007** | 2026-04-29 | **`training_plan_templates`** weichen aktuell vom Übungs-Muster ab (**kein** `visibility`). **Festlegung:** Bei der nächsten sinnvollen Migration auf den **gemeinsamen Governance-Kern** angleichen (**`visibility`** zusätzlich zu `club_id` / `created_by`), Semantik **analog zu Übungen** im Vereinskontext; von dieser Linie nur abweichen, wenn ausdrücklich anders dokumentiert. | Bekannte Schulden bis Migration vermeiden; neue Objekttypen sollen CURR-005 folgen; Zuordnung/Backfill **CURR008**. |
| **CURR-006** | 2026-04-29 | **Instanz-Ebene (`training_unit` u. Ä.):** In der Rahmenprogramm-Phase **keine** neue parallele `visibility`-Schicht auf der Einheit; **Zugriff** über **`group_id`** und bestehende Trainer-/Mitgliedschaftslogik. **Lineage** zu Vorlagen/Rahmen nur als **optionale Metadaten-FKs** (`plan_template_id`, spätere Erweiterungen), ohne dass Schreiben in der Einheit die Vorlage ändert. | CURR-004-kompatibel: API-Policy später ergänzbar ohne Instanz-Umbau. |
| **CURR-005** | 2026-04-29 | **Governance-Kern für Bibliotheksobjekte** (Übung, neue/alte Vorlagen, künftig Rahmen-/Kurs-/Progressions-Container): **`visibility`** im Sinne von `exercises` (`private` \| `club` \| `official`), **`club_id`** optional (NULL wenn nicht vereinsspezifisch), **`created_by`**. Sparte später optional **`division_id`** oder Verknüpfungstabelle — **nicht** Blocker für ersten Progressions-/Rahmen-Entwurf. | Einheitliche Semantik; Altabweichungen gezielt nachziehen (CURR-007). |
| **CURR-004** | 2026-04-29 | **Sichtbarkeit:** Aktuell **globale Nutzungs-/Planungssicht** für alle; Architektur und Datenmodell aber **von Anfang an** so gestalten, dass **spätere** Einschränkungen nach Rollen und Zugehörigkeiten (Verein, Gruppe, Sparte …) ohne Bruch eingeführt werden können. | Vorbereitung einheitlicher Governance; siehe CURR-005/006 für Konkretisierung. |
| **CURR-003** | 2026-04-29 | **Nachgelagert / explizites Backlog (nicht Phase Rahmenprogramm):** **Kursprogramm** kommt nach dem Rahmenprogramm (planerisch ähnlich); **Assessments**, **Sportlerakte**, **KI-Optimierungen** ebenfalls zurückgestellt bis Rahmenkern steht. | Priorität liegt auf Persistenz Progression → Multi-Einheiten-Planung → Einheitenvorschläge. |
| **CURR-002** | 2026-04-29 | **Umsetzungsreihenfolge Rahmenprogramm:** **(1)** Progressionsbezüge **zwischen Übungen** müssen als **persistierter Graph/Baum** modellierbar sein. **(2)** **Planungsmodus**, der **Übungen** (u.a. aus Progression, **auch manuell**, **CURR010**) **auf mehrere Trainingseinheiten verteilt**, **mehrere Ziele** (**CURR011**) enthält und als **speicherbares RahmenTemplate** dient. **(3)** **Warenkorb**-Idee beim Ausarbeiten einer **einzelnen** Einheit. | Reihenfolge vom Datenkern zur UX; Zuordnung/Graph **CURR013** |
| **CURR-001** | 2026-04-29 | Vor dem separaten Produkt **„Kursprogramm“** wird das **„Trainingsrahmenprogramm“** (Ziele + Progression über mehrere Einheiten) angegangen — **nicht** umgekehrt. | Kursprogramm baut auf derselben Planungslogik auf; erst gemeinsamen Kern liefern. |
*Format:* Neue Zeile **oben** einfügen (neueste zuerst).
---
## 6. Offene Fragen (Backlog)
- **Minimal-UI** Rahmenprogramm vs. bestehende Kalender/Liste `training_units`?
- ~~Governance Migrate Default~~ → **CURR008**
- ~~Slots / C4 generisch~~**CURR012** (Modi A/B)
- ~~Relation zwei Vorlagenfamilien~~**CURR009** (**Rahmen** neu, **Einheit** bleibt `training_plan_template`)
- **Technical:** gleiche DBEntität mit `plan_mode` (**A \| B**) vs. **konsequente Teilung** zweier Objekttypen?
- **Progressionsgraph:** Kantentypen (nächste Übung vs. **Variante** vs. Level innerhalb gleicher Übung); optional **Skills**-Anbindung
---
## 7. Produkt-Backlog (explizit, nicht aktuelle Phase)
Siehe **CURR-003:** Kurs-/Stufenprogramm (nach Rahmenkern), Assessments (Plantyp/Testübungen/Sportler), Sportlerakte, KI-/optimierungsunterstützte Planung.
---
## 8. Nächste Aktion (für dich / Team)
1. ~~**Schritt C**~~ · siehe §2.d · **CURR009 bis CURR013**
2. **Technical Spec:** `technical/TRAINING_FRAMEWORK_SPEC.md` — Datenmodell **RahmenEntität** + **Zielliste** + SlotZuordnung; ModusA/B; Graph Stufe1 (**CURR002**(1))
3. **Migrate** weiter **CURR007 / CURR008**
4. Konzeptpaket optional **Schritt E** Lineage vor Implementierung Großrelease
---
## 9. Changelog dieser Datei
| Datum | Änderung |
|-------|-----------|
| 2026-04-28 | Technische Ausarbeitung gebündelt: neue Datei **`technical/TRAINING_FRAMEWORK_SPEC.md`** (Stub); Verweis §8. |
| 2026-04-29 | **CURR009013**, **CURR002** präzisiert; Glossar Modi A/B Slot; §2.d C geklärt; §6Backlog gekürzt. |
| 2026-04-29 | CURR008 (Migration StandardVerein); **§2.d Schritt C** Checkpoints C1C5; Glossar/§6 angepasst. |
| 2026-04-29 | CURR-001004; Umsetzungsreihenfolge §2.a; Glossar Rahmenprogramm/Progressionsgraph; Scope-Backlog. |
| 2026-04-28 | Erstanlage aus Konzept-Arbeitsphase Chat; Schritttabelle und Protokollstruktur. |

View File

@ -0,0 +1,86 @@
# Trainingsrahmenprogramm — Technische Spezifikation (Stub)
**Status:** Entwurf · angelegt 2026-04-28
**Bindendes Fachkonzept / Entscheide:** `.claude/docs/functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR001 bis CURR013)
---
## 1. Abgrenzung zu anderen Dokumenten
| Dokument | Rolle · warum **nicht** hier hineinmischen |
|----------|--------------------------------------------|
| `EXERCISES_DATABASE_FINAL.md`, `EXERCISES_ARCHITECTURE.md`, `EXERCISES_API_SPEC.md` | **Übungskatalog** inkl. Varianten-Progression (Migration 014). Kein Ort für Multi-Session-Rahmen, Slots, Rahmen-Ziele oder `training_units`-Orchestrierung. |
| `DATABASE_SCHEMA.md` | **Nachgeordnete** Übersicht: Migrationshistorie und kompakte Tabellenliste. Neue Migrationen hier **einzeilig** ergänzen, Detail-DDL gehört primär hierher (**§3**) oder in Migrations-SQL. |
| `functional/DOMAIN_MODEL.md` | Fachliche Kernbegriffe; bei Release des Rahmenfeatures **ein kurzer Unterabschnitt** „Rahmen-Vorlage / Slots“ ergänzen, Verweis auf diese Datei. |
| `TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` | **Was** und **warum** (Modus A/B, Governance, CURRTabelle). Keine DDL-Pflicht. |
**Konsequenz:** Diese Datei ist der **technische Arbeitspool** für Rahmenprogramm-Stufe 12 (Graph + Rahmenentität + Slots + Zielliste + API/Migrationen).
---
## 2. Noch auszuarbeiten (Checkliste)
- [ ] **Entität(en):** eigene Bibliotheks-Entität für Mehr-Slot-Rahmen (`training_framework_*` o. Ä., siehe **CURR009**); Abgrenzung zu `training_plan_templates` (**C5**).
- [ ] **Modus A vs. B:** ein Typ mit nullable `group_id` / `plan_mode` vs. zwei Objekttypen — **offen** (Funktionskonzept §6).
- [ ] **Zielliste:** ≥1 Zieleinträge pro Rahmen (**CURR011**); Felder und Optionalität.
- [ ] **Slots:** Reihenfolge, Notizen, **direkte Übungszuordnungen** (M:N oder Join-Tabelle); optionales `training_plan_template_id` pro Slot (**CURR010**, MVP offen).
- [x] **Progressionsgraph zwischen Übungen:** Tabellen/Kanten, getrennt von Varianten-Progression in `exercise_variants` (**CURR002 (1)**, **CURR013**) — siehe **§3**.
- [ ] **Instanziierung (Modus B):** FK/Metadaten zu `training_units`, Bulk vs. Verknüpfen (**CURR012**).
- [ ] **Governance:** `visibility`, `club_id`, `created_by` für neue Bibliothekstypen (**CURR005**); Nachzug `training_plan_templates` (**CURR007**, **CURR008**).
- [ ] **REST/API:** Endpoints grob, AuthZ analog Trainingsplanung.
---
## 3. Progressionsgraph Übung → Übung (Stufe 1, Migration 032)
**Abgrenzung:** Kanten verbinden **`exercises.id``exercises.id`**. Die Varianten-Kette innerhalb einer Übung bleibt in `exercise_variants` (Migration 014).
### Schema
| Tabelle | Zweck |
|---------|--------|
| `exercise_progression_graphs` | Kontainer für mehrere getrennte Graphen (Name, Beschreibung, **`visibility`** `private\|club\|official`, **`club_id`**, **`created_by`**). |
| `exercise_progression_edges` | Gerichtete Kante: `graph_id`, `from_exercise_id`, `to_exercise_id`, **`edge_type`** (VARCHAR, Default `next_exercise`, erweiterbar ohne ENUM-Zwang). |
- **Eindeutigkeit:** `UNIQUE (graph_id, from_exercise_id, to_exercise_id, edge_type)`; `CHECK (from_exercise_id <> to_exercise_id)`.
- **Löschregeln (FK):**
- Kante → Graph: **`ON DELETE CASCADE`** (Graph löschen entfernt alle Kanten).
- Kante → Übung (von/nach): **`ON DELETE CASCADE`** (Übung löschen entfernt alle incident Kanten in allen Graphen — konsistent mit anderen Übungs-Abhängigkeiten wie `exercise_skills`).
- **Indizes:** `graph_id`, `from_exercise_id`, `to_exercise_id`.
### API (FastAPI, Prefix `/api`)
| Methode | Pfad | Kurzbeschreibung |
|---------|------|------------------|
| GET | `/exercise-progression-graphs` | Liste (Admin/Superadmin: alle; sonst nur eigene `created_by`). |
| GET | `/exercise-progression-graphs/{id}` | Detail; Query `include_edges=true` für eingebettete Kanten. |
| POST | `/exercise-progression-graphs` | Anlegen (`_has_planning_role`, wie Trainingsvorlagen). |
| PUT | `/exercise-progression-graphs/{id}` | Metadaten (`name`, `description`, `visibility`, `club_id`). |
| DELETE | `/exercise-progression-graphs/{id}` | Graph + Kanten (CASCADE). |
| GET | `/exercise-progression-graphs/{id}/edges` | Kantenliste; optional Filter `from_exercise_id`, `to_exercise_id`. |
| POST | `/exercise-progression-graphs/{id}/edges` | Kante anlegen; Duplikat → **409**. |
| DELETE | `/exercise-progression-graphs/{id}/edges/{edge_id}` | Kante löschen. |
**AuthZ:** Analog `training_plan_templates`: Zugriff auf einen Graphen nur **Admin/Superadmin** oder **Ersteller** (`created_by`).
### Offen / später
- Weitere **`edge_type`**-Semantik und Filter in der UI („Vorschläge“ beim Planen).
- **CURR013:** Graph bleibt unterstützend; keine Pflicht, jeden Trainingsplan über den Graph zu modellieren.
- Anbindung **`training_units`** / Rahmen-Slots (**Stufe 2**, CURR009012).
### Manuelle Prüfung
1. Nach Migration: `GET /api/exercise-progression-graphs` mit gültigem `X-Auth-Token`.
2. `POST /exercise-progression-graphs` mit `{"name":"Testgraph"}``201`.
3. `POST …/{id}/edges` mit gültigen `from_exercise_id` / `to_exercise_id`; zweites identisches Quadrupel → `409`.
4. Übung löschen, die an einer Kante beteiligt ist: Kanten verschwinden (CASCADE).
---
## 4. Changelog
| Datum | Änderung |
|-------|----------|
| 2026-04-30 | §3: Migration 032 + REST-Endpunkte Progressionsgraph (CURR002 (1)); Checkliste Graph erledigt. |
| 2026-04-28 | Erstanlage: Abgrenzung + Checkliste (Artefakt der Doku-Entscheidung „eigene technische Spec“). |

View File

@ -152,11 +152,12 @@ def read_root():
}
# Register routers
from routers import auth, profiles, exercises, clubs, skills, training_planning, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, skills, training_planning, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin
app.include_router(auth.router)
app.include_router(profiles.router)
app.include_router(exercises.router)
app.include_router(exercise_progression_graphs.router)
app.include_router(clubs.router)
app.include_router(skills.router)
app.include_router(training_planning.router)

View File

@ -0,0 +1,38 @@
-- Migration 032: Progressionsgraph zwischen Übungen (Übung → Übung), getrennt von Varianten-Progression (014).
-- CURR-002 (1): gerichtete Kanten mit optionalem Graph-Kontainer; FK auf exercises; MVP edge_type erweiterbar.
CREATE TABLE IF NOT EXISTS exercise_progression_graphs (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
description TEXT,
visibility VARCHAR(50) NOT NULL DEFAULT 'private'
CHECK (visibility IN ('private', 'club', 'official')),
club_id INT REFERENCES clubs(id) ON DELETE SET NULL,
created_by INT REFERENCES profiles(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_exercise_progression_graphs_club ON exercise_progression_graphs(club_id);
CREATE INDEX IF NOT EXISTS idx_exercise_progression_graphs_creator ON exercise_progression_graphs(created_by);
CREATE INDEX IF NOT EXISTS idx_exercise_progression_graphs_visibility ON exercise_progression_graphs(visibility);
DROP TRIGGER IF EXISTS exercise_progression_graphs_update ON exercise_progression_graphs;
CREATE TRIGGER exercise_progression_graphs_update
BEFORE UPDATE ON exercise_progression_graphs
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
CREATE TABLE IF NOT EXISTS exercise_progression_edges (
id SERIAL PRIMARY KEY,
graph_id INT NOT NULL REFERENCES exercise_progression_graphs(id) ON DELETE CASCADE,
from_exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
to_exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
edge_type VARCHAR(50) NOT NULL DEFAULT 'next_exercise',
created_at TIMESTAMP DEFAULT NOW(),
CHECK (from_exercise_id <> to_exercise_id),
UNIQUE (graph_id, from_exercise_id, to_exercise_id, edge_type)
);
CREATE INDEX IF NOT EXISTS idx_progression_edges_graph ON exercise_progression_edges(graph_id);
CREATE INDEX IF NOT EXISTS idx_progression_edges_from ON exercise_progression_edges(from_exercise_id);
CREATE INDEX IF NOT EXISTS idx_progression_edges_to ON exercise_progression_edges(to_exercise_id);

View File

@ -0,0 +1,4 @@
-- Migration 033: Optionale Notiz / Entwicklungsziel pro Progressions-Kante (Übung→Übung).
ALTER TABLE exercise_progression_edges
ADD COLUMN IF NOT EXISTS notes TEXT;

View File

@ -0,0 +1,346 @@
"""
Progressionsgraph zwischen Übungen (Übung Übung), Migration 032.
AuthZ analog training_plan_templates (_template_access / _has_planning_role).
"""
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from psycopg2 import IntegrityError
from auth import require_auth
from db import get_db, get_cursor, r2d
from routers.training_planning import _has_planning_role
router = APIRouter(prefix="/api", tags=["exercises"])
class ProgressionGraphCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
description: Optional[str] = None
visibility: str = Field(default="private", pattern="^(private|club|official)$")
club_id: Optional[int] = None
class ProgressionGraphUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = None
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
club_id: Optional[int] = None
class ProgressionEdgeCreate(BaseModel):
from_exercise_id: int = Field(..., gt=0)
to_exercise_id: int = Field(..., gt=0)
edge_type: str = Field(default="next_exercise", min_length=1, max_length=50)
notes: Optional[str] = Field(None, max_length=4000)
class ProgressionEdgeUpdate(BaseModel):
notes: Optional[str] = Field(None, max_length=4000)
_EDGE_SELECT = """
SELECT e.id, e.graph_id, e.from_exercise_id, e.to_exercise_id, e.edge_type, e.notes, e.created_at,
ef.title AS from_exercise_title, et.title AS to_exercise_title
FROM exercise_progression_edges e
JOIN exercises ef ON ef.id = e.from_exercise_id
JOIN exercises et ON et.id = e.to_exercise_id
"""
def _graph_access(cur, graph_id: int, profile_id: int, role: str) -> dict:
cur.execute(
"SELECT * FROM exercise_progression_graphs WHERE id = %s",
(graph_id,),
)
r = cur.fetchone()
if not r:
raise HTTPException(status_code=404, detail="Progressionsgraph nicht gefunden")
row = r2d(r)
if role in ("admin", "superadmin"):
return row
if row.get("created_by") != profile_id:
raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Progressionsgraph")
return row
def _assert_exercises_exist(cur, *exercise_ids: int) -> None:
unique_ids = list(dict.fromkeys(exercise_ids))
if not unique_ids:
return
cur.execute(
f"SELECT id FROM exercises WHERE id IN ({','.join(['%s'] * len(unique_ids))})",
tuple(unique_ids),
)
found = {r["id"] if isinstance(r, dict) else r[0] for r in cur.fetchall()}
missing = set(unique_ids) - found
if missing:
raise HTTPException(
status_code=400,
detail=f"Unbekannte Übung(en): {sorted(missing)}",
)
@router.get("/exercise-progression-graphs")
def list_progression_graphs(session: dict = Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
with get_db() as conn:
cur = get_cursor(conn)
if role in ("admin", "superadmin"):
cur.execute(
"""
SELECT g.*,
(SELECT COUNT(*) FROM exercise_progression_edges e WHERE e.graph_id = g.id) AS edges_count
FROM exercise_progression_graphs g
ORDER BY g.updated_at DESC NULLS LAST, g.name
"""
)
else:
cur.execute(
"""
SELECT g.*,
(SELECT COUNT(*) FROM exercise_progression_edges e WHERE e.graph_id = g.id) AS edges_count
FROM exercise_progression_graphs g
WHERE g.created_by = %s
ORDER BY g.updated_at DESC NULLS LAST, g.name
""",
(profile_id,),
)
return [r2d(r) for r in cur.fetchall()]
@router.get("/exercise-progression-graphs/{graph_id}")
def get_progression_graph(
graph_id: int,
include_edges: bool = Query(default=False),
session: dict = Depends(require_auth),
):
profile_id = session["profile_id"]
role = session.get("role")
with get_db() as conn:
cur = get_cursor(conn)
row = _graph_access(cur, graph_id, profile_id, role)
if include_edges:
cur.execute(
_EDGE_SELECT + " WHERE e.graph_id = %s ORDER BY e.id",
(graph_id,),
)
row["edges"] = [r2d(r) for r in cur.fetchall()]
return row
@router.post("/exercise-progression-graphs", status_code=201)
def create_progression_graph(
body: ProgressionGraphCreate,
session: dict = Depends(require_auth),
):
profile_id = session["profile_id"]
role = session.get("role")
if not _has_planning_role(role):
raise HTTPException(status_code=403, detail="Keine Berechtigung zum Anlegen von Progressionsgraphen")
name = body.name.strip()
if not name:
raise HTTPException(status_code=400, detail="name ist Pflicht")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
INSERT INTO exercise_progression_graphs (name, description, visibility, club_id, created_by)
VALUES (%s, %s, %s, %s, %s)
RETURNING id
""",
(name, body.description, body.visibility, body.club_id, profile_id),
)
gid = cur.fetchone()["id"]
conn.commit()
return get_progression_graph(gid, include_edges=False, session=session)
@router.put("/exercise-progression-graphs/{graph_id}")
def update_progression_graph(
graph_id: int,
body: ProgressionGraphUpdate,
session: dict = Depends(require_auth),
):
profile_id = session["profile_id"]
role = session.get("role")
data = body.model_dump(exclude_unset=True)
if not data:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
with get_db() as conn:
cur = get_cursor(conn)
_graph_access(cur, graph_id, profile_id, role)
fields: List[str] = []
params: List[Any] = []
if "name" in data:
n = (data["name"] or "").strip()
if not n:
raise HTTPException(status_code=400, detail="name ist Pflicht")
fields.append("name = %s")
params.append(n)
if "description" in data:
fields.append("description = %s")
params.append(data["description"])
if "visibility" in data:
fields.append("visibility = %s")
params.append(data["visibility"])
if "club_id" in data:
fields.append("club_id = %s")
params.append(data["club_id"])
fields.append("updated_at = NOW()")
params.append(graph_id)
cur.execute(
f"UPDATE exercise_progression_graphs SET {', '.join(fields)} WHERE id = %s",
tuple(params),
)
conn.commit()
return get_progression_graph(graph_id, include_edges=False, session=session)
@router.delete("/exercise-progression-graphs/{graph_id}")
def delete_progression_graph(graph_id: int, session: dict = Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
with get_db() as conn:
cur = get_cursor(conn)
_graph_access(cur, graph_id, profile_id, role)
cur.execute("DELETE FROM exercise_progression_graphs WHERE id = %s", (graph_id,))
conn.commit()
return {"ok": True}
@router.get("/exercise-progression-graphs/{graph_id}/edges")
def list_progression_edges(
graph_id: int,
from_exercise_id: Optional[int] = Query(default=None),
to_exercise_id: Optional[int] = Query(default=None),
session: dict = Depends(require_auth),
):
profile_id = session["profile_id"]
role = session.get("role")
with get_db() as conn:
cur = get_cursor(conn)
_graph_access(cur, graph_id, profile_id, role)
q = _EDGE_SELECT + " WHERE e.graph_id = %s"
params: List[Any] = [graph_id]
if from_exercise_id is not None:
q += " AND e.from_exercise_id = %s"
params.append(from_exercise_id)
if to_exercise_id is not None:
q += " AND e.to_exercise_id = %s"
params.append(to_exercise_id)
q += " ORDER BY e.id"
cur.execute(q, tuple(params))
return [r2d(r) for r in cur.fetchall()]
@router.post("/exercise-progression-graphs/{graph_id}/edges", status_code=201)
def create_progression_edge(
graph_id: int,
body: ProgressionEdgeCreate,
session: dict = Depends(require_auth),
):
profile_id = session["profile_id"]
role = session.get("role")
if body.from_exercise_id == body.to_exercise_id:
raise HTTPException(status_code=400, detail="from_exercise_id und to_exercise_id müssen unterschiedlich sein")
with get_db() as conn:
cur = get_cursor(conn)
_graph_access(cur, graph_id, profile_id, role)
_assert_exercises_exist(cur, body.from_exercise_id, body.to_exercise_id)
et = (body.edge_type or "next_exercise").strip() or "next_exercise"
notes = (body.notes or "").strip() or None
try:
cur.execute(
"""
INSERT INTO exercise_progression_edges (
graph_id, from_exercise_id, to_exercise_id, edge_type, notes
)
VALUES (%s, %s, %s, %s, %s)
RETURNING id
""",
(graph_id, body.from_exercise_id, body.to_exercise_id, et, notes),
)
new_id = cur.fetchone()["id"]
cur.execute(_EDGE_SELECT + " WHERE e.id = %s", (new_id,))
row = r2d(cur.fetchone())
conn.commit()
except IntegrityError as e:
conn.rollback()
raise HTTPException(
status_code=409,
detail="Kante existiert bereits (graph_id, von, nach, edge_type)",
) from e
return row
@router.put("/exercise-progression-graphs/{graph_id}/edges/{edge_id}")
def update_progression_edge(
graph_id: int,
edge_id: int,
body: ProgressionEdgeUpdate,
session: dict = Depends(require_auth),
):
profile_id = session["profile_id"]
role = session.get("role")
data = body.model_dump(exclude_unset=True)
if not data:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
with get_db() as conn:
cur = get_cursor(conn)
_graph_access(cur, graph_id, profile_id, role)
cur.execute(
"SELECT id FROM exercise_progression_edges WHERE id = %s AND graph_id = %s",
(edge_id, graph_id),
)
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Kante nicht gefunden")
if "notes" in data:
n = data["notes"]
notes_val = (n or "").strip() or None if n is not None else None
cur.execute(
"UPDATE exercise_progression_edges SET notes = %s WHERE id = %s AND graph_id = %s",
(notes_val, edge_id, graph_id),
)
conn.commit()
cur.execute(_EDGE_SELECT + " WHERE e.id = %s AND e.graph_id = %s", (edge_id, graph_id))
return r2d(cur.fetchone())
@router.delete("/exercise-progression-graphs/{graph_id}/edges/{edge_id}")
def delete_progression_edge(
graph_id: int,
edge_id: int,
session: dict = Depends(require_auth),
):
profile_id = session["profile_id"]
role = session.get("role")
with get_db() as conn:
cur = get_cursor(conn)
_graph_access(cur, graph_id, profile_id, role)
cur.execute(
"""
DELETE FROM exercise_progression_edges
WHERE id = %s AND graph_id = %s
RETURNING id
""",
(edge_id, graph_id),
)
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Kante nicht gefunden")
conn.commit()
return {"ok": True}

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.4"
BUILD_DATE = "2026-04-27"
DB_SCHEMA_VERSION = "20260428031"
APP_VERSION = "0.8.6"
BUILD_DATE = "2026-04-30"
DB_SCHEMA_VERSION = "20260430033"
MODULE_VERSIONS = {
"auth": "1.0.0",
@ -11,7 +11,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
"exercises": "2.1.0", # Varianten-CRUD API + UI; Listen mit include_variants
"exercises": "2.2.0", # Progressionsgraph Übung→Übung (Migration 032, Router exercise_progression_graphs)
"training_units": "0.1.0",
"training_programs": "0.1.0",
"planning": "0.3.0",
@ -23,6 +23,23 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.6",
"date": "2026-04-30",
"changes": [
"DB 033: exercise_progression_edges.notes (Entwicklungsziel)",
"API: Kanten mit notes; JOIN Übungstitel in Listen; PUT Kanten-Notiz",
"Frontend: Progressionsgraphen-Tab unter Übungen + Bereich in Übung bearbeiten",
],
},
{
"version": "0.8.5",
"date": "2026-04-30",
"changes": [
"DB 032: exercise_progression_graphs + exercise_progression_edges (Übung→Übung, edge_type next_exercise)",
"API: CRUD Progressionsgraphen und Kanten unter /api/exercise-progression-graphs",
],
},
{
"version": "0.8.4",
"date": "2026-04-27",

View File

@ -0,0 +1,599 @@
/**
* Verwaltung mehrerer Progressionsgraphen (Übung Übung) mit Kantentypen Nachfolger / Schwester.
* Varianten-Ketten bleiben unter Übungsvarianten der jeweiligen Übung.
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import ExercisePickerModal from './ExercisePickerModal'
const VIS_OPTIONS = [
{ value: 'private', label: 'Privat' },
{ value: 'club', label: 'Verein' },
{ value: 'official', label: 'Offiziell' },
]
function edgeTypeLabel(type) {
if (type === 'next_exercise') return 'Nachfolger'
if (type === 'sibling') return 'Schwester'
return type || '—'
}
export default function ExerciseProgressionGraphPanel({
anchorExerciseId = null,
anchorTitle = null,
}) {
const [graphs, setGraphs] = useState([])
const [selectedGraphId, setSelectedGraphId] = useState(null)
const [edges, setEdges] = useState([])
const [busy, setBusy] = useState(false)
const [loadErr, setLoadErr] = useState(null)
const [newGraphName, setNewGraphName] = useState('')
const [newGraphVisibility, setNewGraphVisibility] = useState('private')
const [metaName, setMetaName] = useState('')
const [metaDescription, setMetaDescription] = useState('')
const [metaVisibility, setMetaVisibility] = useState('private')
const [relationKind, setRelationKind] = useState('progression')
const [firstEx, setFirstEx] = useState(null)
const [secondEx, setSecondEx] = useState(null)
const [edgeNotes, setEdgeNotes] = useState('')
const [pickSlot, setPickSlot] = useState(null)
const [filterAnchorOnly, setFilterAnchorOnly] = useState(!!anchorExerciseId)
const [editingEdgeNotes, setEditingEdgeNotes] = useState(null)
const [notesDraft, setNotesDraft] = useState('')
const refreshGraphs = useCallback(async () => {
const list = await api.listExerciseProgressionGraphs()
setGraphs(Array.isArray(list) ? list : [])
return list
}, [])
const refreshEdges = useCallback(async (gid) => {
if (!gid) {
setEdges([])
return
}
const list = await api.listExerciseProgressionEdges(gid)
setEdges(Array.isArray(list) ? list : [])
}, [])
useEffect(() => {
let cancelled = false
;(async () => {
setBusy(true)
setLoadErr(null)
try {
await refreshGraphs()
} catch (e) {
if (!cancelled) setLoadErr(e.message || String(e))
} finally {
if (!cancelled) setBusy(false)
}
})()
return () => {
cancelled = true
}
}, [refreshGraphs])
useEffect(() => {
if (!selectedGraphId) {
setEdges([])
setMetaName('')
setMetaDescription('')
setMetaVisibility('private')
return
}
const g = graphs.find((x) => x.id === selectedGraphId)
if (g) {
setMetaName(g.name || '')
setMetaDescription(g.description || '')
setMetaVisibility(g.visibility || 'private')
}
let cancelled = false
;(async () => {
try {
await refreshEdges(selectedGraphId)
} catch (e) {
if (!cancelled) alert(e.message || String(e))
}
})()
return () => {
cancelled = true
}
}, [selectedGraphId, graphs, refreshEdges])
const filteredEdges = useMemo(() => {
if (!filterAnchorOnly || anchorExerciseId == null) return edges
return edges.filter(
(e) =>
e.from_exercise_id === anchorExerciseId || e.to_exercise_id === anchorExerciseId,
)
}, [edges, filterAnchorOnly, anchorExerciseId])
const handleCreateGraph = async (e) => {
e.preventDefault()
const name = newGraphName.trim()
if (!name) {
alert('Name für den Graphen eingeben')
return
}
setBusy(true)
try {
const created = await api.createExerciseProgressionGraph({
name,
visibility: newGraphVisibility,
})
setNewGraphName('')
await refreshGraphs()
if (created?.id != null) setSelectedGraphId(created.id)
} catch (err) {
alert(err.message || String(err))
} finally {
setBusy(false)
}
}
const handleSaveMeta = async () => {
if (!selectedGraphId) return
const name = metaName.trim()
if (!name) {
alert('Name ist Pflicht')
return
}
setBusy(true)
try {
await api.updateExerciseProgressionGraph(selectedGraphId, {
name,
description: metaDescription.trim() || null,
visibility: metaVisibility,
})
await refreshGraphs()
alert('Graph gespeichert.')
} catch (err) {
alert(err.message || String(err))
} finally {
setBusy(false)
}
}
const handleDeleteGraph = async () => {
if (!selectedGraphId) return
if (!confirm('Diesen Progressionsgraphen und alle Kanten wirklich löschen?')) return
setBusy(true)
try {
await api.deleteExerciseProgressionGraph(selectedGraphId)
setSelectedGraphId(null)
await refreshGraphs()
} catch (err) {
alert(err.message || String(err))
} finally {
setBusy(false)
}
}
const handleAddEdge = async () => {
if (!selectedGraphId) {
alert('Zuerst einen Graphen wählen oder anlegen.')
return
}
if (!firstEx?.id || !secondEx?.id) {
alert('Beide Übungen auswählen.')
return
}
if (firstEx.id === secondEx.id) {
alert('Es müssen zwei verschiedene Übungen sein.')
return
}
const edge_type = relationKind === 'sibling' ? 'sibling' : 'next_exercise'
const notes = edgeNotes.trim() || null
const body =
relationKind === 'sibling'
? {
from_exercise_id: firstEx.id,
to_exercise_id: secondEx.id,
edge_type,
notes,
}
: {
from_exercise_id: firstEx.id,
to_exercise_id: secondEx.id,
edge_type: 'next_exercise',
notes,
}
setBusy(true)
try {
await api.createExerciseProgressionEdge(selectedGraphId, body)
setEdgeNotes('')
await refreshEdges(selectedGraphId)
} catch (err) {
alert(err.message || String(err))
} finally {
setBusy(false)
}
}
const handleDeleteEdge = async (edgeId) => {
if (!selectedGraphId) return
if (!confirm('Kante löschen?')) return
setBusy(true)
try {
await api.deleteExerciseProgressionEdge(selectedGraphId, edgeId)
await refreshEdges(selectedGraphId)
} catch (err) {
alert(err.message || String(err))
} finally {
setBusy(false)
}
}
const startEditNotes = (edge) => {
setEditingEdgeNotes(edge.id)
setNotesDraft(edge.notes || '')
}
const saveNotes = async (edgeId) => {
if (!selectedGraphId) return
setBusy(true)
try {
await api.updateExerciseProgressionEdge(selectedGraphId, edgeId, {
notes: notesDraft.trim() || null,
})
setEditingEdgeNotes(null)
await refreshEdges(selectedGraphId)
} catch (err) {
alert(err.message || String(err))
} finally {
setBusy(false)
}
}
const swapEnds = () => {
const a = firstEx
setFirstEx(secondEx)
setSecondEx(a)
}
const onPicked = (ex) => {
const row = { id: ex.id, title: ex.title || `Übung #${ex.id}` }
if (pickSlot === 'first') setFirstEx(row)
else if (pickSlot === 'second') setSecondEx(row)
setPickSlot(null)
}
return (
<div className="exercise-progression-panel">
{anchorExerciseId != null && (
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '12px' }}>
Kontext:{' '}
<strong>{anchorTitle?.trim() || `Übung #${anchorExerciseId}`}</strong>
{' · '}
<Link to={`/exercises/${anchorExerciseId}`}>Ansehen</Link>
</p>
)}
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '16px' }}>
Graphen bilden einen gerichteten Wald aus Übungen: <strong>Nachfolger</strong> ist zuerst A, dann B;
<strong> Schwestern</strong> markieren Alternativen oder parallele Entwicklungsschritte. Fortschritt{' '}
<em>innerhalb einer Übung</em> (Varianten, Progressionsstufen) pflegst du unter Übungsvarianten.
</p>
{loadErr && (
<div className="card" style={{ borderColor: 'var(--danger)', marginBottom: '12px' }}>
<p style={{ margin: 0, color: 'var(--danger)' }}>{loadErr}</p>
</div>
)}
<div className="card" style={{ marginBottom: '12px' }}>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Graph auswählen</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end' }}>
<div className="form-row" style={{ flex: '1 1 220px', marginBottom: 0 }}>
<label className="form-label">Aktiver Graph</label>
<select
className="form-input"
value={selectedGraphId ?? ''}
onChange={(e) => setSelectedGraphId(e.target.value ? parseInt(e.target.value, 10) : null)}
>
<option value=""> wählen </option>
{graphs.map((g) => (
<option key={g.id} value={g.id}>
{g.name} ({g.edges_count ?? 0} Kanten)
</option>
))}
</select>
</div>
<button
type="button"
className="btn"
disabled={busy || !selectedGraphId}
onClick={() => refreshEdges(selectedGraphId)}
>
Kanten neu laden
</button>
<button type="button" className="btn" disabled={busy || !selectedGraphId} onClick={handleDeleteGraph}>
Graph löschen
</button>
</div>
<form onSubmit={handleCreateGraph} style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px solid var(--border)' }}>
<h4 style={{ margin: '0 0 8px', fontSize: '0.95rem' }}>Neuen Graphen anlegen</h4>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end' }}>
<div className="form-row" style={{ flex: '2 1 200px', marginBottom: 0 }}>
<label className="form-label">Name</label>
<input
className="form-input"
value={newGraphName}
onChange={(e) => setNewGraphName(e.target.value)}
placeholder="z. B. Kumite-Einstieg Verein Nord"
/>
</div>
<div className="form-row" style={{ flex: '1 1 140px', marginBottom: 0 }}>
<label className="form-label">Sichtbarkeit</label>
<select
className="form-input"
value={newGraphVisibility}
onChange={(e) => setNewGraphVisibility(e.target.value)}
>
{VIS_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<button type="submit" className="btn btn-primary" disabled={busy}>
Graph erstellen
</button>
</div>
</form>
</div>
{selectedGraphId && (
<div className="card" style={{ marginBottom: '12px' }}>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Graph bearbeiten</h3>
<div className="form-row">
<label className="form-label">Name</label>
<input className="form-input" value={metaName} onChange={(e) => setMetaName(e.target.value)} />
</div>
<div className="form-row">
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
rows={2}
value={metaDescription}
onChange={(e) => setMetaDescription(e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Sichtbarkeit</label>
<select className="form-input" value={metaVisibility} onChange={(e) => setMetaVisibility(e.target.value)}>
{VIS_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={handleSaveMeta}>
Metadaten speichern
</button>
</div>
)}
{selectedGraphId && (
<div className="card" style={{ marginBottom: '12px' }}>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Kante hinzufügen</h3>
<div className="form-row">
<label className="form-label">Beziehung</label>
<select
className="form-input"
value={relationKind}
onChange={(e) => setRelationKind(e.target.value)}
>
<option value="progression">Nachfolger (zuerst Übung A, danach Übung B)</option>
<option value="sibling">Schwester (gleiche Entwicklungslage / Alternative)</option>
</select>
</div>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0 }}>
{relationKind === 'progression'
? '„Vorgänger von B ist A“ entspricht dieser Kante: A kommt vor B. Bei Bedarf „Reihenfolge tauschen“.'
: 'Eine gerichtete Schwester-Kante reicht; semantisch gilt die Paarbeziehung als bidirektional.'}
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '12px' }}>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">
{relationKind === 'progression' ? 'Übung A (kommt zuerst)' : 'Übung A'}
</label>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ flex: '1 1 160px', fontSize: '13px' }}>
{firstEx ? (
<>
<strong>{firstEx.title}</strong>
<span style={{ color: 'var(--text3)' }}> (#{firstEx.id})</span>
</>
) : (
<span style={{ color: 'var(--text3)' }}> nicht gewählt </span>
)}
</span>
<button type="button" className="btn btn-secondary" onClick={() => setPickSlot('first')}>
Übung wählen
</button>
{anchorExerciseId != null && (
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '4px 10px' }}
onClick={() =>
setFirstEx({
id: anchorExerciseId,
title: anchorTitle?.trim() || `Übung #${anchorExerciseId}`,
})
}
>
Diese Übung
</button>
)}
</div>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">
{relationKind === 'progression' ? 'Übung B (kommt danach)' : 'Übung B'}
</label>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ flex: '1 1 160px', fontSize: '13px' }}>
{secondEx ? (
<>
<strong>{secondEx.title}</strong>
<span style={{ color: 'var(--text3)' }}> (#{secondEx.id})</span>
</>
) : (
<span style={{ color: 'var(--text3)' }}> nicht gewählt </span>
)}
</span>
<button type="button" className="btn btn-secondary" onClick={() => setPickSlot('second')}>
Übung wählen
</button>
{anchorExerciseId != null && (
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '4px 10px' }}
onClick={() =>
setSecondEx({
id: anchorExerciseId,
title: anchorTitle?.trim() || `Übung #${anchorExerciseId}`,
})
}
>
Diese Übung
</button>
)}
</div>
</div>
</div>
{relationKind === 'progression' && (
<button type="button" className="btn" style={{ marginTop: '8px' }} onClick={swapEnds}>
Reihenfolge tauschen (A B)
</button>
)}
<div className="form-row">
<label className="form-label">Entwicklungsziel / Notiz (optional)</label>
<textarea
className="form-input"
rows={2}
value={edgeNotes}
onChange={(e) => setEdgeNotes(e.target.value)}
placeholder="z. B. Fokus auf sicheren Abstand, dann dynamischer Eintritt …"
/>
</div>
<button type="button" className="btn btn-primary" disabled={busy} onClick={handleAddEdge}>
Kante speichern
</button>
</div>
)}
{selectedGraphId && anchorExerciseId != null && (
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px', fontSize: '13px' }}>
<input
type="checkbox"
checked={filterAnchorOnly}
onChange={(e) => setFilterAnchorOnly(e.target.checked)}
/>
Nur Kanten anzeigen, die diese Übung betreffen
</label>
)}
{selectedGraphId && (
<div className="card">
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>
Kanten ({filteredEdges.length}
{edges.length !== filteredEdges.length ? ` von ${edges.length}` : ''})
</h3>
{filteredEdges.length === 0 ? (
<p style={{ color: 'var(--text2)', margin: 0 }}>Noch keine Kanten in diesem Graph.</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)' }}>
<th style={{ padding: '8px 6px' }}>Von</th>
<th style={{ padding: '8px 6px' }} />
<th style={{ padding: '8px 6px' }}>Nach</th>
<th style={{ padding: '8px 6px' }}>Art</th>
<th style={{ padding: '8px 6px' }}>Entwicklungsziel</th>
<th style={{ padding: '8px 6px' }} />
</tr>
</thead>
<tbody>
{filteredEdges.map((row) => (
<tr key={row.id} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
<Link to={`/exercises/${row.from_exercise_id}`}>{row.from_exercise_title || `#${row.from_exercise_id}`}</Link>
</td>
<td style={{ padding: '8px 6px', color: 'var(--text3)', verticalAlign: 'top' }}>
{row.edge_type === 'sibling' ? '·' : '→'}
</td>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
<Link to={`/exercises/${row.to_exercise_id}`}>{row.to_exercise_title || `#${row.to_exercise_id}`}</Link>
</td>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>{edgeTypeLabel(row.edge_type)}</td>
<td style={{ padding: '8px 6px', verticalAlign: 'top', maxWidth: '280px' }}>
{editingEdgeNotes === row.id ? (
<>
<textarea
className="form-input"
rows={2}
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
style={{ marginBottom: '6px' }}
/>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => saveNotes(row.id)}>
Notiz speichern
</button>
<button type="button" className="btn" disabled={busy} onClick={() => setEditingEdgeNotes(null)}>
Abbrechen
</button>
</>
) : (
<>
<span style={{ whiteSpace: 'pre-wrap' }}>{row.notes || '—'}</span>
<button
type="button"
className="btn"
style={{ marginLeft: '8px', fontSize: '12px', padding: '4px 8px' }}
onClick={() => startEditNotes(row)}
>
Bearbeiten
</button>
</>
)}
</td>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '4px 8px', background: 'var(--danger)', color: '#fff', border: 'none' }}
onClick={() => handleDeleteEdge(row.id)}
>
Löschen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
<ExercisePickerModal open={pickSlot != null} onClose={() => setPickSlot(null)} onSelectExercise={onPicked} />
</div>
)
}

View File

@ -2,6 +2,7 @@ import React, { useEffect, useState, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import api, { buildExerciseApiPayload } from '../utils/api'
import RichTextEditor from '../components/RichTextEditor'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
const INTENSITY_OPTIONS = [
@ -1151,6 +1152,18 @@ function ExerciseFormPage() {
</details>
)}
{isEdit && (
<details className="card exercise-variants-details" style={{ marginTop: '16px' }}>
<summary className="exercise-variants-summary">
<span className="exercise-variants-summary__title">Progressionsgraph</span>
<span className="exercise-variants-summary__badge">Übung Übung</span>
</summary>
<div className="exercise-variants-details__body">
<ExerciseProgressionGraphPanel anchorExerciseId={exerciseId} anchorTitle={formData.title} />
</div>
</details>
)}
{isEdit && (
<div className="card" style={{ marginTop: '16px' }}>
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Medien</h2>

View File

@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
import api from '../utils/api'
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import MultiSelectCombo from '../components/MultiSelectCombo'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
const PAGE_SIZE = 100
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
@ -44,6 +45,7 @@ function ExercisesListPage() {
const [debouncedAiSearch, setDebouncedAiSearch] = useState('')
const [filters, setFilters] = useState(() => ({ ...INITIAL_FILTERS }))
const [filterModalOpen, setFilterModalOpen] = useState(false)
const [pageTab, setPageTab] = useState('list')
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
@ -287,7 +289,7 @@ function ExercisesListPage() {
}, [])
useEffect(() => {
if (!catalogsReady) return
if (!catalogsReady || pageTab !== 'list') return
let cancelled = false
const run = async () => {
setListFetching(true)
@ -311,7 +313,7 @@ function ExercisesListPage() {
return () => {
cancelled = true
}
}, [queryBase, catalogsReady])
}, [queryBase, catalogsReady, pageTab])
const loadMore = async () => {
if (loadingMore || !hasMore) return
@ -340,7 +342,7 @@ function ExercisesListPage() {
const resetAllFilters = useCallback(() => setFilters({ ...INITIAL_FILTERS }), [])
if (!catalogsReady) {
if (!catalogsReady && pageTab === 'list') {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="spinner"></div>
@ -362,11 +364,44 @@ function ExercisesListPage() {
}}
>
<h1 style={{ fontSize: '1.35rem' }}>Übungen</h1>
<Link to="/exercises/new" className="btn btn-primary">
+ Neu
</Link>
{pageTab === 'list' ? (
<Link to="/exercises/new" className="btn btn-primary">
+ Neu
</Link>
) : (
<span />
)}
</div>
<div
role="tablist"
aria-label="Übungen Bereiche"
style={{ display: 'flex', gap: '8px', marginBottom: '14px', flexWrap: 'wrap' }}
>
<button
type="button"
role="tab"
aria-selected={pageTab === 'list'}
className={pageTab === 'list' ? 'btn btn-primary' : 'btn btn-secondary'}
onClick={() => setPageTab('list')}
>
Liste
</button>
<button
type="button"
role="tab"
aria-selected={pageTab === 'progression'}
className={pageTab === 'progression' ? 'btn btn-primary' : 'btn btn-secondary'}
onClick={() => setPageTab('progression')}
>
Progressionsgraphen
</button>
</div>
{pageTab === 'progression' ? (
<ExerciseProgressionGraphPanel />
) : (
<>
<div className="card exercise-search-bar" style={{ marginBottom: '12px' }}>
<label className="form-label">Volltextsuche (Titel, Ziel, )</label>
<datalist id="exercise-search-titles">
@ -687,6 +722,8 @@ function ExercisesListPage() {
)}
</>
)}
</>
)}
</div>
)
}

View File

@ -443,6 +443,60 @@ export async function reorderExerciseVariants(exerciseId, variantIds) {
})
}
// Progressionsgraphen (Übung → Übung), Migration 032/033
export async function listExerciseProgressionGraphs() {
return request('/api/exercise-progression-graphs')
}
export async function getExerciseProgressionGraph(id, { includeEdges = false } = {}) {
const q = includeEdges ? '?include_edges=true' : ''
return request(`/api/exercise-progression-graphs/${id}${q}`)
}
export async function createExerciseProgressionGraph(data) {
return request('/api/exercise-progression-graphs', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateExerciseProgressionGraph(id, data) {
return request(`/api/exercise-progression-graphs/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteExerciseProgressionGraph(id) {
return request(`/api/exercise-progression-graphs/${id}`, { method: 'DELETE' })
}
export async function listExerciseProgressionEdges(graphId, query = {}) {
const q = new URLSearchParams()
if (query.from_exercise_id != null) q.set('from_exercise_id', String(query.from_exercise_id))
if (query.to_exercise_id != null) q.set('to_exercise_id', String(query.to_exercise_id))
const qs = q.toString()
return request(`/api/exercise-progression-graphs/${graphId}/edges${qs ? `?${qs}` : ''}`)
}
export async function createExerciseProgressionEdge(graphId, data) {
return request(`/api/exercise-progression-graphs/${graphId}/edges`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateExerciseProgressionEdge(graphId, edgeId, data) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/${edgeId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteExerciseProgressionEdge(graphId, edgeId) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/${edgeId}`, { method: 'DELETE' })
}
/** KI-Ausbaustufe (EXERCISES_API_SPEC): benötigt Backend + z. B. OPENROUTER_API_KEY. */
export async function suggestExerciseAi(payload) {
return request('/api/exercises/ai/suggest', {
@ -951,6 +1005,15 @@ export const api = {
updateExerciseMedia,
deleteExerciseMedia,
reorderExerciseMedia,
listExerciseProgressionGraphs,
getExerciseProgressionGraph,
createExerciseProgressionGraph,
updateExerciseProgressionGraph,
deleteExerciseProgressionGraph,
listExerciseProgressionEdges,
createExerciseProgressionEdge,
updateExerciseProgressionEdge,
deleteExerciseProgressionEdge,
// Training Planning
listTrainingUnits,