From 1b7a0405e94a0c446655b12ab033ed41c57bc99f Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 30 Apr 2026 11:47:50 +0200 Subject: [PATCH 01/29] 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. --- ...INING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md | 221 +++++++ .../docs/technical/TRAINING_FRAMEWORK_SPEC.md | 86 +++ backend/main.py | 3 +- .../032_exercise_progression_graph.sql | 38 ++ .../033_exercise_progression_edge_notes.sql | 4 + .../routers/exercise_progression_graphs.py | 346 ++++++++++ backend/version.py | 25 +- .../ExerciseProgressionGraphPanel.jsx | 599 ++++++++++++++++++ frontend/src/pages/ExerciseFormPage.jsx | 13 + frontend/src/pages/ExercisesListPage.jsx | 49 +- frontend/src/utils/api.js | 63 ++ 11 files changed, 1436 insertions(+), 11 deletions(-) create mode 100644 .claude/docs/functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md create mode 100644 .claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md create mode 100644 backend/migrations/032_exercise_progression_graph.sql create mode 100644 backend/migrations/033_exercise_progression_edge_notes.sql create mode 100644 backend/routers/exercise_progression_graphs.py create mode 100644 frontend/src/components/ExerciseProgressionGraphPanel.jsx diff --git a/.claude/docs/functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md b/.claude/docs/functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md new file mode 100644 index 0000000..e9cafc7 --- /dev/null +++ b/.claude/docs/functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md @@ -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. | diff --git a/.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md b/.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md new file mode 100644 index 0000000..37ce7f5 --- /dev/null +++ b/.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md @@ -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“). | diff --git a/backend/main.py b/backend/main.py index 9ae6852..52fd6a2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/migrations/032_exercise_progression_graph.sql b/backend/migrations/032_exercise_progression_graph.sql new file mode 100644 index 0000000..a5100e8 --- /dev/null +++ b/backend/migrations/032_exercise_progression_graph.sql @@ -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); diff --git a/backend/migrations/033_exercise_progression_edge_notes.sql b/backend/migrations/033_exercise_progression_edge_notes.sql new file mode 100644 index 0000000..8e929ae --- /dev/null +++ b/backend/migrations/033_exercise_progression_edge_notes.sql @@ -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; diff --git a/backend/routers/exercise_progression_graphs.py b/backend/routers/exercise_progression_graphs.py new file mode 100644 index 0000000..cc27ac1 --- /dev/null +++ b/backend/routers/exercise_progression_graphs.py @@ -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} diff --git a/backend/version.py b/backend/version.py index 750b412..fade40a 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx new file mode 100644 index 0000000..296fd53 --- /dev/null +++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx @@ -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 ( +
+ {anchorExerciseId != null && ( +

+ Kontext:{' '} + {anchorTitle?.trim() || `Übung #${anchorExerciseId}`} + {' · '} + Ansehen +

+ )} + +

+ Graphen bilden einen gerichteten Wald aus Übungen: Nachfolger ist „zuerst A, dann B“; + Schwestern markieren Alternativen oder parallele Entwicklungsschritte. Fortschritt{' '} + innerhalb einer Übung (Varianten, Progressionsstufen) pflegst du unter „Übungsvarianten“. +

+ + {loadErr && ( +
+

{loadErr}

+
+ )} + +
+

Graph auswählen

+
+
+ + +
+ + +
+ +
+

Neuen Graphen anlegen

+
+
+ + setNewGraphName(e.target.value)} + placeholder="z. B. Kumite-Einstieg Verein Nord" + /> +
+
+ + +
+ +
+
+
+ + {selectedGraphId && ( +
+

Graph bearbeiten

+
+ + setMetaName(e.target.value)} /> +
+
+ +