feat: add exercise progression graph functionality and update versioning
- 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:
parent
362eb4145f
commit
1b7a0405e9
|
|
@ -0,0 +1,221 @@
|
|||
# Konzept: Trainingsplanung über Einheiten hinweg, Kurspläne, Governance, Assessments
|
||||
|
||||
**Status:** Arbeitspapier (lebend)
|
||||
**Stand:** 2026-04-29 (Schritt C Kernentscheide CURR‑009–013)
|
||||
**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 CURR‑010, CURR‑011, CURR‑013.
|
||||
- **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-001–002)
|
||||
|
||||
| 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 (CURR‑002 (2) i. V. m. **CURR‑010–013**) | nach Stufe 1 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-001–004 |
|
||||
| **B** | **Governance-Muster** (einheitliche Sichtbarkeit; Bibliothek vs. Instanz) | ✅ Leitplan §2.c; Entscheidungen §5 CURR-005–007 |
|
||||
| **C** | **Rahmenprogramm** — §2.d (**C1–C4** ✅ · **C5** Leitplan) | ✅ Kern i. V. m. **CURR‑009–013** |
|
||||
| **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 (Modus‑Flags Felder zweier Nutzungsbilder, Datenmodell **mehrere Ziele**, Slot‑Übung‑Zuordnung; Progressionsgraph Stufe 1 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 2026‑04‑29
|
||||
|
||||
| Check | Ergebnis | Verweis |
|
||||
|-------|----------|---------|
|
||||
| **C1** | ✅ **Eigene Rahmen-Entität** (C1a) — nicht die Einheitenvorlage überladen | **CURR‑009** |
|
||||
| **C2** | ✅ Slots erlauben **beliebige Übungen** über Trainings-/Slots zu verteilen; **persistenter Progressionsgraph** ist **unterstützend**, **Pflicht‑Pflege** im Graph gilt **nicht** (Admin‑Aufwand; v. a. global) | **CURR‑010**, **CURR‑013** |
|
||||
| **C3** | ✅ Über denselben Planungszeitraum **mehrere** gleichzeitige **Entwicklungsziele** (nicht nur ein Sammelfeld) | **CURR‑011** |
|
||||
| **C4** | ✅ **Zwei Nutzungsbilder**, kein „entweder C4a oder C4b global“ — siehe Ausführungen unten zu **Konkret** vs. **Bibliothek** | **CURR‑012** |
|
||||
| **C5** | ✅ `training_plan_template` bleibt **eine‑Einheit‑Mikrovorlage**; Rahmen adressiert **n** Sessions; pro Slot weiterhin möglich: **optional** `training_plan_template_id` (Technical Spec entscheidet MVP‑Pflicht) | Glossar |
|
||||
|
||||
---
|
||||
|
||||
#### C4 verständlich: **„Materialisierung“** = zwei echte Situationen
|
||||
|
||||
| Nutzungsbild | Kontext | Gruppe / Datum | Typische Persistenz‑Schicht |
|
||||
|--------------|---------|----------------|----------------------------|
|
||||
| **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-/Lehrplan‑Bibliothek** | Übergeordnete **Struktur** ohne laufenden Kurs | **In der Bibliotheksvorlage selbst oft:** keine Gruppe/Zeit | Rahmen-/Kurs‑Vorlage **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 **Modus A** passen **automatisches Anlegen n Einheiten** (früheres C4a) **oder** Zuordnung zu **bereits geplanten** Einheiten (C4b) — je nach Produkt/UI. Bei **Modus B** 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 Mikro‑Vorlage)?“ — **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 Rahmen‑Entität bestätigt (Chat 2026‑04‑29).
|
||||
|
||||
---
|
||||
|
||||
*Konkretisierung technischer Felder (ein Objekt zwei Modi vs. zwei Typen) → **Technical Spec**; keine neuen Contradicts zu CURR‑001 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** (**CURR‑010**, **CURR‑011**, **CURR‑013**) |
|
||||
| **Progressionsbaum / -graph** | Optionale gerichtete Beziehungen **zwischen Übungen** zur **Unterstützung** beim Planen (**CURR‑010** — **kein Pflicht‑Pflegeschritt 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 (**CURR‑012**); eigene Entität (**CURR‑009**) |
|
||||
| **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 — **CURR‑012 Modus B**. Modus A bleibt nahe bestehender Einheiten-Planung |
|
||||
| **Konkret-Planung (Modus A)** | Mehr‑Wochen 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 **CURR‑002 (1)+(2)**: **Persistenter Progressionsgraph** ist **unterstützend**, **nicht** die alleinige Quelle für Slot‑geplante Übungen. Der Planungsmodus **muss beliebige Übungen** auf Slots verteilen **ohne** Pflicht zur Graph‑Pflege 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:** **Modus A (Konkret)** — Mehr‑Wochenplan für **bekannte Trainingsgruppe + Termine** → bestehende/neue **`training_units`**; Rahmen als Planhilfe über mehrere Einheiten. **Modus B (Bibliothek)** — **Kurs-/Stufen‑Struktur ohne** Gruppe/Uhrzeit bis zur **Übernahme**; dann Zuordnung von Gruppe+Zeitraum und **Instanziierung** in **`training_units`**. Bulk‑Anlegen (**C4a**) und Verknüpfen existierender (**C4b**) sind **Modus‑A‑Alternativen**. | Chat C4 |
|
||||
| **CURR-011** | 2026-04-29 | **Mehrere parallele Entwicklungsziele** im selben Planungszeitraum → Datenmodell: **Zielliste mit ≥1 Einträgen** auf Rahmen‑Ebene (Details Technical Spec). **Nicht** nur ein einziges Sammelziel‑Feld. | Chat C3 |
|
||||
| **CURR-010** | 2026-04-29 | **Slot‑Inhalt:** 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 Bibliotheks‑Entität** für Mehr‑Slot‑Rahmen (**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**. Vereins‑Zuordnung für Bestands-/Default‑Zeilen erfolgt beim Backfill mit dem **Standard‑Verein der Installation** (konkret: Club‑ID bzw. Konvention im Migrate‑Skript 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 Multi‑Tenant eigene Migrate‑Anweisung. | Nutzerfestlegung; pragmatisches Backfill ohne Mehr‑Mandanten‑Heuristik; §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 **CURR‑008**. |
|
||||
| **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**, **CURR‑010**) **auf mehrere Trainingseinheiten verteilt**, **mehrere Ziele** (**CURR‑011**) enthält und als **speicherbares Rahmen‑Template** dient. **(3)** **Warenkorb**-Idee beim Ausarbeiten einer **einzelnen** Einheit. | Reihenfolge vom Datenkern zur UX; Zuordnung/Graph **CURR‑013** |
|
||||
| **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~~ → **CURR‑008**
|
||||
- ~~Slots / C4 generisch~~ → **CURR‑012** (Modi A/B)
|
||||
- ~~Relation zwei Vorlagenfamilien~~ → **CURR‑009** (**Rahmen** neu, **Einheit** bleibt `training_plan_template`)
|
||||
- **Technical:** gleiche DB‑Entitä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 · **CURR‑009 bis CURR‑013**
|
||||
2. **Technical Spec:** `technical/TRAINING_FRAMEWORK_SPEC.md` — Datenmodell **Rahmen‑Entität** + **Zielliste** + Slot‑Zuordnung; Modus A/B; Graph Stufe 1 (**CURR‑002** (1))
|
||||
3. **Migrate** weiter **CURR‑007 / CURR‑008**
|
||||
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 | **CURR‑009–013**, **CURR‑002** präzisiert; Glossar Modi A/B Slot; §2.d C geklärt; §6‑Backlog gekürzt. |
|
||||
| 2026-04-29 | CURR‑008 (Migration Standard‑Verein); **§2.d Schritt C** Checkpoints C1–C5; Glossar/§6 angepasst. |
|
||||
| 2026-04-29 | CURR-001–004; Umsetzungsreihenfolge §2.a; Glossar Rahmenprogramm/Progressionsgraph; Scope-Backlog. |
|
||||
| 2026-04-28 | Erstanlage aus Konzept-Arbeitsphase Chat; Schritttabelle und Protokollstruktur. |
|
||||
86
.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md
Normal file
86
.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md
Normal 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` (CURR‑001 bis CURR‑013)
|
||||
|
||||
---
|
||||
|
||||
## 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, CURR‑Tabelle). Keine DDL-Pflicht. |
|
||||
|
||||
**Konsequenz:** Diese Datei ist der **technische Arbeitspool** für Rahmenprogramm-Stufe 1–2 (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 **CURR‑009**); 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 (**CURR‑011**); Felder und Optionalität.
|
||||
- [ ] **Slots:** Reihenfolge, Notizen, **direkte Übungszuordnungen** (M:N oder Join-Tabelle); optionales `training_plan_template_id` pro Slot (**CURR‑010**, MVP offen).
|
||||
- [x] **Progressionsgraph zwischen Übungen:** Tabellen/Kanten, getrennt von Varianten-Progression in `exercise_variants` (**CURR‑002 (1)**, **CURR‑013**) — siehe **§3**.
|
||||
- [ ] **Instanziierung (Modus B):** FK/Metadaten zu `training_units`, Bulk vs. Verknüpfen (**CURR‑012**).
|
||||
- [ ] **Governance:** `visibility`, `club_id`, `created_by` für neue Bibliothekstypen (**CURR‑005**); Nachzug `training_plan_templates` (**CURR‑007**, **CURR‑008**).
|
||||
- [ ] **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).
|
||||
- **CURR‑013:** Graph bleibt unterstützend; keine Pflicht, jeden Trainingsplan über den Graph zu modellieren.
|
||||
- Anbindung **`training_units`** / Rahmen-Slots (**Stufe 2**, CURR‑009–012).
|
||||
|
||||
### 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 (CURR‑002 (1)); Checkliste Graph erledigt. |
|
||||
| 2026-04-28 | Erstanlage: Abgrenzung + Checkliste (Artefakt der Doku-Entscheidung „eigene technische Spec“). |
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
38
backend/migrations/032_exercise_progression_graph.sql
Normal file
38
backend/migrations/032_exercise_progression_graph.sql
Normal 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);
|
||||
|
|
@ -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;
|
||||
346
backend/routers/exercise_progression_graphs.py
Normal file
346
backend/routers/exercise_progression_graphs.py
Normal 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}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
599
frontend/src/components/ExerciseProgressionGraphPanel.jsx
Normal file
599
frontend/src/components/ExerciseProgressionGraphPanel.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user