diff --git a/.claude/docs/PROJECT_STATUS.md b/.claude/docs/PROJECT_STATUS.md index 14091a3..1fcaccc 100644 --- a/.claude/docs/PROJECT_STATUS.md +++ b/.claude/docs/PROJECT_STATUS.md @@ -1,31 +1,30 @@ # Shinkan Jinkendo - Projekt-Status -**Stand:** 2026-04-27 -**Version (Code):** 0.7.9 (`backend/version.py`, APP_VERSION) -**DB-Schema-Version:** `20260427030` +**Stand:** 2026-05-05 +**Version (Code):** 0.8.10 (`backend/version.py`, APP_VERSION) +**DB-Schema-Version:** `20260505037` **Branch:** develop --- ## Executive Summary -**Aktueller Meilenstein:** Übungsvarianten Ende-zu-Ende (API, DB 030, Planung, UI) sowie Listen-Suche ohne Full-Page-Reload ✅ +**Aktueller Meilenstein:** **Trainingsrahmenprogramm Bibliothek + Slot‑Blueprint** (DB **036–037**): Rahmenkopf nur als Vorlage mit Kontext‑Stammdaten; pro Slot genau eine **Blueprint‑`training_unit`** mit **`framework_unit_sections`/`_items`** wie die Planung; Kalenderliste blendet Blueprints aus; **`POST /api/training-units/from-framework-slot`** materialisiert Kopien mit **`origin_framework_slot_id`**. Parallel: **Progressionsgraph** (032–034) bleibt unterstützend (**`TRAINING_FRAMEWORK_SPEC.md`** §3–§4). -**Letzte dokumentierte Änderungen (April 2026):** +**Letzte dokumentierte Änderungen (Mai 2026):** -- ✅ Migration **030**: `training_unit_exercises.exercise_variant_id` (FK zu `exercise_variants`, ON DELETE SET NULL). -- ✅ **GET `/api/exercises?include_variants=true`** für Trainingsplanung und Übersichten. -- ✅ Varianten-**CRUD** + **Reorder**; Validierung in der Trainingsplanung (Variante gehört zur Übung). -- ✅ **Übungsliste**: Filter-Chips, Modal, `listFetching` statt Full-Page-Spinner, ``-Titel aus Treffern. -- ✅ **Medien-Upload**: rollenbasierte Limits (Standard 50 MB, Admin bis 1024 MB, Env-Vars). -- ✅ **RichTextEditor**: Selection-Restore, Listen-Styling im Editor. +- ✅ Migration **036:** Rahmen nur Bibliothek; Fokus/Stil + M:N Trainingsarten/Zielgruppen; Entfall `plan_mode`/`group_id` am Kopf. +- ✅ Migration **037:** `training_units.framework_slot_id` / `origin_framework_slot_id`; Migration Entfall **`training_framework_slot_exercises`**. +- ✅ APIs: erweiterte Rahmen‑Hydration (`sections`, `exercises`, `blueprint_training_unit_id`); Planung siehe **`TRAINING_FRAMEWORK_SPEC.md`** §2.4. +- ✅ Frontend: `createTrainingUnitFromFrameworkSlot` in `api.js`. -**Referenz:** Ausführliche technische Liste → [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) +**Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) · Rahmen/Graph: [`technical/TRAINING_FRAMEWORK_SPEC.md`](technical/TRAINING_FRAMEWORK_SPEC.md) **Nächste Schritte (Auszug):** -1. Prod-Deployment der Migrationen **020–030** und Smoke-Tests. -2. Optional: Server-Autocomplete für Suche; Progressions-Serien als Blöcke (siehe Feature-Doc). +1. Kalender‑UI: „Aus Rahmen übernehmen“ an **`from-framework-slot`** anbinden; ggf. Bulk. +2. Governance: Sichtbarkeit **club/official** für Rahmen so ausprägen, dass andere Trainer kopieren dürfen (Policy + API). +3. Optional Backlog Graph: Alternativgruppen / bessere Visualisierung (**§4**). --- @@ -42,6 +41,8 @@ | 023 | Skills Complete Import (69 Skills) | ✅ | 🔲 | | 028–029 | exercise_media / skills Stufen | ✅ | 🔲 | | **030** | **training_unit_exercises.exercise_variant_id** | ✅ | 🔲 | +| **032–034** | **Progressionsgraph Übung→Übung** | ✅ | 🔲 | +| **035–037** | **Rahmenprogramm, Bibliothek‑Kopf, Slot‑Blueprint‑Units** | ✅ | 🔲 | --- @@ -66,6 +67,7 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu - [x] CRUD (Create, Read, Update, Delete) - [x] M:N Beziehungen (Focus Areas, Styles, Target Groups, Skills) - [x] **Varianten** (CRUD, Reorder, Voraussetzung) + Anzeige im Detail +- [x] **Progressionsgraph zwischen Übungen** (Bibliotheks-Container, Kanten, Sequenz-Bulk, Varianten-Knoten — Zwischenstand, siehe TRAINING_FRAMEWORK_SPEC §4) - [x] Medien (Upload/Embed, rollenabhängige Größenlimits) - [x] Suche & Filter (Multi-Filter, Chips, Fokus beim Suchen) - [x] Exercise Blocks (Bausteine) @@ -73,8 +75,10 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu **Trainingsplanung:** -- [x] Training Units / Einbinden von Übungen +- [x] Training Units / strukturierter Ablauf (Sektionen + Items) - [x] **Optionale Zuordnung einer Übungsvariante** pro Eintrag (`exercise_variant_id`) +- [x] **Trainingsrahmenprogramm Bibliothek** (Ziele, Slots, Kontext) + **Slot‑Blueprints** in `training_units` (036–037) +- [x] **Materialisierung** aus Rahmen‑Slot (`POST …/training-units/from-framework-slot`; UI‑Anbindung optional) - [ ] Kalender-View / erweiterte Roadmap (Backlog) **MediaWiki Import:** @@ -121,7 +125,7 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu ### Dev -Branch `develop`; Migrations bis mindestens **030** auf dem aktuellen Entwicklungsstand; Details in `backend/version.py`. +Branch `develop`; Migrations bis mindestens **037** auf dem aktuellen Entwicklungsstand; Details in `backend/version.py`. ### Prod @@ -133,15 +137,16 @@ Deployment der oben genannten Migrationen und Datenabgleich nach internem Prozes | Dokument | Pfad | Stand | Status | |----------|------|-------|--------| -| Lieferliste Q2 2026 | `library/FEATURES_DELIVERED_2026-Q2.md` | 2026-04-27 | ✅ Neu | +| Lieferliste Q2 2026 | `library/FEATURES_DELIVERED_2026-Q2.md` | 2026-05-05 | ✅ Aktualisiert (u. a. 036–037) | +| Trainingsrahmen + Graph | `technical/TRAINING_FRAMEWORK_SPEC.md` | 2026-05-05 | ✅ §2 Blueprint | | Anforderungen (Index) | `functional/SHINKAN_REQUIREMENTS.md` | 2026-04-27 | ✅ Neu | -| Database Schema | `technical/DATABASE_SCHEMA.md` | 2026-04-27 | ✅ Aktualisiert (030) | -| Domain Model | `functional/DOMAIN_MODEL.md` | 2026-04-27 | ✅ Referenz | -| API Übungen | `technical/EXERCISES_API_SPEC.md` | 2026-04-27 | ✅ Aktualisiert (v1.3) | -| Frontend Routing | `technical/EXERCISES_FRONTEND_ROUTING.md` | 2026-04-27 | ✅ Aktualisiert | +| Database Schema | `technical/DATABASE_SCHEMA.md` | 2026-05-05 | ✅ Aktualisiert (037) | +| Domain Model | `functional/DOMAIN_MODEL.md` | 2026-05-05 | ✅ Aktualisiert | +| API Übungen | `technical/EXERCISES_API_SPEC.md` | 2026-04-30 | ✅ Ergänzt Progressions-API | +| Frontend Routing | `technical/EXERCISES_FRONTEND_ROUTING.md` | 2026-04-30 | ✅ Ergänzt UI-Hinweise | | Search & Filter | `technical/SEARCH_FILTER_SPEC.md` | 2026-04-27 | ✅ Aktualisiert (Liste UX) | | Media Upload | `technical/MEDIA_UPLOAD_SPEC.md` | 2026-04-27 | ✅ Aktualisiert (Limits) | -| Projektstatus | `PROJECT_STATUS.md` | 2026-04-27 | ✅ Diese Datei | +| Projektstatus | `PROJECT_STATUS.md` | 2026-05-05 | ✅ Diese Datei | --- @@ -152,4 +157,4 @@ Deployment der oben genannten Migrationen und Datenabgleich nach internem Prozes --- -**Letzte Aktualisierung:** 2026-04-27 +**Letzte Aktualisierung:** 2026-05-05 diff --git a/.claude/docs/functional/DOMAIN_MODEL.md b/.claude/docs/functional/DOMAIN_MODEL.md index 898c5c6..fa96c38 100644 --- a/.claude/docs/functional/DOMAIN_MODEL.md +++ b/.claude/docs/functional/DOMAIN_MODEL.md @@ -1,7 +1,7 @@ # Shinkan Jinkendo - Fachliches Domänenmodell -**Version:** 0.4.0 -**Stand:** 2026-04-27 (Migration 023: Skills Complete Import) +**Version:** 0.4.3 +**Stand:** 2026-05-05 (Migration **036–037:** Rahmen nur Bibliothek; Slot‑Inhalt über Blueprint‑`training_units` + Sektionen/Items wie Planung — siehe `TRAINING_FRAMEWORK_SPEC.md` §2) **Basis:** `shinkan_anforderungsdokument_entwurf.md` + Fähigkeitsmatrix --- @@ -458,6 +458,22 @@ skill_level_definitions ( **Umsetzung (Trainingsplanung):** Ein Eintrag in `training_unit_exercises` kann optional eine konkrete Varianten-ID (`exercise_variant_id`, Migration 030) tragen; Bindung wird gegen die gewählte Übung validiert. Varianten werden über die Übungs-API verwaltet (`technical/EXERCISES_API_SPEC.md`). +### Progressionsgraph zwischen Übungen (Zwischenstand, CURR‑002 Stufe 1) + +**Abgrenzung:** Zusätzlich zur Varianten-Reihe **innerhalb** einer Übung gibt es optional einen **Bibliotheks-Progressionsgraphen**: gerichtete Kanten zwischen **Übungen** (Knoten optional auf konkrete **Varianten** eingegrenzt). Gemeinsamer Kontainer pro Graph (`exercise_progression_graphs`); Kanten mit Typ z. B. Nachfolger oder Schwester. + +**Rolle:** **unterstützend** für Planung und spätere Rahmenprogramme — keine Pflicht, jeden Trainingsablauf als Graph zu modellieren (**CURR‑013**). + +**Fachliche Grenze aktuell:** Mehrere gleichwertige „Pakete“ paralleler Alternativen sind **modellierbar** (mehrere ausgehende Kanten), aber noch **nicht** über eine dedizierte „Alternativgruppe“ in der UI trivial pflegbar; siehe `technical/TRAINING_FRAMEWORK_SPEC.md` §4. + +### Trainingsrahmen‑Vorlage (Rahmenprogramm, CURR‑002 Stufe 2 / CURR‑009) + +**Abgrenzung:** Eine **einzeilige** Trainingsplan‑Mikrovorlage (`training_plan_template`) strukturiert **eine** Einheit; das **Rahmenprogramm** ist eine **eigene Bibliotheksentität** mit **sortierten Session‑Slots**, **mindestens einem** formulierten **Entwicklungsziel** (Zielliste, **CURR‑011**) und einem **vollständigen Ablauf** pro Slot (**`training_unit_sections` + `training_unit_section_items`** wie bei geplanten Einheiten — **CURR‑010** inhaltlich, technisch seit **037** identisch zur Planungsstruktur). Der persistierte **Progressionsgraph** zwischen Übungen bleibt **optional** (**CURR‑013**). + +**Bibliothek only (036):** Kein Kopf‑`plan_mode`/keine Kopf‑`group_id`; Zuordnung zu Gruppe und Datum erfolgt nur über **kopierte** Kalender‑`training_units` (Instanz). + +**Konkretisierung (037/API):** `POST /api/training-units/from-framework-slot` legt eine geplante Einheit aus dem Slot‑Blueprint an; **`origin_framework_slot_id`** dient als Herkunftsreferenz (**Lineage light**; weiteres Feedback/Lineage‑Konzept: Konzeptpapier Schritt **E**). + --- ## Methodenbezug (§11.5) 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..d5366b8 --- /dev/null +++ b/.claude/docs/functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md @@ -0,0 +1,225 @@ +# Konzept: Trainingsplanung über Einheiten hinweg, Kurspläne, Governance, Assessments + +**Status:** Arbeitspapier (lebend) +**Stand:** 2026-05-05 (Rahmen‑Bibliothek **036**, Slot‑Blueprint **037** / API `from-framework-slot`; CURR‑002 Stufe 1 Graph unverändert 032–034) +**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) | ✅ **Zwischenstand im Produkt** (Migrationen 032–034, UI/API); UX für **parallele gleichwertige Alternativ‑Pakete** noch kein Erstklass‑Fall — siehe `technical/TRAINING_FRAMEWORK_SPEC.md` §4 | +| **2** | **Planungs-/Rahmenmodus:** Übungen auf **mehrere** Session-Slots verteilen; **mehrere Ziele**; speicherbare Rahmen-Vorlage (CURR‑002 (2) i. V. m. **CURR‑010–013**) | **in Arbeit**: Bibliotheks‑Backend + Slot‑Blueprint + Kopie‑API (**037**); **UI Kalender**/Bulk folgt (**CURR‑012**) | +| **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) | ➕ **teilweise:** `training_units.origin_framework_slot_id`; vollständiges Konzept/UX offen | +| **F** | **Assessments** | 📌 Backlog (CURR-003) | +| **G** | **Progressions-Automatik** (KI, komplexe Vorschläge) | 📌 Backlog (CURR-003) | + +**Aktueller Fokus:** **Kalender-/Planungs‑UI** an **`POST /api/training-units/from-framework-slot`** und Visibility für geteilte Rahmen; weiteres Lineage (**Schritt E**) ergänzend zu **`origin_framework_slot_id`**. + +--- + +### 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`, **`visibility`** seit **035** (Backfill **`club`**); Referenz‑Muster an Übung angleichen (**CURR‑007** erledigt für dieses Feld) | +| `training_framework_programs` | `visibility`, `club_id`, `created_by`; Kontext `focus_area_id`, `style_direction_id`; keine Kopf‑`group_id`; Slot‑Inhalt über **Blueprint‑`training_units`** (**036–037**) | +| `training_units` | `group_id`, `created_by`, `plan_template_id` — **Instanz** oder **Blueprint** (`framework_slot_id`); Lineage‑Light **`origin_framework_slot_id`** | + +#### 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**. + +**Stand Code 036–037:** Am Rahmenkopf gibt es **keine** **`plan_mode`/`group_id`** mehr — die Bibliothek ist immer „Modus B“; konkrete Gruppe/Zeit entstehen **nur** in **`training_units`** (Kalender‑Zeilen oder Übernahme‑API **`from-framework-slot`**). + +--- + +#### 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)** als **Teil des vollständigen Ablaufs** (wie geplante Einheit: **Sektionen und Items**, **037**) 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?~~ → **Festgelegt:** eine Entität **`training_framework_programs`** + **`plan_mode`**; siehe **`technical/TRAINING_FRAMEWORK_SPEC.md` §2.0**. +- **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. ~~Progressionsgraph Stufe 1~~ ✅ siehe **`technical/TRAINING_FRAMEWORK_SPEC.md`** §3–§4 · **Jetzt:** **`TRAINING_FRAMEWORK_SPEC.md`** §2 (Checkliste) mit **DDL-/API-Abschnitt Rahmen** ergänzen (**CURR‑002 (2)**); Modus A/B siehe Funktionskonzept §6 +3. **Migrate** weiter **CURR‑007 / CURR‑008** (ideal parallel oder vor erster Rahmen‑Migration mit neuem Bibliothekstyp) +4. Konzeptpaket optional **Schritt E** Lineage vor Implementierung Großrelease + +--- + +## 9. Changelog dieser Datei + +| Datum | Änderung | +|-------|-----------| +| 2026-04-30 | §8 Punkt 2 angepasst (Graph ✅; nächster Fokus Rahmen‑Spec **CURR‑002 (2)**). | +| 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/library/FEATURES_DELIVERED_2026-Q2.md b/.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md index 2e0b56c..90045d9 100644 --- a/.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md +++ b/.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md @@ -1,9 +1,9 @@ -# Gelieferte Features & technische Basis (April 2026) +# Gelieferte Features & technische Basis (Q2 2026) -**Stand:** 2026-04-27 -**Referenz:** `backend/version.py` — **APP_VERSION 0.7.9**, **DB_SCHEMA_VERSION 20260427030** +**Stand:** 2026-05-05 +**Referenz:** `backend/version.py` — **APP_VERSION 0.8.10**, **DB_SCHEMA_VERSION 20260505037** -Dieses Dokument bündelt die in der Entwicklungsphase erreichten **lieferbaren** Funktionen und die zugehörigen **technischen Artefakte**. Detail-Spezifikationen bleiben in den verlinkten Pfaden unter `.claude/docs/technical/` und `.claude/docs/functional/`. +Dieses Dokument bündelt die in der Entwicklungsphase erreichten **lieferbaren** Funktionen und die zugehörigen **technischen Artefakte**. Trainingsrahmen‑Bibliothek + Slot‑Blueprint: **`technical/TRAINING_FRAMEWORK_SPEC.md`** §2. **Progressionsgraph zwischen Übungen** (Zwischenstand, Grenzen): **§§3–4**. Detail-Spezifikationen bleiben in den verlinkten Pfaden unter `.claude/docs/technical/` und `.claude/docs/functional/`. --- @@ -11,20 +11,32 @@ Dieses Dokument bündelt die in der Entwicklungsphase erreichten **lieferbaren** | Migration | Inhalt | |-----------|--------| +| **032–034** | **Progressionsgraph Übung→Übung:** Container `exercise_progression_graphs`, Kanten `exercise_progression_edges`; **`notes`** (033); optionale Varianten-Endpunkte + Constraints (034) | | **028** | `exercise_media` erweitert (Embed/Metadaten), `exercise_skills` Level-Felder (VARCHAR); Medien-API | | **029** | Kanonische Fähigkeitsstufen (basis–optimierung), `model_levels`-Namen | | **030** | `training_unit_exercises.exercise_variant_id` → FK `exercise_variants(id)` ON DELETE SET NULL | +| **035** | **`training_framework_programs`** + Ziele, Slots (+ frühere Slot‑Übungstabelle, heute entfallen nach **037**); **`training_plan_templates.visibility`** | +| **036** | Rahmen nur Bibliothek: Kontext + M:N Trainingsarten/Zielgruppen; keine Modus-Spalten / keine Kopf‑`group_id` | +| **037** | **`training_units.framework_slot_id`**, strukturierter Ablauf wie Planung; Entfall **`training_framework_slot_exercises`**; **`origin_framework_slot_id`** | --- -## 2. Backend – Übungen (`routers/exercises.py`) +## 2. Backend – Progressionsgraphen (`routers/exercise_progression_graphs.py`) -### 2.1 Liste & Suche +- REST unter **`/api/exercise-progression-graphs`** inkl. Kanten-CRUD, **`POST …/edges/sequence`** (Reihe auf einmal), **`POST …/edges/delete-batch`**. +- AuthZ wie Trainingsvorlagen: Admin/Superadmin oder Graph‑Ersteller; Anlegen mit Trainings-/Planungsrolle (`_has_planning_role`). +- Listenresponses mit Übungstiteln und Variantennamen (JOIN). + +--- + +## 3. Backend – Übungen (`routers/exercises.py`) + +### 3.1 Liste & Suche - `GET /api/exercises` mit Filtern u. a.: Fokus, Stilrichtung, Trainingsstil, Zielgruppe, Fähigkeiten, **Skill-Stufe min/max**, `visibility_any`, `status_any`, `search`, **`ai_search`** (Platzhalter, derzeit gleiche Volltextlogik wie `search`). - Optional: **`include_variants=true`** — liefert pro Übung ein kompaktes **`variants`**-JSON (id, variant_name, sequence_order) für Planung/UI. -### 2.2 Übungsvarianten (CRUD) +### 3.2 Übungsvarianten (CRUD) Implementiert gemäß **`EXERCISES_API_SPEC.md`** (Varianten-Abschnitt): @@ -35,7 +47,7 @@ Implementiert gemäß **`EXERCISES_API_SPEC.md`** (Varianten-Abschnitt): Sortierung der Varianten im Detail: **`sequence_order`**, dann **`progression_level`**, dann **`id`**. -### 2.3 Medien-Upload – Größenlimits +### 3.3 Medien-Upload – Größenlimits - Standard: **50 MB** pro Datei (`EXERCISE_MEDIA_MAX_UPLOAD_MB`, Default 50). - **`admin`** / **`superadmin`**: **1024 MB** Default (`EXERCISE_MEDIA_ADMIN_MAX_UPLOAD_MB`), nie unter dem Nutzer-Limit (in MB verglichen). @@ -44,16 +56,18 @@ Logik: `_upload_limit_bytes(session)` vor `read()`-Prüfung. --- -## 3. Backend – Trainingsplanung (`routers/training_planning.py`) +## 4. Backend – Trainingsplanung (`routers/training_planning.py`) -- `training_unit_exercises`: Schreiben/Lesen von **`exercise_variant_id`**. -- Validierung: Variante muss zur gewählten **`exercise_id`** gehören. -- JOIN liefert u. a. **`exercise_variant_name`** beim Lesen einer Einheit. +- Strukturierte Einheiten: **`training_unit_sections`** + **`training_unit_section_items`** (Migration **031**) — Hauptpfad beim Lesen/Schreiben von Einheiten. +- **`training_unit_exercises`:** Legacy-/Nebenpfad; weiterhin **`exercise_variant_id`** (Migration **030**) mit Validierung gegen die gewählte **`exercise_id`**; JOINs liefern u. a. **`exercise_variant_name`**. +- **Blueprint‑Zeilen (`framework_slot_id` gesetzt):** **`GET /api/training-units`** listet diese **nicht**; **`PUT`** mit eingeschränkten Regeln (**kein** `plan_template_id` / kein Reset aus Vorlage über diesen Kopf wie bei Kalender‑Einheit). +- Übernahme aus Rahmen: **`POST /api/training-units/from-framework-slot`** ({ `framework_slot_id`, `group_id`, `planned_date` }) — tiefe Kopie inkl. Sektionen/Items; **`origin_framework_slot_id`** setzt Lineage‑Light. --- -## 4. Frontend – Übungsliste (`ExercisesListPage.jsx`) +## 5. Frontend – Übungsliste (`ExercisesListPage.jsx`) +- Tabs **Liste** · **Progressionsgraphen** (`ExerciseProgressionGraphPanel`): Graphen anlegen/bearbeiten, Kanten inkl. Sequenz-Bulk und Tabellenansicht. - **Filter-Modal** (Fokus, Stilrichtung, Trainingsstil, Zielgruppe, Fähigkeit + Stufen von/bis, Sichtbarkeit, Status). - **Filter-Chips** unter der Suchleiste; Klick entfernt einen Filter; Badge am Filter-Button = Anzahl Chips. - **Kein Vollbild-Spinner** bei jeder Suche: nur noch **`listFetching`** — Suchfelder bleiben im DOM (**Fokus/Cursor** bleiben erhalten); Liste zeigt optional „Aktualisiere Treffer…“. @@ -62,29 +76,30 @@ Logik: `_upload_limit_bytes(session)` vor `read()`-Prüfung. --- -## 5. Frontend – Übung bearbeiten (`ExerciseFormPage.jsx`) +## 6. Frontend – Übung bearbeiten (`ExerciseFormPage.jsx`) - **Varianten-Editor**: eingeklappter Bereich (`
`), **eine Variante zur Zeit** über Dropdown oder „Neue Variante“; Felder über **`ExerciseVariantFields`**; Reihenfolge Nach oben/unten; Speichern/Löschen pro Variante. - **Medien** wie zuvor (Formularteil). +- Block **Progressionsgraph** (Edit): Kanten mit Bezug zur aktuellen Übung. Hinweis: Es gibt **keine** separaten Routen `/exercises/:id/variants/...` — Bearbeitung erfolgt unter **`/exercises/:id/edit`** (Routing-Doku ggf. anpassen). --- -## 6. Frontend – Übung Detail (`ExerciseDetailPage.jsx`) +## 7. Frontend – Übung Detail (`ExerciseDetailPage.jsx`) - Varianten-Abschnitt mit **Meta** (Dauer, Schwierigkeit, Material, Progressionsstufe) wo vorhanden. --- -## 7. Frontend – Trainingsplanung (`TrainingPlanningPage.jsx`) +## 8. Frontend – Trainingsplanung (`TrainingPlanningPage.jsx`) - `listExercises({ include_variants: true })`. - Pro Zeile: Übung + **Variante** (optional), Dauer, Reihenfolge. --- -## 8. Rich-Text (`RichTextEditor.jsx` + CSS) +## 9. Rich-Text (`RichTextEditor.jsx` + CSS) - **Selection Save/Restore** vor Toolbar-Klicks (`insertUnorderedList` / `insertOrderedList` zuverlässiger bei Mehrzeilen-Markierung). - **`styleWithCSS` false** vor Formatbefehlen. @@ -92,24 +107,36 @@ Hinweis: Es gibt **keine** separaten Routen `/exercises/:id/variants/...` — Be --- -## 9. Admin – Matrix / Reifegrad (Kontext) +## 10. Admin – Matrix / Reifegrad (Kontext) - Bereits dokumentiert in **`CHANGELOG`** / Module **`maturity_models`**: Matrix-Stack-Bundle Export/Import, Kontext-Bindings — siehe `version.py` und Admin-UI-Pfade. --- -## 10. Nächste sinnvolle Schritte (nicht Lieferstand) +## 11. Trainingsrahmen: Bibliothek + Slot‑Blueprint (DB **036–037**) +- **036:** `training_framework_programs` nur Bibliothek — `focus_area_id`, `style_direction_id`, M:N `training_framework_program_training_types` / `_target_groups`; Entfall `plan_mode`, `group_id`; Slot‑Verknüpfungen zu Kalender‑Einheiten geleert. +- **037:** Pro Slot genau eine **`training_units`**‑Zeile mit **`framework_slot_id`**; Ablauf über **`training_unit_sections`** / **`training_unit_section_items`** (wie Planung); Legacy **`training_framework_slot_exercises`** Datenmigration + **`DROP` TABLE**; geplante Kopien können **`origin_framework_slot_id`** tragen. +- **Router `training_framework_programs.py`:** CRUD **`/api/training-framework-programs`**, Slots im Speichern mit neuen Blueprint‑`training_units`, Hydration **`sections`/`exercises`/`blueprint_training_unit_id`**; siehe **`TRAINING_FRAMEWORK_SPEC.md`** §2.3. +- **Frontend:** **`TrainingFrameworkProgramEditPage.jsx`**, **`createTrainingUnitFromFrameworkSlot`** (`api.js`). +- **Doku:** **`technical/TRAINING_FRAMEWORK_SPEC.md`** §2; **`technical/DATABASE_SCHEMA.md`**; **`functional/DOMAIN_MODEL.md`** (Trainingsrahmen‑Abschnitt). + +--- + +## 12. Nächste sinnvolle Schritte (nicht Lieferstand) + +- Trainingsplanung: Kalender‑UI‑Anbindung **„aus Rahmen übernehmen“**; Visibility/Policies für geteilte Rahmen (**CURR‑004** später). - Progressions-Serien als **Blöcke** (angekündigt; Voraussetzung: `prerequisite_variant_id` / `progression_level` vorhanden). - Serverseitige **Suchvorschläge** (Autocomplete-Endpoint), falls datalist nicht reicht. - Optional: Streaming/chunked Upload für sehr große Videos (RAM-Thema). --- -## 11. Verweise +## 13. Verweise | Thema | Dokument | |--------|----------| +| Rahmenprogramm / Progressionsgraph | `technical/TRAINING_FRAMEWORK_SPEC.md` | | API Übungen | `technical/EXERCISES_API_SPEC.md` | | Domänenmodell | `functional/DOMAIN_MODEL.md` | | Datenbank Überblick | `technical/DATABASE_SCHEMA.md` | diff --git a/.claude/docs/technical/DATABASE_SCHEMA.md b/.claude/docs/technical/DATABASE_SCHEMA.md index 203ee19..58a3668 100644 --- a/.claude/docs/technical/DATABASE_SCHEMA.md +++ b/.claude/docs/technical/DATABASE_SCHEMA.md @@ -1,8 +1,8 @@ # Shinkan Jinkendo - Datenbank-Schema (Technisch) -**Version:** 0.4.0 -**Stand:** 2026-04-27 -**Aktuell deployed:** Migration 023 (Skills Complete Import) +**Version:** 0.5.2 +**Stand:** 2026-05-05 +**Hinweis:** Produktiver Deploy sollte mindestens bis Migration **037** (Rahmen‑Slot‑Blueprints in `training_units`; Entfall `training_framework_slot_exercises`) geführt sein — Details siehe `backend/version.py` (`DB_SCHEMA_VERSION`). --- @@ -40,6 +40,13 @@ Dieses Dokument beschreibt die **technische Datenbankstruktur** von Shinkan Jink | 021 | 2026-04-27 | ~~Import Skills from Matrix~~ (DEPRECATED) | ⚠️ Faulty | | **022** | **2026-04-27** | **Skills Schema Complete (BREAKING)** | ✅ Deployed | | **023** | **2026-04-27** | **Skills Complete Import (69 Skills)** | ✅ Deployed | +| 024–031 | *versch.* | Reifegradmodelle, Medien, Planvorlagen/Sektionen u. a. — siehe `backend/migrations/` | ✅ je Umgebung | +| **032** | **2026-04-30** | **Progressionsgraph Übung→Übung:** `exercise_progression_graphs`, `exercise_progression_edges` | ✅ | +| **033** | **2026-04-30** | **`exercise_progression_edges.notes`** | ✅ | +| **034** | **2026-04-30** | **Kanten-Endpunkte optional `exercise_variants`; UNIQUE/CHECK** | ✅ | +| **035** | **2026-05-05** | **Rahmenprogramm:** `training_framework_programs` (+ Ziele, Slots, früher `training_framework_slot_exercises`); **`training_plan_templates.visibility`** (Backfill `club`) — siehe `TRAINING_FRAMEWORK_SPEC.md` | ✅ | +| **036** | **2026-05-05** | **Rahmen nur Bibliothek:** Kopf mit `focus_area_id`, `style_direction_id`, M:N Trainingsarten/Zielgruppen; Entfall `plan_mode`, `group_id`; Slot‑`training_unit_id` geleert — siehe `036_framework_program_context_only_library.sql` | ✅ | +| **037** | **2026-05-05** | **Slot‑Blueprint:** `training_units.framework_slot_id` (+ CHECK Blueprint vs. Kalender), `origin_framework_slot_id`; Migration Slot‑Übungen → `training_unit_sections`/`training_unit_section_items`; **`DROP training_framework_slot_exercises`** | ✅ | --- @@ -265,17 +272,74 @@ exercise_variants (id, exercise_id, name, description, ...) exercise_media (id, exercise_id, type, url, title, description, ...) ``` -### Training Planning +### Trainingsrahmenprogramm Bibliothek (Migrationen **035–036**) + +Kopf ohne Gruppenbindung (`training_framework_programs`), Ziele, Slots. Slot‑spezifischer Ablauf liegt nach **037** nicht mehr in eigener Übungstabelle, sondern in **`training_units`** mit **`framework_slot_id`** — siehe nächster Abschnitt. ```sql -training_units (id, group_id, date, title, description, ...) -training_unit_exercises ( - training_unit_id, exercise_id, sort_order, - exercise_variant_id -- FK exercise_variants(id) ON DELETE SET NULL (Migration 030) +training_framework_programs (… focus_area_id, style_direction_id, visibility, club_id, created_by …) +training_framework_goals (framework_program_id, sort_order, title, notes) +training_framework_slots (framework_program_id, sort_order, title, notes, training_unit_id -- ungenutzt) +training_framework_program_training_types (framework_program_id, training_type_id) +training_framework_program_target_groups (framework_program_id, target_group_id) +``` + +### Training Planning & Rahmen‑Blueprint (Migrationen 006, 031, **037**) + +Geplante Einheit und **Rahmen‑Slot‑Blueprint** teilen sich **`training_units`** und den strukturierten Ablauf über **Sektionen** (031). Blueprint‑Zeilen haben **`framework_slot_id`** gesetzt (genau eine Zeile pro Slot); Kalender‑Zeilen haben **`framework_slot_id IS NULL`** und **`group_id` / `planned_date`** gesetzt. Kopien aus dem Rahmen können **`origin_framework_slot_id`** setzen. + +```sql +training_units ( + id, + group_id INT NULL REFERENCES training_groups(id), -- Pflicht für Kalender‑Zeilen (CHECK) + planned_date DATE NULL, -- Pflicht für Kalender‑Zeilen (CHECK) + planned_time_start, planned_time_end, planned_focus, + actual_date, actual_time_start, actual_time_end, attendance_count, + status, notes, trainer_notes, + created_by, plan_template_id REFERENCES training_plan_templates(id), + framework_slot_id INT NULL REFERENCES training_framework_slots(id) ON DELETE CASCADE, + origin_framework_slot_id INT NULL REFERENCES training_framework_slots(id) ON DELETE SET NULL, + … ) +training_unit_sections ( + training_unit_id, order_index, title, guidance_notes, + source_template_section_id REFERENCES training_plan_template_sections(id) +) +training_unit_section_items ( + section_id, order_index, item_type CHECK ('exercise'|'note'), + exercise_id, exercise_variant_id, planned_duration_min, actual_duration_min, + notes, modifications, note_body +) +``` + +**Legacy (Migration 006, für ältere Codepfade noch referenzierbar):** `training_unit_exercises`; produktiver Standardablauf liegt in **Sections/Items**. + +**Trainingsvorlagen (031):** `training_plan_templates`, `training_plan_template_sections`. + +```sql exercise_blocks (id, name, description, created_by, club_id, ...) -- Migration 017 ``` +### Progressionsgraph Übung → Übung (Migrationen 032–034) + +Separater gerichteter Graph **zwischen** Übungen (nicht zu verwechseln mit Varianten-Reihen **innerhalb** einer Übung, Migration 014). Detail-DDL und REST siehe `technical/TRAINING_FRAMEWORK_SPEC.md` §3. + +```sql +exercise_progression_graphs ( + id, name, description, visibility, club_id, created_by, + created_at, updated_at +) +exercise_progression_edges ( + id, graph_id, + from_exercise_id, to_exercise_id, + from_exercise_variant_id, -- nullable (Migration 034) + to_exercise_variant_id, + edge_type, -- z. B. next_exercise, sibling + notes, -- Migration 033 + created_at +) +``` + ### MediaWiki Import (Migration 018) ```sql diff --git a/.claude/docs/technical/EXERCISES_API_SPEC.md b/.claude/docs/technical/EXERCISES_API_SPEC.md index 24ac9f1..9572d7d 100644 --- a/.claude/docs/technical/EXERCISES_API_SPEC.md +++ b/.claude/docs/technical/EXERCISES_API_SPEC.md @@ -1,9 +1,10 @@ # Exercises API Specification -**Version:** 1.3 -**Datum:** 2026-04-27 -**Status:** Teilweise implementiert (Liste mit Filtern + Varianten + Medienlimits siehe Code) +**Version:** 1.4 +**Datum:** 2026-04-30 +**Status:** Teilweise implementiert (Liste mit Filtern + Varianten + Medienlimits + Progressionsgraphen siehe Code) **Autor:** Claude Code +**Änderungen v1.4:** Endpoints **`/exercise-progression-graphs`** inkl. Kanten, **`POST …/edges/sequence`**, **`POST …/edges/delete-batch`** — Detailtabellen siehe **`TRAINING_FRAMEWORK_SPEC.md`** §3.3 **Änderungen v1.3:** `GET /exercises` erweiterte Query-Parameter (`include_variants`, Multi-Filter, `ai_search`-Platzhalter); Dokumentation angepasst **Änderungen v1.2:** KI-Assistenz Endpoints, Skill-Level-System (benannte Stufen), intensity als low/medium/high **Änderungen v1.1:** Exercise Blocks Endpoints, Permissions dokumentiert, age_groups korrigiert @@ -60,6 +61,20 @@ Development: https://dev.shinkan.jinkendo.de/api | PUT | `/exercise-blocks/{id}/items/{item_id}` | Update item | | DELETE | `/exercise-blocks/{id}/items/{item_id}` | Remove item | | PUT | `/exercise-blocks/{id}/items/reorder` | Reorder items (DnD) | +| **Progressionsgraphen** (Übung→Übung) | +| GET | `/exercise-progression-graphs` | Liste Graphen | +| GET | `/exercise-progression-graphs/{id}` | Detail; Query `include_edges` | +| POST | `/exercise-progression-graphs` | Graph anlegen | +| PUT | `/exercise-progression-graphs/{id}` | Metadaten | +| DELETE | `/exercise-progression-graphs/{id}` | Graph + Kanten | +| GET | `/exercise-progression-graphs/{id}/edges` | Kantenliste | +| POST | `/exercise-progression-graphs/{id}/edges` | Einzelkante | +| POST | `/exercise-progression-graphs/{id}/edges/sequence` | Reihe (`steps`) in einer Transaktion | +| PUT | `/exercise-progression-graphs/{id}/edges/{edge_id}` | z. B. Notiz | +| DELETE | `/exercise-progression-graphs/{id}/edges/{edge_id}` | Kante löschen | +| POST | `/exercise-progression-graphs/{id}/edges/delete-batch` | `{ edge_ids }` | + +Vollständige Pfadtabelle, Auth und Feldgrenzen: **`TRAINING_FRAMEWORK_SPEC.md`** §3. --- diff --git a/.claude/docs/technical/EXERCISES_ARCHITECTURE.md b/.claude/docs/technical/EXERCISES_ARCHITECTURE.md index 42fecfa..8ce3822 100644 --- a/.claude/docs/technical/EXERCISES_ARCHITECTURE.md +++ b/.claude/docs/technical/EXERCISES_ARCHITECTURE.md @@ -1,9 +1,10 @@ # Exercise System Architecture -**Version:** 1.0 -**Datum:** 2026-04-24 +**Version:** 1.1 +**Datum:** 2026-04-30 **Status:** DRAFT - Awaiting Review -**Autor:** Claude Code +**Autor:** Claude Code +**Änderungen v1.1:** Progressionsgraph **zwischen** Übungen (Migration 032–034); Verweis `TRAINING_FRAMEWORK_SPEC.md` --- @@ -54,6 +55,10 @@ Exercise Block ──── (N) Block Items ──── (1) Exercise └── is_placeholder (for templates) ``` +### 1.1b Progressionsgraph zwischen Übungen (nicht „Serie“) + +**Abgrenzung:** Separates Konzept von der **Varianten-Serie** (§1.1): hier geht es um **gerichtete Kanten zwischen verschiedenen Übungen** (optional mit Varianten als Knoten-Endpunkten), gruppiert in Bibliotheks-Containern (`exercise_progression_graphs`). Schema, REST, Produktgrenzen und Backlog (parallele Alternativ-Pakete): **`TRAINING_FRAMEWORK_SPEC.md`** §3–§4. + --- ### 1.2 Medien-Strategie diff --git a/.claude/docs/technical/EXERCISES_DATABASE_FINAL.md b/.claude/docs/technical/EXERCISES_DATABASE_FINAL.md index 5510c0b..bb24b6e 100644 --- a/.claude/docs/technical/EXERCISES_DATABASE_FINAL.md +++ b/.claude/docs/technical/EXERCISES_DATABASE_FINAL.md @@ -19,6 +19,8 @@ **Basis:** Migrationen 001-013 (bereits deployed) +**Progressionsgraph zwischen Übungen:** Migrationen **032–034** — nicht Bestandteil dieses „Exercise Catalog“-Schemas-Dokuments; siehe **`TRAINING_FRAMEWORK_SPEC.md`** §3 und **`DATABASE_SCHEMA.md`** (Migrationshistorie). + --- ## 2. Migration 014: Variant Progression + Search diff --git a/.claude/docs/technical/EXERCISES_FRONTEND_ROUTING.md b/.claude/docs/technical/EXERCISES_FRONTEND_ROUTING.md index d8f8ddc..9ef6f81 100644 --- a/.claude/docs/technical/EXERCISES_FRONTEND_ROUTING.md +++ b/.claude/docs/technical/EXERCISES_FRONTEND_ROUTING.md @@ -1,9 +1,10 @@ # Frontend Routing & Navigation Specification -**Version:** 1.1 -**Datum:** 2026-04-27 +**Version:** 1.2 +**Datum:** 2026-04-30 **Status:** DRAFT - Awaiting Review **Autor:** Claude Code +**Änderungen v1.2:** Übersicht **Übungen**: Tabs Liste \| Progressionsgraphen auf `/exercises`; Progressions-Editor ohne neue Routen (Panel + Formularblock unter `/exercises/:id/edit`) **Änderungen v1.1:** Übungsvarianten-Bearbeitung nur unter `/exercises/:id/edit` (keine VariantFormPage-Routen) --- @@ -13,10 +14,10 @@ ### 1.1 Route-Übersicht ``` -/exercises → ExercisesListPage (Grid + Filter + Chips) +/exercises → ExercisesListPage — Tabs: **Liste** \| **Progressionsgraphen** (`ExerciseProgressionGraphPanel`) /exercises/new → ExerciseFormPage (Create) /exercises/{id} → ExerciseDetailPage (Accordion-Layout) -/exercises/{id}/edit → ExerciseFormPage (Edit inkl. Varianten-Editor inline) +/exercises/{id}/edit → ExerciseFormPage (Edit inkl. Varianten-Editor inline + Block Progressionsgraph) /exercise-blocks → ExerciseBlocksListPage (Meine Blocks) /exercise-blocks/new → ExerciseBlockFormPage (Create) @@ -672,7 +673,7 @@ function App() { --- -**Version:** 1.1 -**Letzte Änderung:** 2026-04-24 -**Status:** REVIEWED - Pending Implementation -**Review-Änderungen:** Exercise Blocks Routes + Navigation hinzugefügt +**Version:** 1.2 +**Letzte Änderung:** 2026-04-30 +**Status:** REVIEWED - Pending Implementation +**Review-Änderungen:** Progressionsgraphen-UI (Tabs, Formularblock); Exercise Blocks Routes + Navigation (früher) diff --git a/.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md b/.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md new file mode 100644 index 0000000..2062851 --- /dev/null +++ b/.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md @@ -0,0 +1,193 @@ +# Trainingsrahmenprogramm — Technische Spezifikation + +**Status:** Rahmen‑Bibliothek + Slot‑Blueprint dokumentiert · **Stand:** 2026-05-05 (Migration **036–037**) +**Bindendes Fachkonzept / Entscheide:** `.claude/docs/functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR‑001 bis CURR‑013) + +**Relevant für nächsten Schritt:** CURR‑002 **(2)** Trainingsplanung / Rahmen über mehrere Einheiten — der hier dokumentierte **Progressionsgraph Stufe 1** ist bewusst **unterstützend**, keine Pflicht für Slot-Zuordnungen (**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 **innerhalb einer Übung** (Migration 014). Kanten **zwischen** Übungen siehe **§3**. | +| `DATABASE_SCHEMA.md` | **Nachgeordnete** Übersicht: Migrationshistorie und Tabellenliste; Detail-DDL primär **hier §2–§3** + SQL unter `backend/migrations/`. | +| `functional/DOMAIN_MODEL.md` | Fachliche Begriffe; Kurzverweis auf Progressionsgraph ergänzt. | +| `TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` | **Was** und **warum** (Bibliothek vs. Instanz, Governance, CURR‑Tabelle). | + +**Konsequenz:** Diese Datei bleibt der **technische Arbeitspool** für Rahmenprogramm Stufe 1–2. Abschnitt **§4** beschreibt explizit den **aktuellen Produktfreigabe-Umfang** und **bekannte Lücken** (damit Trainingsplanung weiter gebaut werden kann ohne falscher Erwartung an „Alternative‑Pakete“ in der UI). + +--- + +## 2. Rahmenprogramm (CURR‑002 Stufe 2) — Checkliste & technische Ausarbeitung + +### 2.0 Technische Entscheidung: nur Bibliothek + Slot‑Blueprint = `training_units` + +**Fachlich:** Das Rahmenprogramm ist eine **wiederverwendbare Bibliotheksvorlage** ohne Bindung an eine Trainingsgruppe oder einen Kalendertermin (**Migration 036** entfernte `plan_mode`, `group_id` am Kopf sowie die Nutzung von `training_framework_slots.training_unit_id` für „konkrete“ Kopplung). + +**Slot‑Inhalt (Migration 037):** Pro `training_framework_slot` existiert genau eine **Blueprint‑Zeile** in `training_units` mit **`framework_slot_id`** (partieller **UNIQUE**-Index); der Ablauf entspricht **derselben** Struktur wie geplante Einheiten über **`training_unit_sections`** und **`training_unit_section_items`** (Übungen, Notizen, Varianten wie in der Planung). Die frühere Tabelle **`training_framework_slot_exercises`** wird nach Datenübernahme **`DROP`**pt. + +**Geplante Einheit aus Rahmen:** **`POST /api/training-units/from-framework-slot`** kopiert diese Blueprint‑Unit (**tiefe Kopie**) mit **`group_id` + `planned_date`**; **`origin_framework_slot_id`** hält die Herkunft (Lineage‑Light). **`GET /api/training-units`** blendet Einheiten mit **`framework_slot_id IS NOT NULL`** aus (Kalender/API‑Liste ohne Rahmen‑Blueprints). + +**CHECK‑Constraint auf `training_units`:** Zeile ist entweder **Blueprint** (`framework_slot_id` gesetzt, `group_id`/`planned_date` NULL, kein `origin_framework_slot_id`) oder **Kalender‑Einheit** (`framework_slot_id` NULL, `group_id` und `planned_date` gesetzt; `origin_framework_slot_id` optional). + +**Konsequenz Konzept CURR‑012 („concrete/library“):** Persistiert wird **ein** Kopf ohne Modus-Spalte: Immer Bibliotheks‑Rolle; Konkretisierung nur über Planung/API‑Kopie. Historische DDL mit `plan_mode` siehe **`035`**/`036` in dieser Datei (**§5 Changelog**) und `backend/migrations/`. + +### 2.1 Checkliste (Abhak-Stand) + +- [x] **Entität(en):** `training_framework_programs` (**CURR‑009**); `training_plan_templates` unverändert **eine‑Einheit‑Mikrovorlage** (**C5**). +- [x] **Bibliothek only (036):** Kopf ohne `plan_mode`/`group_id`; Kontextfilter **`focus_area_id`**, **`style_direction_id`**; M:N **`training_framework_program_training_types`**, **`training_framework_program_target_groups`**. +- [x] **Zielliste:** `training_framework_goals`, API **≥ 1** Ziel (**CURR‑011**). +- [x] **Slots:** `training_framework_slots` mit **`sort_order`**, optional **Titel/Notizen**; **Ablauf** über zugehörige **Blueprint‑`training_units`** + Sektionen/Items (**037**), nicht mehr `training_framework_slot_exercises`. +- [x] **Progressionsgraph:** Stufe 1 (**§3–§4**); **kein Pflichtbezug** pro Slot (**CURR‑013**). +- [x] **Kein Live‑Write** von Kalendereinheiten zurück in die Vorlage (**CURR‑006**); Konkretisierung = **Kopie** (siehe **§2.4**). +- [x] **Instanziierung (MVP):** `POST /api/training-units/from-framework-slot` — weiterer Ausbau: Bulk, Kalender‑UI‑Flow, **`training_plan_template_id` pro Slot** weiterhin optional/deferred (**CURR‑010**). +- [x] **Governance:** `visibility`, `club_id`, **`training_plan_templates.visibility`** (**035**) — (**CURR‑005–008**). +- [x] **REST Rahmenprogramm:** `/api/training-framework-programs` (**§2.3**); Planung (**§2.4**). + +### 2.2 DDL‑Überblick (Migrationen **035** → **036** → **037**) + +Auszug aktueller Zustand nach **036/037** (Details: `backend/migrations/035_training_framework_programs.sql`, `036_framework_program_context_only_library.sql`, `037_training_framework_blueprint_units.sql`): + +```sql +-- Kopf (ohne Modus-Spalten nach 036) +training_framework_programs ( + id, title NOT NULL, description, + planned_period_start, planned_period_end NULL, + visibility NOT NULL, club_id, created_by, + focus_area_id, style_direction_id NULL REFERENCES …, + … timestamps +) + +training_framework_goals ( + id, framework_program_id FK CASCADE, sort_order UNIQUE per framework, + title, notes +) + +training_framework_slots ( + id, framework_program_id FK CASCADE, sort_order UNIQUE per framework, + title, notes, + training_unit_id FK … (Spalte technisch noch vorhanden; fachlich ungenutzt, per 036 geleert) +) + +-- Blueprint: eigene Einheit wie in der Planung (031) +training_units.framework_slot_id -- UNIQUE (partial index), FK → slots ON DELETE CASCADE +training_units.origin_framework_slot_id -- optional auf Kopien aus dem Rahmen (SET NULL) +-- CHECK chk_training_units_blueprint_vs_scheduled (Blueprint vs. Kalender‑Zeile) +-- training_framework_slot_exercises → entfallen (037) +``` + +**Löschkaskaden:** Rahmen löschen ⇒ Ziele + Slots; **Slot löschen** ⇒ zugehörige Blueprint‑`training_unit` per **`ON DELETE CASCADE`** auf `framework_slot_id`. + +### 2.3 REST‑Überblick (`router` `training_framework_programs`) + +| Methode | Pfad | Zweck | +|---------|------|--------| +| GET | `/training-framework-programs` | Liste; Admin/Superadmin alle, sonst eigene (`created_by`); u. a. `goals_count`, `slots_count`, Kontext‑Counts | +| GET | `/training-framework-programs/{id}` | Detail inkl. `goals[]`, `slots[]` mit je **`blueprint_training_unit_id`**, **`sections[]`**, **`exercises[]`** (letzteres aus Sektionen geflacht, kompatibel zum Editor) | +| POST | `/training-framework-programs` | Neu; **Pflicht:** `title`, **`goals`** (≥ 1); optional `slots` (weiterhin **`exercises[]`** pro Slot möglich — Backend materialisiert Sektionen); Header: `focus_area_id`, `style_direction_id`, `training_type_ids`, `target_group_ids`, … | +| PUT | `/training-framework-programs/{id}` | Header; volles Ersetzen von **`goals`** und/oder **`slots`** (neue Slots ⇒ neue Blueprint‑Units) | +| DELETE | `/training-framework-programs/{id}` | Rahmen + Kinder | + +**AuthZ:** wie zuvor — Planungsrolle zum Schreiben; Lesen/Schreiben/Löschen des Rahmens: Admin/Superadmin oder Ersteller. + +**Payload‑Hinweise (JSON), Slots:** + +- Weiterhin: `exercises: [{ exercise_id, exercise_variant_id?, order_index? }]` (wird intern in eine Sektion übernommen). +- Erweitert möglich: `sections` wie bei **`PUT /api/training-units/{id}`** (voller Ablauf mit Notizen/Items). + +### 2.4 Planung / Kalender (`router` `training_planning.py`, Auszug) + +| Methode | Pfad | Zweck | +|---------|------|--------| +| GET | `/training-units` | Nur **Kalender‑Einheiten** (`framework_slot_id IS NULL`) | +| GET/PUT | `/training-units/{id}` | Blueprint lesen/bearbeiten: möglich mit Rahmen‑Auth; spezielle Regeln im **PUT** (kein Template‑Reset, kein `plan_template_id` am Blueprint) | +| DELETE | `/training-units/{id}` | Blueprint **nicht** über diesen Pfad löschen (Fehlerhinweis); Slot entfernen über Rahmen‑**PUT** | +| POST | `/training-units/from-framework-slot` | Body: `framework_slot_id`, `group_id`, `planned_date` — tiefe Kopie + **`origin_framework_slot_id`** | + +**Frontend:** `createTrainingUnitFromFrameworkSlot` in `frontend/src/utils/api.js`. + +--- + +## 3. Progressionsgraph Übung → Übung (implementierter Stand) + +### 3.1 Abgrenzung + +- **Zwischen Übungen:** gerichtete Kanten auf Ebene **`exercises`** mit optionalen Endpunkten auf konkreten **`exercise_variants`** (Knoten = „Übung“ oder „Übung · Variante“). Migrationen **032–034**. +- **Innerhalb einer Übung:** Reihenfolge / Progressionsmetadaten der Varianten unverändert über **`exercise_variants`** (Migration **014**) — nicht duplizieren. + +AuthZ analog **`training_plan_templates`**: Graph nur für **Admin/Superadmin** oder **Ersteller** (`created_by`); Anlegen neuer Graphen mit **`_has_planning_role`**. + +### 3.2 Migrationen & Schema (Kurz) + +| Mig. | Inhalt | +|------|--------| +| **032** | `exercise_progression_graphs` (Name, Beschreibung, **`visibility`**, **`club_id`**, **`created_by`**); `exercise_progression_edges` (`graph_id`, von/nach Übung, `edge_type` VARCHAR Default `next_exercise`). FK CASCADE zu Graph und Übungen. | +| **033** | `exercise_progression_edges.notes` (freier Text / „Entwicklungsziel“ pro Kante). | +| **034** | `from_exercise_variant_id`, `to_exercise_variant_id` (nullable, FK `exercise_variants`, CASCADE). CHECK: gleiche Übung nur mit **zwei verschiedenen Varianten**. UNIQUE-Index über Graph + Endpunkte inkl. `COALESCE(variant_id,0)` + `edge_type`. | + +Kantentypen in Produktnutzung: **`next_exercise`** (Nachfolger), **`sibling`** (Schwester / gleiche „Entwicklungslage“, semantisch oft Paar — weiterhin eine gerichtete Kante in DB). + +Listenqueries liefern Join‑Felder **`from_exercise_title`**, **`to_exercise_title`**, **`from_variant_name`**, **`to_variant_name`**. + +### 3.3 REST (`/api`, Router `exercise_progression_graphs.py`) + +| Methode | Pfad | Zweck | +|---------|------|--------| +| GET | `/exercise-progression-graphs` | Liste (+ `edges_count`); Admin sieht alle, sonst nur eigene. | +| GET | `/exercise-progression-graphs/{id}` | Detail; `?include_edges=true` | +| POST | `/exercise-progression-graphs` | Graph anlegen | +| PUT | `/exercise-progression-graphs/{id}` | Metadaten | +| DELETE | `/exercise-progression-graphs/{id}` | Graph + Kanten | +| GET | `/exercise-progression-graphs/{id}/edges` | Kanten; Query optional `from_exercise_id`, `to_exercise_id` | +| POST | `/exercise-progression-graphs/{id}/edges` | Einzelkante; Duplikat/Constraint → **409** | +| POST | `/exercise-progression-graphs/{id}/edges/sequence` | **Bulk:** `{ steps: [{ exercise_id, variant_id? }, …], segment_notes?: [...] }` — nur **`next_exercise`**, Transaktion alle oder keine Zeile | +| PUT | `/exercise-progression-graphs/{id}/edges/{edge_id}` | z. B. **`notes`** | +| DELETE | `/exercise-progression-graphs/{id}/edges/{edge_id}` | eine Kante | +| POST | `/exercise-progression-graphs/{id}/edges/delete-batch` | `{ edge_ids: [...] }` — z. B. gesamte sichtbare „Reihe“ löschen | + +### 3.4 Frontend (Stand Code) + +- **`ExercisesListPage`:** Tabs **Liste** · **Progressionsgraphen** → **`ExerciseProgressionGraphPanel`**. +- **`ExerciseFormPage`** (nur Edit): eingeklappter Block **Progressionsgraph** mit Kontext „diese Übung“ + Filter „nur betroffene Kanten“. +- Panel‑Funktionen: **Sequenz‑Editor** (mehrere Schritte → ein Bulk‑Speichern), zusammengefasste **Reihen‑Lesart** für `next_exercise`, eigene Liste für **Schwestern**, Einzelkantenbereich, Tab **Alle Kanten (Tabelle)**. +- API‑Client: `frontend/src/utils/api.js` (`createExerciseProgressionSequence`, `deleteExerciseProgressionEdgesBatch`, …). + +--- + +## 4. Zwischenstand für Produkt / Trainingsplanung (bewusste Grenzen) + +**Freigabe:** Der beschriebene Stand unterstützt **Rahmen‑Bibliothek mit vollem Ablauf pro Slot** (wie Planung) und **Kopie in die Gruppenplanung**; der **Progressionsgraph** bleibt **unterstützend** (**CURR‑013**). Offen: Kalender‑UI‑Flow, Bulk‑Instanziierung, erweiterte Lineage/Feedback (**Konzept Schritt E**). + +**Was gut nutzbar ist** + +- Lineare **Reihen** mehrerer Übungen (bzw. Varianten‑Knoten) über **Sequenz‑API** bzw. Sequenz‑UI. +- **Nachfolger‑Lesart** als zusammenhängende Kette in der Übersicht. +- **Schwester‑Kanten** als eigene Liste (Alternative gleicher „Stufe“ zwischen zwei Knoten). +- **Einzelkanten** für Sonderfälle und Verzweigungen. + +**Was noch nicht „ein Knopf“ ist (bekannt)** + +- **Parallele gleichwertige Alternativen** („Paket B bestehend aus mehreren Übungen als echte Alternative zu Paket A“) sind **nicht** als erste‑Klass‑UX modelliert: mehrere Nachfolger aus einem Knoten sind technisch möglich (mehrere `next_exercise`‑Kanten), aber **keine** dedizierte Gruppe „Alternativ‑Set“. Pflege kann mehrzeilig und koplastisch wirken. +- **Visualisierung echter Bäume** (Join‑Points, mehrere ausgehende Pfeile in einem Bild) ist nur eingeschränkt über Reihen‑Zusammenfassung + Tabelle abbildbar. + +**Nächste sinnvolle Ausbaustufen** (Backlog Graph‑UX, nicht Blocker Planung) + +- Semantik **`alternative_group_id`** oder **Hyperkanten** (ein UX‑Schritt legt mehrere Kanten mit gemeinsamer Gruppe an). +- Komfort beim Pflegen **symmetrischer Schwestern** (ein Klick für zwei Richtungen / Dedupe). +- Karten-/Baum‑Layout statt nur Zeilen. + +Details weiterhin Diskussionsgrundlage in `TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` §6 (Kantentypen). + +--- + +## 5. Changelog (Dokument) + +| Datum | Änderung | +|-------|----------| +| 2026-05-05 | **037 / API:** Nur Bibliothek + **Blueprint** pro Slot über `training_units` + Sektionen/Items; `training_framework_slot_exercises` entfernt; `POST …/training-units/from-framework-slot`; Planungsliste ohne Blueprints. **036** dokumentiert am Kopf (Kontext, M:N, kein plan_mode/group_id). **§2** vollständig ersetzt. | +| 2026-05-05 | **CURR‑002 (2):** §2 Rahmenprogramm — Entscheid **eine Tabelle + `plan_mode`**, DDL‑Skizze, REST‑Überblick; Migration **035**; `training_plan_templates.visibility`. *(Historisch — Modus-Spalten durch **036** ersetzt.)* | +| 2026-04-30 | **Zwischen-Doku:** §3 auf Migrationen 032–034 + API **sequence/delete-batch** + Frontend erweitert; **§4** Produktfreigabe vs. Lücken (parallele Alternativen); Changelog §5. | +| 2026-04-30 | §3: erste Fassung Migration 032 + REST‑Basis (CURR‑002 (1)). | +| 2026-04-28 | Erstanlage Stub mit Checkliste. | diff --git a/CLAUDE.md b/CLAUDE.md index adef7a2..fa53f8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ backend/ ├── migrations/ # SQL-Migrationen (XXX_*.sql Pattern) └── routers/ # Router-Module auth · profiles · clubs · groups · skills · methods - exercises · training_units · training_programs + exercises · exercise_progression_graphs · training_units · training_programs planning · import_wiki · admin · membership frontend/src/ @@ -78,12 +78,12 @@ frontend/src/ **Siehe:** `backend/version.py` (`APP_VERSION`, `DB_SCHEMA_VERSION`, `MODULE_VERSIONS`) und `.claude/docs/PROJECT_STATUS.md`. -Kurz (Stand 2026-04-27): App **0.7.9**, DB-Schema-Version **20260427030**; Kern-Features: Übungen mit Varianten, Medien, Trainingsplanung mit optionaler Variantenwahl. +Kurz (Stand 2026-05-05): App **0.8.10**, DB‑Schema‑Version **`20260505037`**; Kern: Übungen, Varianten, Medien, Planung mit Sektionen, **Trainingsrahmen Bibliothek + Slot‑Blueprint** (036–037), Progressionsgraph, Reifegrad/Matrix‑Stack — Details `PROJECT_STATUS.md` und `TRAINING_FRAMEWORK_SPEC.md` §2. ### Log (Auszug) +- 2026-05-05: Rahmen nur Bibliothek (**036**), Slot‑Ablauf = `training_units` + Sektionen (**037**), `POST /api/training-units/from-framework-slot`, keine `training_framework_slot_exercises` mehr — siehe `DATABASE_SCHEMA.md` / `FEATURES_DELIVERED_2026-Q2.md`. - 2026-04-27: Übungsvarianten API/UI, Migration 030, Listen-UX-Suche, Admin-Upload-Limits — siehe `PROJECT_STATUS.md` und `docs/library/FEATURES_DELIVERED_2026-Q2.md`. -- 2026-04-21: Repository- und Initial-Setup (Historie; Details in Git). ## Domänenmodell (MVP Core) @@ -104,12 +104,11 @@ Kurz (Stand 2026-04-27): App **0.7.9**, DB-Schema-Version **20260427030**; Kern- - `exercise_skills` - M:N Übung ↔ Fähigkeit - `exercise_media` - Medien (Bilder, Videos) -**Trainingsplanung:** -- `training_templates` - Vorlagen / Standards -- `training_sections` - Trainingsabschnitte -- `section_exercises` - Übungen in Abschnitten -- `training_units` - Konkrete Trainingseinheiten -- `training_programs` - Trainingsprogramme +**Trainingsplanung / Rahmen:** +- `training_plan_templates` + Sektionsvorlagen — Mikrovorlage pro Einheit (Migration **031**) +- `training_units` — Kalender‑Instanzen **und** Rahmen‑Slot‑Blueprints (`framework_slot_id` ab **037**) +- `training_framework_programs` + Ziele + Slots (Migration **035–036**) — Bibliotheks‑Rahmen +- Legacy: `training_templates` / `section_exercises` o. ä. — in älteren Skizzen; produktiver Pfad siehe Migrationen **006**/**031** **Governance:** - `content_change_requests` - Änderungsanfragen diff --git a/backend/main.py b/backend/main.py index 9ae6852..ad11a2e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -152,14 +152,16 @@ 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, training_framework_programs, 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) +app.include_router(training_framework_programs.router) app.include_router(catalogs.router) app.include_router(maturity_models.router) app.include_router(matrix_stack_bundle.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/migrations/034_exercise_progression_edge_variants.sql b/backend/migrations/034_exercise_progression_edge_variants.sql new file mode 100644 index 0000000..77586f9 --- /dev/null +++ b/backend/migrations/034_exercise_progression_edge_variants.sql @@ -0,0 +1,45 @@ +-- Migration 034: Progressionskanten optional mit Übungsvarianten (Knoten = Übung oder konkrete Variante). +-- UNIQUE und CHECK angepasst; Kanten innerhalb derselben Übung nur zwischen verschiedenen Varianten. + +ALTER TABLE exercise_progression_edges + ADD COLUMN IF NOT EXISTS from_exercise_variant_id INT REFERENCES exercise_variants(id) ON DELETE CASCADE, + ADD COLUMN IF NOT EXISTS to_exercise_variant_id INT REFERENCES exercise_variants(id) ON DELETE CASCADE; + +CREATE INDEX IF NOT EXISTS idx_progression_edges_from_variant ON exercise_progression_edges(from_exercise_variant_id) + WHERE from_exercise_variant_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_progression_edges_to_variant ON exercise_progression_edges(to_exercise_variant_id) + WHERE to_exercise_variant_id IS NOT NULL; + +DO $$ +BEGIN + ALTER TABLE exercise_progression_edges + DROP CONSTRAINT exercise_progression_edges_graph_id_from_exercise_id_to_exercise_id_edge_type_key; +EXCEPTION + WHEN undefined_object THEN NULL; +END $$; + +DO $$ +BEGIN + ALTER TABLE exercise_progression_edges DROP CONSTRAINT exercise_progression_edges_check; +EXCEPTION + WHEN undefined_object THEN NULL; +END $$; + +ALTER TABLE exercise_progression_edges ADD CONSTRAINT exercise_progression_edges_endpoints_distinct CHECK ( + (from_exercise_id <> to_exercise_id) + OR ( + from_exercise_variant_id IS NOT NULL + AND to_exercise_variant_id IS NOT NULL + AND from_exercise_variant_id <> to_exercise_variant_id + ) +); + +CREATE UNIQUE INDEX IF NOT EXISTS exercise_progression_edges_unique_endpoints +ON exercise_progression_edges ( + graph_id, + from_exercise_id, + COALESCE(from_exercise_variant_id, 0), + to_exercise_id, + COALESCE(to_exercise_variant_id, 0), + edge_type +); diff --git a/backend/migrations/035_training_framework_programs.sql b/backend/migrations/035_training_framework_programs.sql new file mode 100644 index 0000000..fdbb4c4 --- /dev/null +++ b/backend/migrations/035_training_framework_programs.sql @@ -0,0 +1,92 @@ +-- Migration 035: Trainingsrahmenprogramm (Rahmen‑Vorlage, CURR‑002 Stufe 2 / CURR‑009–013) +-- + CURR‑007/008: training_plan_templates.visibility (Backfill club, dann NOT NULL + Default) + +-- ── Trainings‑Mikrovorlagen: gemeinsamer Governance‑Kern (visibility) ───── +ALTER TABLE training_plan_templates + ADD COLUMN IF NOT EXISTS visibility VARCHAR(50) + CHECK (visibility IN ('private', 'club', 'official')); + +UPDATE training_plan_templates +SET visibility = 'club' +WHERE visibility IS NULL; + +ALTER TABLE training_plan_templates + ALTER COLUMN visibility SET DEFAULT 'club'; + +ALTER TABLE training_plan_templates + ALTER COLUMN visibility SET NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_training_plan_templates_visibility ON training_plan_templates(visibility); + +-- ── Rahmen‑Header ──────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS training_framework_programs ( + id SERIAL PRIMARY KEY, + title VARCHAR(200) NOT NULL, + description TEXT, + plan_mode VARCHAR(20) NOT NULL + CHECK (plan_mode IN ('concrete', 'library')), + -- Modus B (library): immer NULL · Modus A (concrete): optional gebunden an eine Trainingsgruppe + group_id INT REFERENCES training_groups(id) ON DELETE SET NULL, + planned_period_start DATE, + planned_period_end DATE, + 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(), + CHECK ( + (plan_mode = 'library' AND group_id IS NULL) + OR plan_mode = 'concrete' + ) +); + +CREATE INDEX IF NOT EXISTS idx_training_framework_programs_creator ON training_framework_programs(created_by); +CREATE INDEX IF NOT EXISTS idx_training_framework_programs_club ON training_framework_programs(club_id); +CREATE INDEX IF NOT EXISTS idx_training_framework_programs_visibility ON training_framework_programs(visibility); +CREATE INDEX IF NOT EXISTS idx_training_framework_programs_mode ON training_framework_programs(plan_mode); +CREATE INDEX IF NOT EXISTS idx_training_framework_programs_group ON training_framework_programs(group_id); + +DROP TRIGGER IF EXISTS training_framework_programs_update ON training_framework_programs; +CREATE TRIGGER training_framework_programs_update + BEFORE UPDATE ON training_framework_programs + FOR EACH ROW EXECUTE FUNCTION update_timestamp(); + +-- ── Zielliste (CURR‑011: ≥ 1 durch API beim Speichern) ───────────────────── +CREATE TABLE IF NOT EXISTS training_framework_goals ( + id SERIAL PRIMARY KEY, + framework_program_id INT NOT NULL REFERENCES training_framework_programs(id) ON DELETE CASCADE, + sort_order INT NOT NULL, + title VARCHAR(500) NOT NULL, + notes TEXT, + UNIQUE (framework_program_id, sort_order) +); + +CREATE INDEX IF NOT EXISTS idx_training_framework_goals_framework ON training_framework_goals(framework_program_id); + +-- ── Slots (Sessions im Rahmen) ────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS training_framework_slots ( + id SERIAL PRIMARY KEY, + framework_program_id INT NOT NULL REFERENCES training_framework_programs(id) ON DELETE CASCADE, + sort_order INT NOT NULL, + title VARCHAR(200), + notes TEXT, + training_unit_id INT REFERENCES training_units(id) ON DELETE SET NULL, + UNIQUE (framework_program_id, sort_order) +); + +CREATE INDEX IF NOT EXISTS idx_training_framework_slots_framework ON training_framework_slots(framework_program_id); +CREATE INDEX IF NOT EXISTS idx_training_framework_slots_unit ON training_framework_slots(training_unit_id); + +-- ── Übungen pro Slot (tragende „Stückliste“, CURR‑010) ──────────────────────── +CREATE TABLE IF NOT EXISTS training_framework_slot_exercises ( + id SERIAL PRIMARY KEY, + slot_id INT NOT NULL REFERENCES training_framework_slots(id) ON DELETE CASCADE, + exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE, + exercise_variant_id INT REFERENCES exercise_variants(id) ON DELETE SET NULL, + order_index INT NOT NULL, + UNIQUE (slot_id, order_index) +); + +CREATE INDEX IF NOT EXISTS idx_training_framework_slot_exercises_slot ON training_framework_slot_exercises(slot_id); +CREATE INDEX IF NOT EXISTS idx_training_framework_slot_exercises_exercise ON training_framework_slot_exercises(exercise_id); diff --git a/backend/migrations/036_framework_program_context_only_library.sql b/backend/migrations/036_framework_program_context_only_library.sql new file mode 100644 index 0000000..5a2b257 --- /dev/null +++ b/backend/migrations/036_framework_program_context_only_library.sql @@ -0,0 +1,64 @@ +-- Migration 036: Rahmenprogramm — nur Bibliothek + Kontext-Stammdaten (Fokus, Stil, Typen, Zielgruppen) +-- Grund: Zuordnung zu Gruppen/Kalender nur aus der Planung (Kopie + Lineage), nicht am Rahmenkopf. + +-- ── Kontext am Rahmenkopf (Zuordenbarkeit / Filter) ───────────────────────── +ALTER TABLE training_framework_programs + ADD COLUMN IF NOT EXISTS focus_area_id INT REFERENCES focus_areas(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS style_direction_id INT REFERENCES style_directions(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_training_framework_programs_focus ON training_framework_programs(focus_area_id); +CREATE INDEX IF NOT EXISTS idx_training_framework_programs_style ON training_framework_programs(style_direction_id); + +-- ── M:N Trainingsstile (training_types Katalog) ───────────────────────────── +CREATE TABLE IF NOT EXISTS training_framework_program_training_types ( + framework_program_id INT NOT NULL REFERENCES training_framework_programs(id) ON DELETE CASCADE, + training_type_id INT NOT NULL REFERENCES training_types(id) ON DELETE CASCADE, + PRIMARY KEY (framework_program_id, training_type_id) +); + +CREATE INDEX IF NOT EXISTS idx_tfptt_type ON training_framework_program_training_types(training_type_id); + +-- ── M:N Zielgruppen ───────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS training_framework_program_target_groups ( + framework_program_id INT NOT NULL REFERENCES training_framework_programs(id) ON DELETE CASCADE, + target_group_id INT NOT NULL REFERENCES target_groups(id) ON DELETE CASCADE, + PRIMARY KEY (framework_program_id, target_group_id) +); + +CREATE INDEX IF NOT EXISTS idx_tfptg_tg ON training_framework_program_target_groups(target_group_id); + +-- ── Kein „Konkret“ mehr: Slots nicht an Kalender-Einheiten hängen ─────────── +UPDATE training_framework_slots SET training_unit_id = NULL WHERE training_unit_id IS NOT NULL; + +-- Gruppe/Modus vom Rahmen lösen (Historie: evtl. noch concrete + group_id gesetzt) +UPDATE training_framework_programs SET group_id = NULL; + +DROP INDEX IF EXISTS idx_training_framework_programs_group; + +ALTER TABLE training_framework_programs DROP CONSTRAINT IF EXISTS training_framework_programs_group_id_fkey; + +ALTER TABLE training_framework_programs DROP COLUMN IF EXISTS group_id; + +-- Inline-CHECK(s) aus Migration 035 (plan_mode + group-Kombination) entfernen +DO $$ +DECLARE + r RECORD; +BEGIN + FOR r IN ( + SELECT c.conname AS cn + FROM pg_constraint c + WHERE c.conrelid = 'public.training_framework_programs'::regclass + AND c.contype = 'c' + AND ( + pg_get_constraintdef(c.oid) ILIKE '%plan_mode%' + OR pg_get_constraintdef(c.oid) ILIKE '%group_id%' + ) + ) + LOOP + EXECUTE format('ALTER TABLE training_framework_programs DROP CONSTRAINT %I', r.cn); + END LOOP; +END $$; + +ALTER TABLE training_framework_programs DROP COLUMN IF EXISTS plan_mode; + +DROP INDEX IF EXISTS idx_training_framework_programs_mode; diff --git a/backend/migrations/037_training_framework_blueprint_units.sql b/backend/migrations/037_training_framework_blueprint_units.sql new file mode 100644 index 0000000..25953f6 --- /dev/null +++ b/backend/migrations/037_training_framework_blueprint_units.sql @@ -0,0 +1,130 @@ +-- Migration 037: Rahmen-Slot-„Blueprint“ = eine training_units-Zeile (Ablauf wie echte Einheit) +-- training_framework_slot_exercises migriert nach training_unit_sections / training_unit_section_items, +-- dann entfernt. + +-- ── Neue Spalten ─────────────────────────────────────────────────────────────── +ALTER TABLE training_units + ADD COLUMN IF NOT EXISTS framework_slot_id INT REFERENCES training_framework_slots(id) + ON DELETE CASCADE, + ADD COLUMN IF NOT EXISTS origin_framework_slot_id INT REFERENCES training_framework_slots(id) + ON DELETE SET NULL; + +-- Genau eine Blueprint-Einheit pro Slot (PostgreSQL UNIQUE erlaubt mehrere NULLs — hier Partial Index) +DROP INDEX IF EXISTS uq_training_units_blueprint_slot; + +CREATE UNIQUE INDEX uq_training_units_blueprint_slot + ON training_units(framework_slot_id) + WHERE framework_slot_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_training_units_framework_blueprint_calendar + ON training_units(planned_date, group_id) + WHERE framework_slot_id IS NULL; + +CREATE INDEX IF NOT EXISTS idx_training_units_origin_slot + ON training_units(origin_framework_slot_id) + WHERE origin_framework_slot_id IS NOT NULL; + +-- ── Nullable für Blueprint-Zeilen ──────────────────────────────────────────── +ALTER TABLE training_units ALTER COLUMN planned_date DROP NOT NULL; +ALTER TABLE training_units ALTER COLUMN group_id DROP NOT NULL; + +-- ── Für jeden Slot eine Blueprint-Einheit; vorhandene Übungen in erste Sektion ─ +DO $$ +DECLARE + rec RECORD; + new_uid INTEGER; + new_sec INTEGER; +BEGIN + FOR rec IN + SELECT s.id AS sid, fp.created_by AS fp_created_by + FROM training_framework_slots s + JOIN training_framework_programs fp ON fp.id = s.framework_program_id + LOOP + IF EXISTS (SELECT 1 FROM training_units tu WHERE tu.framework_slot_id = rec.sid) THEN + CONTINUE; + END IF; + + INSERT INTO training_units ( + group_id, + planned_date, + planned_time_start, + planned_time_end, + planned_focus, + status, + notes, + trainer_notes, + created_by, + plan_template_id, + framework_slot_id + ) + VALUES ( + NULL, + NULL, + NULL, + NULL, + NULL, + 'planned', + NULL, + NULL, + rec.fp_created_by, + NULL, + rec.sid + ) + RETURNING id INTO new_uid; + + INSERT INTO training_unit_sections ( + training_unit_id, + order_index, + title, + guidance_notes + ) + VALUES (new_uid, 0, 'Ablauf', NULL) + RETURNING id INTO new_sec; + + INSERT INTO training_unit_section_items ( + section_id, + order_index, + item_type, + exercise_id, + exercise_variant_id, + planned_duration_min, + actual_duration_min, + notes, + modifications, + note_body + ) + SELECT + new_sec, + sf.order_index, + 'exercise'::character varying(20), + sf.exercise_id, + sf.exercise_variant_id, + NULL::integer, + NULL::integer, + NULL::text, + NULL::text, + NULL::text + FROM training_framework_slot_exercises sf + WHERE sf.slot_id = rec.sid + ORDER BY sf.order_index; + END LOOP; +END $$; + +DROP TABLE IF EXISTS training_framework_slot_exercises; + +ALTER TABLE training_units DROP CONSTRAINT IF EXISTS chk_training_units_blueprint_vs_scheduled; + +ALTER TABLE training_units + ADD CONSTRAINT chk_training_units_blueprint_vs_scheduled CHECK ( + ( + framework_slot_id IS NOT NULL + AND group_id IS NULL + AND planned_date IS NULL + AND origin_framework_slot_id IS NULL + ) + OR ( + framework_slot_id IS NULL + AND group_id IS NOT NULL + AND planned_date IS NOT NULL + ) + ); diff --git a/backend/migrations/038_training_unit_lead_trainer.sql b/backend/migrations/038_training_unit_lead_trainer.sql new file mode 100644 index 0000000..1f76c2d --- /dev/null +++ b/backend/migrations/038_training_unit_lead_trainer.sql @@ -0,0 +1,11 @@ +-- Migration 038: Optionale verantwortliche Person pro Trainingstermin (Vertretung) +-- Für Vereins-/Trainerübersicht: COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) = wirksamer Leitungstrainer. + +ALTER TABLE training_units +ADD COLUMN IF NOT EXISTS lead_trainer_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL; + +COMMENT ON COLUMN training_units.lead_trainer_profile_id IS 'Vertretung / expliziter Leiter dieses Terms; NULL = Standard (Haupttrainer der Gruppe)'; + +CREATE INDEX IF NOT EXISTS idx_training_units_lead_trainer + ON training_units(lead_trainer_profile_id) + WHERE lead_trainer_profile_id IS NOT NULL; diff --git a/backend/routers/exercise_progression_graphs.py b/backend/routers/exercise_progression_graphs.py new file mode 100644 index 0000000..13326aa --- /dev/null +++ b/backend/routers/exercise_progression_graphs.py @@ -0,0 +1,507 @@ +""" +Progressionsgraph zwischen Übungen (Übung → Übung), Migration 032–034. +Optional Übungsvarianten als Knoten-Endpunkte; Sequenz-Bulk-Anlage. +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, model_validator +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) + from_exercise_variant_id: Optional[int] = Field(default=None) + to_exercise_variant_id: Optional[int] = Field(default=None) + 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) + + +class SequenceStep(BaseModel): + exercise_id: int = Field(..., gt=0) + variant_id: Optional[int] = Field(default=None) + + +class ProgressionSequenceCreate(BaseModel): + steps: List[SequenceStep] = Field(..., min_length=2) + segment_notes: Optional[List[Optional[str]]] = None + """Länge muss len(steps)-1 sein, wenn gesetzt; Notiz pro Kante Zwischen je zwei Schritten.""" + + @model_validator(mode="after") + def check_segment_notes_len(self): + if self.segment_notes is not None and len(self.segment_notes) != len(self.steps) - 1: + raise ValueError( + f"segment_notes muss genau {len(self.steps) - 1} Einträge haben (len(steps)-1)" + ) + return self + + +class EdgeIdsBatch(BaseModel): + edge_ids: List[int] = Field(..., min_length=1) + + +_EDGE_SELECT = """ + SELECT e.id, e.graph_id, + e.from_exercise_id, e.from_exercise_variant_id, + e.to_exercise_id, e.to_exercise_variant_id, + e.edge_type, e.notes, e.created_at, + ef.title AS from_exercise_title, et.title AS to_exercise_title, + vf.variant_name AS from_variant_name, vt.variant_name AS to_variant_name + 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 + LEFT JOIN exercise_variants vf ON vf.id = e.from_exercise_variant_id + LEFT JOIN exercise_variants vt ON vt.id = e.to_exercise_variant_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)}", + ) + + +def _assert_variant_for_exercise(cur, exercise_id: int, variant_id: Optional[int]) -> None: + if variant_id is None: + return + cur.execute( + "SELECT exercise_id FROM exercise_variants WHERE id = %s", + (variant_id,), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=400, detail=f"Unbekannte Variante: {variant_id}") + ev_ex = row["exercise_id"] if isinstance(row, dict) else row[0] + if ev_ex != exercise_id: + raise HTTPException(status_code=400, detail="Variante gehört nicht zur gewählten Übung") + + +def _insert_edge_row( + cur, + graph_id: int, + from_exercise_id: int, + from_variant_id: Optional[int], + to_exercise_id: int, + to_variant_id: Optional[int], + edge_type: str, + notes: Optional[str], +) -> dict: + cur.execute( + """ + INSERT INTO exercise_progression_edges ( + graph_id, + from_exercise_id, from_exercise_variant_id, + to_exercise_id, to_exercise_variant_id, + edge_type, notes + ) + VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, + ( + graph_id, + from_exercise_id, + from_variant_id, + to_exercise_id, + to_variant_id, + edge_type, + notes, + ), + ) + new_id = cur.fetchone()["id"] + cur.execute(_EDGE_SELECT + " WHERE e.id = %s", (new_id,)) + return r2d(cur.fetchone()) + + +@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") + + 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) + fv = body.from_exercise_variant_id + tv = body.to_exercise_variant_id + _assert_variant_for_exercise(cur, body.from_exercise_id, fv) + _assert_variant_for_exercise(cur, body.to_exercise_id, tv) + et = (body.edge_type or "next_exercise").strip() or "next_exercise" + notes = (body.notes or "").strip() or None + try: + row = _insert_edge_row( + cur, + graph_id, + body.from_exercise_id, + fv, + body.to_exercise_id, + tv, + et, + notes, + ) + conn.commit() + except IntegrityError as e: + conn.rollback() + raise HTTPException( + status_code=409, + detail="Kante existiert bereits oder Endpunkte unzulässig (gleiche Übung ohne zwei Varianten)", + ) from e + + return row + + +@router.post("/exercise-progression-graphs/{graph_id}/edges/sequence", status_code=201) +def create_progression_sequence( + graph_id: int, + body: ProgressionSequenceCreate, + session: dict = Depends(require_auth), +): + """Legt n−1 Nachfolger-Kanten (next_exercise) für eine geordnete Schrittliste an.""" + profile_id = session["profile_id"] + role = session.get("role") + steps = body.steps + n_seg = len(steps) - 1 + seg_notes = body.segment_notes + + created: List[dict] = [] + with get_db() as conn: + cur = get_cursor(conn) + _graph_access(cur, graph_id, profile_id, role) + + ex_ids = [s.exercise_id for s in steps] + _assert_exercises_exist(cur, *ex_ids) + + try: + for i in range(n_seg): + a, b = steps[i], steps[i + 1] + _assert_variant_for_exercise(cur, a.exercise_id, a.variant_id) + _assert_variant_for_exercise(cur, b.exercise_id, b.variant_id) + note = None + if seg_notes is not None: + raw = seg_notes[i] + note = (raw or "").strip() or None if raw is not None else None + row = _insert_edge_row( + cur, + graph_id, + a.exercise_id, + a.variant_id, + b.exercise_id, + b.variant_id, + "next_exercise", + note, + ) + created.append(row) + conn.commit() + except IntegrityError as e: + conn.rollback() + raise HTTPException( + status_code=409, + detail="Sequenz konnte nicht vollständig angelegt werden (Duplikat oder ungültige Endpunkte). Keine Teilmenge gespeichert.", + ) from e + + return {"created": created, "count": len(created)} + + +@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} + + +@router.post("/exercise-progression-graphs/{graph_id}/edges/delete-batch") +def delete_progression_edges_batch( + graph_id: int, + body: EdgeIdsBatch, + session: dict = Depends(require_auth), +): + """Löscht mehrere Kanten (z. B. eine zusammenhängende Kette in einem Schritt).""" + profile_id = session["profile_id"] + role = session.get("role") + ids = body.edge_ids + clean_ids = list(dict.fromkeys(ids)) + + with get_db() as conn: + cur = get_cursor(conn) + _graph_access(cur, graph_id, profile_id, role) + cur.execute( + f""" + DELETE FROM exercise_progression_edges + WHERE graph_id = %s AND id IN ({",".join(["%s"] * len(clean_ids))}) + """, + (graph_id, *clean_ids), + ) + deleted = cur.rowcount + conn.commit() + return {"ok": True, "deleted": deleted} diff --git a/backend/routers/training_framework_programs.py b/backend/routers/training_framework_programs.py new file mode 100644 index 0000000..459f7f1 --- /dev/null +++ b/backend/routers/training_framework_programs.py @@ -0,0 +1,507 @@ +""" +Trainingsrahmenprogramm — wiederverwendbare Vorlage (Bibliothek) über mehrere Session-Slots. + +Zuordnung zu Trainingsgruppen / konkreten Einheiten erfolgt aus der Planung (Kopie + Lineage), +nicht über group_id oder training_unit_id am Rahmen. +AuthZ wie Planungs-Vorlagen: Admin sieht alle, sonst nur eigene Artefakte; Schreibzugriff mit Planungsrolle. +""" +from typing import Any, Dict, List, Optional, Sequence + +from fastapi import APIRouter, Depends, HTTPException + +from auth import require_auth +from db import get_db, get_cursor, r2d + +from routers.training_planning import ( + _has_planning_role, + _hydrate_training_unit_payload, + _optional_positive_int, + _insert_sections_from_legacy_exercises, + _replace_unit_sections, + _validate_variant_for_exercise, +) + +router = APIRouter(prefix="/api", tags=["training_framework_programs"]) +_VALID_VISIBILITY = frozenset({"private", "club", "official"}) + + +def _framework_access(cur, framework_id: int, profile_id: int, role: str) -> Dict[str, Any]: + cur.execute("SELECT * FROM training_framework_programs WHERE id = %s", (framework_id,)) + r = cur.fetchone() + if not r: + raise HTTPException(status_code=404, detail="Trainingsrahmen 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 Trainingsrahmen") + return row + + +def _training_type_ids(cur, framework_id: int) -> List[int]: + cur.execute( + """ + SELECT training_type_id + FROM training_framework_program_training_types + WHERE framework_program_id = %s + ORDER BY training_type_id + """, + (framework_id,), + ) + return [r["training_type_id"] for r in cur.fetchall()] + + +def _target_group_ids(cur, framework_id: int) -> List[int]: + cur.execute( + """ + SELECT target_group_id + FROM training_framework_program_target_groups + WHERE framework_program_id = %s + ORDER BY target_group_id + """, + (framework_id,), + ) + return [r["target_group_id"] for r in cur.fetchall()] + + +def _hydrate_framework(cur, row: Dict[str, Any]) -> Dict[str, Any]: + fid = row["id"] + cur.execute( + """ + SELECT id, framework_program_id, sort_order, title, notes + FROM training_framework_goals + WHERE framework_program_id = %s + ORDER BY sort_order + """, + (fid,), + ) + row["goals"] = [r2d(g) for g in cur.fetchall()] + cur.execute( + """ + SELECT id, framework_program_id, sort_order, title, notes + FROM training_framework_slots + WHERE framework_program_id = %s + ORDER BY sort_order + """, + (fid,), + ) + slots = [r2d(s) for s in cur.fetchall()] + for s in slots: + cur.execute( + "SELECT id FROM training_units WHERE framework_slot_id = %s", + (s["id"],), + ) + row_b = cur.fetchone() + if not row_b: + s["blueprint_training_unit_id"] = None + s["sections"] = [] + s["exercises"] = [] + continue + uid = row_b["id"] + s["blueprint_training_unit_id"] = uid + unit_min: Dict[str, Any] = {"id": uid} + _hydrate_training_unit_payload(cur, unit_min) + s["sections"] = unit_min.get("sections", []) + s["exercises"] = unit_min.get("exercises", []) + row["slots"] = slots + row["training_type_ids"] = _training_type_ids(cur, fid) + row["target_group_ids"] = _target_group_ids(cur, fid) + return row + + +def _assert_visibility(val: Optional[str]) -> Optional[str]: + if val is None: + return None + if val not in _VALID_VISIBILITY: + raise HTTPException( + status_code=400, + detail="visibility muss private, club oder official sein", + ) + return val + + +def _parse_positive_int_ids(raw: Any, label: str) -> List[int]: + if raw is None: + return [] + if not isinstance(raw, list): + raise HTTPException(status_code=400, detail=f"{label} muss eine Liste von IDs sein") + out: List[int] = [] + for item in raw: + if item in (None, ""): + continue + try: + n = int(item) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail=f"{label}: ungültige ID") from None + if n <= 0: + raise HTTPException(status_code=400, detail=f"{label}: ungültige ID") + if n not in out: + out.append(n) + return out + + +def _replace_training_types(cur, framework_id: int, ids: Sequence[int]) -> None: + cur.execute( + "DELETE FROM training_framework_program_training_types WHERE framework_program_id = %s", + (framework_id,), + ) + for tid in ids: + cur.execute( + """ + INSERT INTO training_framework_program_training_types (framework_program_id, training_type_id) + VALUES (%s, %s) + ON CONFLICT DO NOTHING + """, + (framework_id, tid), + ) + + +def _replace_target_groups(cur, framework_id: int, ids: Sequence[int]) -> None: + cur.execute( + "DELETE FROM training_framework_program_target_groups WHERE framework_program_id = %s", + (framework_id,), + ) + for gid in ids: + cur.execute( + """ + INSERT INTO training_framework_program_target_groups (framework_program_id, target_group_id) + VALUES (%s, %s) + ON CONFLICT DO NOTHING + """, + (framework_id, gid), + ) + + +def _insert_goal_rows(cur, framework_id: int, goals_in: List[Any]) -> None: + if not goals_in: + raise HTTPException(status_code=400, detail="Mindestens ein Entwicklungsziel (goals) ist erforderlich") + for gi, g in enumerate(goals_in): + title_g = (g.get("title") or "").strip() + if not title_g: + raise HTTPException(status_code=400, detail="Jedes Ziel braucht ein nicht-leeres title") + order_ix = g.get("sort_order") + if order_ix is None: + order_ix = gi + cur.execute( + """ + INSERT INTO training_framework_goals ( + framework_program_id, sort_order, title, notes + ) VALUES (%s, %s, %s, %s) + """, + (framework_id, int(order_ix), title_g[:500], g.get("notes")), + ) + + +def _insert_default_blueprint_section(cur, blueprint_unit_id: int) -> None: + """Leerer Ablauf, falls noch keine Sektionen existieren.""" + cur.execute( + "SELECT 1 FROM training_unit_sections WHERE training_unit_id = %s", + (blueprint_unit_id,), + ) + if cur.fetchone(): + return + _replace_unit_sections( + cur, + blueprint_unit_id, + [{"title": "Ablauf", "order_index": 0, "guidance_notes": None, "items": []}], + ) + + +def _insert_slots_and_blueprints( + cur, + framework_id: int, + slots_in: Optional[List[Any]], + profile_id: int, +) -> None: + if slots_in is None: + return + for si, slot in enumerate(slots_in): + order_ix = slot.get("sort_order") + if order_ix is None: + order_ix = si + title_s = slot.get("title") + if title_s is not None: + title_s = title_s.strip() or None + + cur.execute( + """ + INSERT INTO training_framework_slots ( + framework_program_id, sort_order, title, notes, training_unit_id + ) VALUES (%s, %s, %s, %s, NULL) + RETURNING id + """, + ( + framework_id, + int(order_ix), + title_s, + slot.get("notes"), + ), + ) + sid = cur.fetchone()["id"] + + cur.execute( + """ + INSERT INTO training_units ( + group_id, planned_date, + planned_time_start, planned_time_end, planned_focus, + status, notes, trainer_notes, + created_by, plan_template_id, framework_slot_id + ) VALUES ( + NULL, NULL, + NULL, NULL, NULL, + 'planned', NULL, NULL, + %s, NULL, %s + ) + RETURNING id + """, + (profile_id, sid), + ) + bid = cur.fetchone()["id"] + + sections_in = slot.get("sections") + exercises_in = slot.get("exercises") + + if sections_in is not None: + if len(sections_in) == 0: + _insert_default_blueprint_section(cur, bid) + else: + _replace_unit_sections(cur, bid, sections_in) + elif exercises_in is not None and len(exercises_in) > 0: + for raw in exercises_in: + eid = raw.get("exercise_id") + if not eid: + continue + vid = _optional_positive_int(raw.get("exercise_variant_id"), "exercise_variant_id") + _validate_variant_for_exercise(cur, int(eid), vid) + _insert_sections_from_legacy_exercises(cur, bid, exercises_in) + else: + _insert_default_blueprint_section(cur, bid) + + +@router.get("/training-framework-programs") +def list_training_framework_programs(session=Depends(require_auth)): + profile_id = session["profile_id"] + role = session.get("role") + with get_db() as conn: + cur = get_cursor(conn) + base_sel = """ + SELECT fp.*, + fa.name AS focus_area_name, + sd.name AS style_direction_name, + (SELECT COUNT(*)::int FROM training_framework_goals g WHERE g.framework_program_id = fp.id) + AS goals_count, + (SELECT COUNT(*)::int FROM training_framework_slots s WHERE s.framework_program_id = fp.id) + AS slots_count, + (SELECT COUNT(*)::int FROM training_framework_program_training_types t + WHERE t.framework_program_id = fp.id) AS training_types_count, + (SELECT COUNT(*)::int FROM training_framework_program_target_groups tg + WHERE tg.framework_program_id = fp.id) AS target_groups_count, + ( + SELECT STRING_AGG(typ.name::text, ', ' ORDER BY typ.sort_order NULLS LAST, typ.name) + FROM training_framework_program_training_types j + JOIN training_types typ ON typ.id = j.training_type_id + WHERE j.framework_program_id = fp.id + ) AS training_type_names_agg, + ( + SELECT STRING_AGG(tg.name::text, ', ' ORDER BY tg.name) + FROM training_framework_program_target_groups j + JOIN target_groups tg ON tg.id = j.target_group_id + WHERE j.framework_program_id = fp.id + ) AS target_group_names_agg + FROM training_framework_programs fp + LEFT JOIN focus_areas fa ON fa.id = fp.focus_area_id + LEFT JOIN style_directions sd ON sd.id = fp.style_direction_id + """ + if role in ("admin", "superadmin"): + cur.execute(base_sel + " ORDER BY fp.updated_at DESC NULLS LAST, fp.title") + else: + cur.execute( + base_sel + " WHERE fp.created_by = %s ORDER BY fp.updated_at DESC NULLS LAST, fp.title", + (profile_id,), + ) + return [r2d(r) for r in cur.fetchall()] + + +@router.get("/training-framework-programs/{framework_id}") +def get_training_framework_program(framework_id: int, session=Depends(require_auth)): + profile_id = session["profile_id"] + role = session.get("role") + with get_db() as conn: + cur = get_cursor(conn) + row = _framework_access(cur, framework_id, profile_id, role) + return _hydrate_framework(cur, row) + + +@router.post("/training-framework-programs") +def create_training_framework_program(data: dict, session=Depends(require_auth)): + profile_id = session["profile_id"] + role = session.get("role") + if not _has_planning_role(role): + raise HTTPException(status_code=403, detail="Nur Planungsberechtigte dürfen Rahmenprogramme anlegen") + + title = (data.get("title") or "").strip() + if not title: + raise HTTPException(status_code=400, detail="title ist Pflicht") + + vis = data.get("visibility") or "private" + vis = _assert_visibility(vis) + club_id = data.get("club_id") + goals_in = data.get("goals") + slots_in = data.get("slots") + if not isinstance(goals_in, list) or not goals_in: + raise HTTPException(status_code=400, detail="goals als Liste mit mindestens einem Eintrag ist Pflicht") + + fa_id = _optional_positive_int(data.get("focus_area_id"), "focus_area_id") + sd_id = _optional_positive_int(data.get("style_direction_id"), "style_direction_id") + tt_ids = _parse_positive_int_ids(data.get("training_type_ids"), "training_type_ids") + tg_ids = _parse_positive_int_ids(data.get("target_group_ids"), "target_group_ids") + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + INSERT INTO training_framework_programs ( + title, description, + planned_period_start, planned_period_end, + visibility, club_id, created_by, + focus_area_id, style_direction_id + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, + ( + title[:200], + data.get("description"), + data.get("planned_period_start"), + data.get("planned_period_end"), + vis, + club_id, + profile_id, + fa_id, + sd_id, + ), + ) + fid = cur.fetchone()["id"] + _insert_goal_rows(cur, fid, goals_in) + _insert_slots_and_blueprints(cur, fid, slots_in, profile_id) + _replace_training_types(cur, fid, tt_ids) + _replace_target_groups(cur, fid, tg_ids) + conn.commit() + + return get_training_framework_program(fid, session) + + +@router.put("/training-framework-programs/{framework_id}") +def update_training_framework_program(framework_id: int, data: dict, session=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") + + with get_db() as conn: + cur = get_cursor(conn) + _framework_access(cur, framework_id, profile_id, role) + + header_fields = [] + header_params: List[Any] = [] + + if "title" in data: + tit = (data.get("title") or "").strip() + if not tit: + raise HTTPException(status_code=400, detail="title ist Pflicht") + header_fields.append("title = %s") + header_params.append(tit[:200]) + + if "description" in data: + header_fields.append("description = %s") + header_params.append(data.get("description")) + if "planned_period_start" in data: + header_fields.append("planned_period_start = %s") + header_params.append(data.get("planned_period_start")) + if "planned_period_end" in data: + header_fields.append("planned_period_end = %s") + header_params.append(data.get("planned_period_end")) + + if "visibility" in data: + v = _assert_visibility(data.get("visibility")) + if v is None: + raise HTTPException(status_code=400, detail="visibility fehlt") + header_fields.append("visibility = %s") + header_params.append(v) + if "club_id" in data: + header_fields.append("club_id = %s") + header_params.append(data.get("club_id")) + + if "focus_area_id" in data: + fidv = data.get("focus_area_id") + header_fields.append("focus_area_id = %s") + header_params.append( + None if fidv in (None, "") else _optional_positive_int(fidv, "focus_area_id") + ) + if "style_direction_id" in data: + sidv = data.get("style_direction_id") + header_fields.append("style_direction_id = %s") + header_params.append( + None if sidv in (None, "") else _optional_positive_int(sidv, "style_direction_id") + ) + + if header_fields: + header_fields.append("updated_at = NOW()") + header_params.append(framework_id) + cur.execute( + f""" + UPDATE training_framework_programs + SET {", ".join(header_fields)} + WHERE id = %s + """, + tuple(header_params), + ) + + if "training_type_ids" in data: + tt_ids = _parse_positive_int_ids(data.get("training_type_ids"), "training_type_ids") + _replace_training_types(cur, framework_id, tt_ids) + + if "target_group_ids" in data: + tg_ids = _parse_positive_int_ids(data.get("target_group_ids"), "target_group_ids") + _replace_target_groups(cur, framework_id, tg_ids) + + if "goals" in data: + goals_in = data["goals"] + if not isinstance(goals_in, list) or not goals_in: + raise HTTPException(status_code=400, detail="goals als Liste mit mindestens einem Eintrag ist Pflicht") + cur.execute( + "DELETE FROM training_framework_goals WHERE framework_program_id = %s", + (framework_id,), + ) + _insert_goal_rows(cur, framework_id, goals_in) + + if "slots" in data: + cur.execute( + "DELETE FROM training_framework_slots WHERE framework_program_id = %s", + (framework_id,), + ) + _insert_slots_and_blueprints(cur, framework_id, data.get("slots") or [], profile_id) + + if header_fields or "goals" in data or "slots" in data or "training_type_ids" in data or "target_group_ids" in data: + cur.execute( + "UPDATE training_framework_programs SET updated_at = NOW() WHERE id = %s", + (framework_id,), + ) + + conn.commit() + + return get_training_framework_program(framework_id, session) + + +@router.delete("/training-framework-programs/{framework_id}") +def delete_training_framework_program(framework_id: int, session=Depends(require_auth)): + profile_id = session["profile_id"] + role = session.get("role") + with get_db() as conn: + cur = get_cursor(conn) + _framework_access(cur, framework_id, profile_id, role) + cur.execute( + "DELETE FROM training_framework_programs WHERE id = %s", + (framework_id,), + ) + conn.commit() + return {"ok": True} diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index 6208193..2cd7f03 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -67,10 +67,14 @@ def _can_access_group_for_create(cur, group_id: int, profile_id: int, role: str) def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]: cur.execute( """ - SELECT tu.id, tu.created_by, tu.group_id, tu.plan_template_id, - tg.trainer_id, tg.co_trainer_ids + SELECT tu.id, tu.created_by, tu.group_id, tu.plan_template_id, tu.framework_slot_id, + tu.lead_trainer_profile_id, + tg.trainer_id, tg.co_trainer_ids, + fwp.created_by AS framework_created_by FROM training_units tu LEFT JOIN training_groups tg ON tu.group_id = tg.id + LEFT JOIN training_framework_slots fs ON fs.id = tu.framework_slot_id + LEFT JOIN training_framework_programs fwp ON fwp.id = fs.framework_program_id WHERE tu.id = %s """, (unit_id,), @@ -84,12 +88,23 @@ def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]: def _assert_training_unit_permission( cur, unit_row: Dict[str, Any], profile_id: int, role: str ) -> None: + if unit_row.get("framework_slot_id"): + if role in ["admin", "superadmin"]: + return + if unit_row.get("created_by") == profile_id: + return + fw_by = unit_row.get("framework_created_by") + if fw_by is not None and fw_by == profile_id: + return + raise HTTPException(status_code=403, detail="Keine Berechtigung") + co_trainers = unit_row["co_trainer_ids"] or [] if role not in ["admin", "superadmin"]: if ( unit_row["created_by"] != profile_id and unit_row["trainer_id"] != profile_id and profile_id not in co_trainers + and unit_row.get("lead_trainer_profile_id") != profile_id ): raise HTTPException(status_code=403, detail="Keine Berechtigung") @@ -99,6 +114,82 @@ def _assert_delete_training_unit(role: str, created_by: int, profile_id: int) -> raise HTTPException(status_code=403, detail="Keine Berechtigung") +def _assert_club_visible_for_trainer(cur, club_id: int, profile_id: int, role: str) -> None: + """Nicht-Admin: mindestens eine aktive Gruppe im Verein als Trainer/Co-Trainer.""" + if role in ("admin", "superadmin"): + return + cur.execute( + """ + SELECT 1 FROM training_groups g + WHERE g.club_id = %s AND g.status = 'active' + AND ( + g.trainer_id = %s + OR (g.co_trainer_ids IS NOT NULL AND g.co_trainer_ids @> jsonb_build_array(%s::int)) + ) + LIMIT 1 + """, + (club_id, profile_id, profile_id), + ) + if not cur.fetchone(): + raise HTTPException(status_code=403, detail="Kein Zugriff auf diesen Verein") + + +def _normalize_lead_trainer_profile_id( + cur, + group_id: int, + raw_lead: Any, + profile_id: int, + role: str, +) -> Optional[int]: + """NULL = Vertretung aufheben; sonst Profil-ID mit Profil-Check und Gruppenkontext.""" + if raw_lead is None: + return None + if raw_lead in ("", []): + return None + try: + nid = int(raw_lead) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="lead_trainer_profile_id ungültig") + if nid < 1: + raise HTTPException(status_code=400, detail="lead_trainer_profile_id ungültig") + cur.execute("SELECT 1 FROM profiles WHERE id = %s", (nid,)) + if not cur.fetchone(): + raise HTTPException(status_code=400, detail="Profil für Leitung nicht gefunden") + if role in ("admin", "superadmin"): + return nid + if nid == profile_id: + return nid + cur.execute( + "SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s", + (group_id,), + ) + gr = cur.fetchone() + if not gr: + raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden") + eligible = {gr["trainer_id"]} if gr.get("trainer_id") else set() + for x in gr.get("co_trainer_ids") or []: + eligible.add(x) + if nid in eligible: + return nid + raise HTTPException( + status_code=403, + detail="Lead-Trainer kann nur eigene Person, Haupttrainer oder Co-Trainer der Gruppe sein", + ) + + +# Nachverfolgbarkeit: Übernahmen aus Rahmenprogramm über origin_framework_slot_id +_ORIGIN_LINEAGE_JOIN = """ + LEFT JOIN training_framework_slots origin_slot ON origin_slot.id = tu.origin_framework_slot_id + LEFT JOIN training_framework_programs origin_fp ON origin_fp.id = origin_slot.framework_program_id +""" +_ORIGIN_LINEAGE_FIELDS = """ + origin_fp.id AS origin_framework_program_id, + origin_fp.title AS origin_framework_program_title, + COALESCE(TRIM(origin_slot.title), '') AS origin_framework_slot_title, + origin_slot.sort_order AS origin_framework_slot_sort_order +""" + + def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]: cur.execute( """ @@ -138,6 +229,116 @@ def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]: return secs +def _sections_clone_payload(cur, unit_id: int) -> List[Dict[str, Any]]: + """Sektionen/Items für eine tiefe Kopie (ohne DB-IDs / Join-Felder).""" + secs = _fetch_sections(cur, unit_id) + out: List[Dict[str, Any]] = [] + for sec in secs: + items_clean: List[Dict[str, Any]] = [] + for it in sorted(sec.get("items", []), key=lambda x: x.get("order_index", 0)): + itype = it.get("item_type") or ("exercise" if it.get("exercise_id") else "note") + oix = it.get("order_index") + if itype == "note": + items_clean.append( + { + "item_type": "note", + "order_index": oix, + "note_body": it.get("note_body") or "", + } + ) + continue + if itype != "exercise" or not it.get("exercise_id"): + continue + items_clean.append( + { + "item_type": "exercise", + "order_index": oix, + "exercise_id": it["exercise_id"], + "exercise_variant_id": it.get("exercise_variant_id"), + "planned_duration_min": it.get("planned_duration_min"), + "actual_duration_min": it.get("actual_duration_min"), + "notes": it.get("notes"), + "modifications": it.get("modifications"), + } + ) + out.append( + { + "title": sec.get("title"), + "order_index": sec.get("order_index"), + "guidance_notes": sec.get("guidance_notes"), + "items": items_clean, + } + ) + return out + + +def _copy_blueprint_into_scheduled_unit( + cur, + blueprint_unit_id: int, + group_id: int, + planned_date: str, + profile_id: int, + origin_framework_slot_id: Optional[int], +) -> int: + cur.execute( + """ + INSERT INTO training_units ( + group_id, + planned_date, + planned_time_start, + planned_time_end, + planned_focus, + actual_date, + actual_time_start, + actual_time_end, + attendance_count, + status, + notes, + trainer_notes, + created_by, + plan_template_id, + origin_framework_slot_id, + framework_slot_id + ) + SELECT + %s, + %s, + planned_time_start, + planned_time_end, + planned_focus, + NULL::DATE, + NULL::TIME WITHOUT TIME ZONE, + NULL::TIME WITHOUT TIME ZONE, + NULL::INT, + COALESCE(status, 'planned'), + notes, + trainer_notes, + %s, + NULL::INT, + %s, + NULL::INT + FROM training_units + WHERE id = %s + AND framework_slot_id IS NOT NULL + RETURNING id + """, + ( + group_id, + planned_date, + profile_id, + origin_framework_slot_id, + blueprint_unit_id, + ), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Blueprint-Einheit nicht gefunden") + nu = row["id"] + cloned = _sections_clone_payload(cur, blueprint_unit_id) + _replace_unit_sections(cur, nu, cloned) + return nu + + def _flatten_exercises_from_sections(unit: Dict[str, Any]) -> None: flat: List[Dict[str, Any]] = [] for sec in sorted(unit.get("sections", []), key=lambda s: s.get("order_index", 0)): @@ -497,39 +698,99 @@ def delete_training_plan_template(template_id: int, session=Depends(require_auth @router.get("/training-units") def list_training_units( group_id: Optional[int] = Query(default=None), + club_id: Optional[int] = Query(default=None), start_date: Optional[str] = Query(default=None), end_date: Optional[str] = Query(default=None), status: Optional[str] = Query(default=None), + assigned_to_me: bool = Query(default=False), + sort: str = Query(default="desc"), + limit: Optional[int] = Query(default=None), session=Depends(require_auth), ): profile_id = session["profile_id"] role = session.get("role") + gid = _optional_positive_int(group_id, "group_id") if group_id else None + cid = _optional_positive_int(club_id, "club_id") if club_id else None + if gid and cid: + raise HTTPException(status_code=400, detail="Nur eines der Parameter group_id oder club_id angeben") + with get_db() as conn: cur = get_cursor(conn) + if cid and role not in ["admin", "superadmin"]: + _assert_club_visible_for_trainer(cur, cid, profile_id, role) + + if gid and role not in ["admin", "superadmin"]: + cur.execute( + "SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s AND status = 'active'", + (gid,), + ) + gr = cur.fetchone() + if not gr: + raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden") + cob = gr["co_trainer_ids"] or [] + if gr["trainer_id"] != profile_id and profile_id not in cob: + raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Gruppe") + + order_dir = "ASC" if (sort or "").strip().lower() == "asc" else "DESC" + lim: Optional[int] = None + if limit is not None: + try: + lim = int(limit) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="limit ungültig") + if lim < 1: + raise HTTPException(status_code=400, detail="limit ungültig") + lim = min(lim, 250) + query = """ SELECT tu.*, tg.name as group_name, tg.weekday as group_weekday, + tg.club_id AS group_club_id, c.name as club_name, - p.name as trainer_name + p.name as trainer_name, + p.name as creator_name, + COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id, + leadp.name AS lead_trainer_name + """ + query += "," + _ORIGIN_LINEAGE_FIELDS + query += """ FROM training_units tu LEFT JOIN training_groups tg ON tu.group_id = tg.id LEFT JOIN clubs c ON tg.club_id = c.id LEFT JOIN profiles p ON tu.created_by = p.id + LEFT JOIN profiles leadp ON leadp.id = COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) """ + query += _ORIGIN_LINEAGE_JOIN where = [] params = [] if role not in ["admin", "superadmin"]: - where.append("(tu.created_by = %s OR tg.trainer_id = %s)") - params.extend([profile_id, profile_id]) + where.append( + "(tu.created_by = %s OR tg.trainer_id = %s OR " + "(tg.co_trainer_ids IS NOT NULL AND tg.co_trainer_ids @> jsonb_build_array(%s::int)))" + ) + params.extend([profile_id, profile_id, profile_id]) - if group_id: + where.append("tu.framework_slot_id IS NULL") + + if gid: where.append("tu.group_id = %s") - params.append(group_id) + params.append(gid) + + if cid: + where.append("tg.club_id = %s") + params.append(cid) + + if assigned_to_me: + where.append( + "(COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) = %s OR " + "(tg.co_trainer_ids IS NOT NULL AND tg.co_trainer_ids @> jsonb_build_array(%s::int)))" + ) + params.extend([profile_id, profile_id]) if start_date: where.append("tu.planned_date >= %s") @@ -546,7 +807,10 @@ def list_training_units( if where: query += " WHERE " + " AND ".join(where) - query += " ORDER BY tu.planned_date DESC, tu.planned_time_start DESC" + query += f" ORDER BY tu.planned_date {order_dir}, tu.planned_time_start {order_dir} NULLS LAST" + if lim is not None: + query += " LIMIT %s" + params.append(lim) cur.execute(query, params) rows = cur.fetchall() @@ -570,11 +834,19 @@ def get_training_unit(unit_id: int, session=Depends(require_auth)): tg.time_end as group_time_end, tg.location as group_location, c.name as club_name, - p.name as trainer_name + p.name as trainer_name, + p.name as creator_name, + tg.trainer_id AS trainer_id, + tg.co_trainer_ids AS co_trainer_ids, + COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id, + leadp.name AS lead_trainer_name, + """ + _ORIGIN_LINEAGE_FIELDS.strip() + """ FROM training_units tu LEFT JOIN training_groups tg ON tu.group_id = tg.id LEFT JOIN clubs c ON tg.club_id = c.id LEFT JOIN profiles p ON tu.created_by = p.id + LEFT JOIN profiles leadp ON leadp.id = COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) + """ + _ORIGIN_LINEAGE_JOIN.strip() + """ WHERE tu.id = %s """, (unit_id,), @@ -586,12 +858,24 @@ def get_training_unit(unit_id: int, session=Depends(require_auth)): unit = r2d(unit) - cur.execute("SELECT trainer_id FROM training_groups WHERE id = %s", (unit["group_id"],)) - group = cur.fetchone() - - if role not in ["admin", "superadmin"]: - if unit["created_by"] != profile_id and (not group or group["trainer_id"] != profile_id): - raise HTTPException(status_code=403, detail="Keine Berechtigung") + if unit.get("framework_slot_id"): + if role not in ["admin", "superadmin"]: + cur.execute( + """ + SELECT fp.created_by FROM training_framework_slots s + JOIN training_framework_programs fp ON fp.id = s.framework_program_id + WHERE s.id = %s + """, + (unit["framework_slot_id"],), + ) + fr = cur.fetchone() + cb = fr["created_by"] if fr else None + if unit["created_by"] != profile_id and cb != profile_id: + raise HTTPException(status_code=403, detail="Keine Berechtigung") + else: + if not unit.get("group_id"): + raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden") + _assert_training_unit_permission(cur, unit, profile_id, role) _hydrate_training_unit_payload(cur, unit) return unit @@ -671,6 +955,8 @@ def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth) unit_row = _training_unit_guard_row(cur, unit_id) _assert_training_unit_permission(cur, unit_row, profile_id, role) + is_blueprint = unit_row.get("framework_slot_id") is not None + tpl_upd = data.get("plan_template_id") if "plan_template_id" in data else None tpl_id_val = None if tpl_upd not in (None, ""): @@ -690,43 +976,96 @@ def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth) else: trainer_notes_val = data.get("trainer_notes") - cur.execute( - """ - UPDATE training_units SET - planned_date = COALESCE(%s, planned_date), - planned_time_start = %s, - planned_time_end = %s, - planned_focus = %s, - actual_date = %s, - actual_time_start = %s, - actual_time_end = %s, - attendance_count = %s, - status = %s, - notes = %s, - trainer_notes = %s, - plan_template_id = COALESCE(%s, plan_template_id), - updated_at = NOW() - WHERE id = %s - """, - ( - data.get("planned_date"), - data.get("planned_time_start"), - data.get("planned_time_end"), - data.get("planned_focus"), - data.get("actual_date"), - data.get("actual_time_start"), - data.get("actual_time_end"), - data.get("attendance_count"), - data.get("status"), - data.get("notes"), - trainer_notes_val, - tpl_id_val, - unit_id, - ), - ) + if is_blueprint: + if data.get("reset_from_template"): + raise HTTPException( + status_code=400, + detail="Rahmen-Blueprints können nicht aus einer Vorlage zurückgesetzt werden", + ) + if tpl_upd not in (None, ""): + raise HTTPException( + status_code=400, + detail="plan_template_id ist bei Rahmen-Blueprints nicht zulässig", + ) + blueprint_fields = [] + blueprint_params: List[Any] = [] + if "planned_focus" in data: + blueprint_fields.append("planned_focus = %s") + blueprint_params.append(data.get("planned_focus")) + if "planned_time_start" in data: + blueprint_fields.append("planned_time_start = %s") + blueprint_params.append(data.get("planned_time_start")) + if "planned_time_end" in data: + blueprint_fields.append("planned_time_end = %s") + blueprint_params.append(data.get("planned_time_end")) + if "notes" in data: + blueprint_fields.append("notes = %s") + blueprint_params.append(data.get("notes")) + blueprint_fields.append("trainer_notes = %s") + blueprint_params.append(trainer_notes_val) + blueprint_params.append(unit_id) + cur.execute( + f""" + UPDATE training_units SET + {", ".join(blueprint_fields)}, + updated_at = NOW() + WHERE id = %s + """, + tuple(blueprint_params), + ) + else: + lead_sql = "" + lead_params: List[Any] = [] + if "lead_trainer_profile_id" in data: + nl = _normalize_lead_trainer_profile_id( + cur, + unit_row["group_id"], + data.get("lead_trainer_profile_id"), + profile_id, + role, + ) + lead_sql = ", lead_trainer_profile_id = %s" + lead_params.append(nl) + + cur.execute( + f""" + UPDATE training_units SET + planned_date = COALESCE(%s, planned_date), + planned_time_start = %s, + planned_time_end = %s, + planned_focus = %s, + actual_date = %s, + actual_time_start = %s, + actual_time_end = %s, + attendance_count = %s, + status = %s, + notes = %s, + trainer_notes = %s, + plan_template_id = COALESCE(%s, plan_template_id), + updated_at = NOW() + {lead_sql} + WHERE id = %s + """, + ( + data.get("planned_date"), + data.get("planned_time_start"), + data.get("planned_time_end"), + data.get("planned_focus"), + data.get("actual_date"), + data.get("actual_time_start"), + data.get("actual_time_end"), + data.get("attendance_count"), + data.get("status"), + data.get("notes"), + trainer_notes_val, + tpl_id_val, + ) + + tuple(lead_params) + + (unit_id,), + ) content_handled = False - if data.get("reset_from_template"): + if not is_blueprint and data.get("reset_from_template"): tid = tpl_id_val or unit_row.get("plan_template_id") if not tid: raise HTTPException( @@ -761,7 +1100,7 @@ def delete_training_unit(unit_id: int, session=Depends(require_auth)): cur = get_cursor(conn) cur.execute( - "SELECT created_by FROM training_units WHERE id = %s", + "SELECT created_by, framework_slot_id FROM training_units WHERE id = %s", (unit_id,), ) @@ -770,6 +1109,12 @@ def delete_training_unit(unit_id: int, session=Depends(require_auth)): if not unit: raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden") + if unit.get("framework_slot_id"): + raise HTTPException( + status_code=400, + detail="Blueprint-Einheiten werden über das Rahmenprogramm verwaltet, nicht hier gelöscht.", + ) + _assert_delete_training_unit(role, unit["created_by"], profile_id) cur.execute("DELETE FROM training_units WHERE id = %s", (unit_id,)) @@ -778,6 +1123,74 @@ def delete_training_unit(unit_id: int, session=Depends(require_auth)): return {"ok": True} +@router.post("/training-units/from-framework-slot") +def create_training_unit_from_framework_slot(data: dict, session=Depends(require_auth)): + """Geplante Einheit aus Rahmen-Slot-Blueprint kopieren (Lineage über origin_framework_slot_id).""" + profile_id = session["profile_id"] + role = session.get("role") + + if not _has_planning_role(role): + raise HTTPException(status_code=403, detail="Nur Planungsberechtigte dürfen Trainingseinheiten erstellen") + + raw_sid = data.get("framework_slot_id") + try: + slot_id = int(raw_sid) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="framework_slot_id ist ungültig") + if slot_id < 1: + raise HTTPException(status_code=400, detail="framework_slot_id ist ungültig") + + group_id = data.get("group_id") + planned_date = data.get("planned_date") + if not group_id or not planned_date: + raise HTTPException(status_code=400, detail="group_id und planned_date sind Pflichtfelder") + + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute( + """ + SELECT fp.created_by FROM training_framework_slots s + JOIN training_framework_programs fp ON fp.id = s.framework_program_id + WHERE s.id = %s + """, + (slot_id,), + ) + fw_row = cur.fetchone() + if not fw_row: + raise HTTPException(status_code=404, detail="Rahmen-Slot nicht gefunden") + + if role not in ["admin", "superadmin"]: + if fw_row["created_by"] is not None and fw_row["created_by"] != profile_id: + raise HTTPException( + status_code=403, + detail="Keine Berechtigung für dieses Rahmenprogramm", + ) + + cur.execute( + "SELECT id FROM training_units WHERE framework_slot_id = %s", + (slot_id,), + ) + blueprint = cur.fetchone() + if not blueprint: + raise HTTPException(status_code=404, detail="Keine Blueprint-Einheit für diesen Slot") + + _can_access_group_for_create(cur, int(group_id), profile_id, role) + + new_id = _copy_blueprint_into_scheduled_unit( + cur, + int(blueprint["id"]), + int(group_id), + str(planned_date), + profile_id, + slot_id, + ) + + conn.commit() + + return get_training_unit(new_id, session) + + @router.post("/training-units/quick-create") def quick_create_training_unit(data: dict, session=Depends(require_auth)): profile_id = session["profile_id"] diff --git a/backend/version.py b/backend/version.py index 750b412..00b7e14 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.11" +BUILD_DATE = "2026-05-05" +DB_SCHEMA_VERSION = "20260505038" MODULE_VERSIONS = { "auth": "1.0.0", @@ -11,10 +11,10 @@ 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.3.0", # Progressionsgraph: Sequenz-API, Varianten-Knoten, UX Ketten (Migration 034) "training_units": "0.1.0", "training_programs": "0.1.0", - "planning": "0.3.0", + "planning": "0.6.0", "import_wiki": "1.0.0", "admin": "1.0.0", "membership": "1.0.0", @@ -23,6 +23,66 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.11", + "date": "2026-05-05", + "changes": [ + "DB 038: training_units.lead_trainer_profile_id (Vertretung / Leitung pro Termin)", + "API GET /api/training-units: club_id, assigned_to_me, sort, limit; Co-Trainer in Sichtbarkeit; lead_trainer_name / effective_lead_trainer_profile_id", + "API PUT /api/training-units/{id}: lead_trainer_profile_id (Validierung über Gruppe)", + ], + }, + { + "version": "0.8.10", + "date": "2026-05-05", + "changes": [ + "DB 037: Rahmen-Slot-Blueprints als training_units (framework_slot_id); migration training_framework_slot_exercises → Sektionen/Items; origin_framework_slot_id für Kopien", + "API: Rahmen-Slots mit sections/exercises aus Blueprint; Kalender list_training_units ohne Blueprints; POST /api/training-units/from-framework-slot", + ], + }, + { + "version": "0.8.9", + "date": "2026-05-05", + "changes": [ + "DB 036: Rahmenprogramm Kontext (Fokusbereich, Stilrichtung, M:N Trainingsarten & Zielgruppen); nur Bibliothek — plan_mode/group_id/Slot-training_unit entfernt.", + "API: /api/training-framework-programs ohne concrete/library; Payload focus_area_id, style_direction_id, training_type_ids, target_group_ids", + ], + }, + { + "version": "0.8.8", + "date": "2026-05-05", + "changes": [ + "DB 035: Trainingsrahmenprogramm (Rahmen, Ziele, Slots, Slot-Übungen); plan_mode concrete|library", + "DB 035: training_plan_templates.visibility + Backfill club (CURR-007/008)", + "API: CRUD /api/training-framework-programs (AuthZ wie Übungs-/Planungsbibliothek)", + ], + }, + { + "version": "0.8.7", + "date": "2026-04-30", + "changes": [ + "DB 034: Progressionskanten mit optionalen Varianten-Endpunkten", + "API: POST …/edges/sequence (Reihe auf einmal); POST …/edges/delete-batch", + "Frontend Progressions-UI: Sequenz-Editor, Ketten-Ansicht, Variantenwahl", + ], + }, + { + "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/docs/HANDOVER.md b/docs/HANDOVER.md index 388f537..8ffbc8c 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -1,6 +1,6 @@ # Shinkan Jinkendo – Entwicklungsstand & Handover -**Stand:** 2026-04-28 +**Stand:** 2026-05-05 **App-Version / DB-Schema:** siehe `backend/version.py` Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand** und **nächste Baustellen**. @@ -25,6 +25,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl | Übungen: API, DB, Architektur, Routing | `.claude/docs/technical/EXERCISES_API_SPEC.md`, `EXERCISES_DATABASE_FINAL.md`, `EXERCISES_ARCHITECTURE.md`, `EXERCISES_FRONTEND_ROUTING.md` | | Media / Upload | `.claude/docs/technical/MEDIA_UPLOAD_SPEC.md` | | MediaWiki-Import | `.claude/docs/technical/MEDIAWIKI_IMPORT_SPEC.md` | +| Rahmenprogramm · Planung (`training_units` Blueprints), Progressionsgraph | `.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md`; Überblick DB → `.claude/docs/technical/DATABASE_SCHEMA.md`; Domäne → `.claude/docs/functional/DOMAIN_MODEL.md` | --- @@ -64,7 +65,16 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl --- -## 3. Stand: Übungen (Lücke für nächste Session) +## 3. Trainingsrahmenprogramm & Planungs‑Blueprint (kurz) + +- **Migration 036:** Rahmenkopf nur Bibliothek (Kontext: Fokusbereich, Stilrichtung; M:N Trainingsarten/Zielgruppen); keine `plan_mode`/keine Kopf‑`group_id`. +- **Migration 037:** Pro Rahmen‑Slot eine **`training_units`‑Zeile mit `framework_slot_id`**; strukturierter Ablauf wie echte Einheiten (`training_unit_sections` / `training_unit_section_items`). Tabelle **`training_framework_slot_exercises`** entfällt. +- **API:** Rahmen unter **`/api/training-framework-programs`** (Slots liefern u. a. **`blueprint_training_unit_id`**, **`sections[]`**, **`exercises[]`**); Kalenderliste **`GET /api/training-units`** ohne Blueprints; Übernahme **`POST /api/training-units/from-framework-slot`**. +- **Code:** `backend/routers/training_framework_programs.py`, `training_planning.py`; Frontend **`TrainingFrameworkProgramEditPage.jsx`**; **`createTrainingUnitFromFrameworkSlot`** in `api.js`. + +--- + +## 4. Stand: Übungen (Lücke für nächste Session) **Ist (laut Projektdoku und aktuellem Produktziel):** @@ -79,18 +89,18 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl --- -## 4. Technische Referenz (kurz) +## 5. Technische Referenz (kurz) | Bereich | Einstieg | |---------|----------| -| Backend API | `backend/main.py`, `backend/routers/maturity_models.py`, `matrix_stack_bundle.py`, `exercises.py`, `catalogs.py`, `skills.py` | -| Migrationen | `backend/migrations/` (u. a. 024–027 Reifegrad/Bindings) | +| Backend API | `backend/main.py`; Router u. a. **`training_framework_programs.py`**, **`training_planning.py`**, `maturity_models.py`, `matrix_stack_bundle.py`, `exercises.py`, `catalogs.py`, `skills.py` | +| Migrationen | `backend/migrations/` (u. a. 024–027 Reifegrad/Bindings; **035–037** Rahmenprogramm / Slot‑Blueprint) | | Frontend API | `frontend/src/utils/api.js` | | Version / Changelog | `backend/version.py` | --- -## 5. Veraltete Hinweise +## 6. Veraltete Hinweise Die Datei `.claude/docs/working/HANDOVER_NEXT_SESSION.md` (2026-04-22) ist **historisch**; für den aktuellen Stand gilt **`docs/HANDOVER.md`**. diff --git a/frontend/index.html b/frontend/index.html index de010d4..4ce53ec 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,16 +5,21 @@ - + + + + - + + - + - + + - + Shinkan Jinkendo diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000..545fcd4 Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/favicon-32.png b/frontend/public/favicon-32.png new file mode 100644 index 0000000..362bbe6 Binary files /dev/null and b/frontend/public/favicon-32.png differ diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 0000000..77c88ea Binary files /dev/null and b/frontend/public/icon-192.png differ diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 0000000..a612be2 Binary files /dev/null and b/frontend/public/icon-512.png differ diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest new file mode 100644 index 0000000..e0a9d34 --- /dev/null +++ b/frontend/public/manifest.webmanifest @@ -0,0 +1,25 @@ +{ + "name": "Shinkan Jinkendo", + "short_name": "Shinkan", + "description": "Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#f4f3ef", + "theme_color": "#1D9E75", + "lang": "de", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9675635..069dd33 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -21,6 +21,8 @@ import ExerciseFormPage from './pages/ExerciseFormPage' import ClubsPage from './pages/ClubsPage' import SkillsPage from './pages/SkillsPage' import TrainingPlanningPage from './pages/TrainingPlanningPage' +import TrainingFrameworkProgramsListPage from './pages/TrainingFrameworkProgramsListPage' +import TrainingFrameworkProgramEditPage from './pages/TrainingFrameworkProgramEditPage' import TrainingUnitRunPage from './pages/TrainingUnitRunPage' import TrainingCoachPage from './pages/TrainingCoachPage' import AdminCatalogsPage from './pages/AdminCatalogsPage' @@ -157,9 +159,12 @@ function AppRoutes() { } /> } /> - } /> + } /> + } /> + } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/app.css b/frontend/src/app.css index 52ecbf4..9868097 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -18,8 +18,7 @@ --header-h: 52px; --font: system-ui, -apple-system, 'Segoe UI', sans-serif; --capture-content-max: 800px; - /* Admin: nutzt volle Hauptspalte bis zu dieser Obergrenze (siehe .app-main:has(.admin-shell)) */ - --admin-main-max: min(1720px, calc(100vw - 200px)); + --desktop-sidebar-width: 220px; } @media (prefers-color-scheme: dark) { :root { @@ -33,27 +32,107 @@ html, body, #root { height: 100%; } body { font-family: var(--font); background: var(--bg); color: var(--text1); -webkit-text-size-adjust: 100%; } -.app-shell { display: flex; flex-direction: column; height: 100%; max-width: 600px; margin: 0 auto; } +/* Mobile / Web-App: keine horizontale Seiten-Scroll-Leiste; volle Gerätebreite (<1024px) */ +@media (max-width: 1023px) { + html { + overflow-x: clip; + } + body, + #root { + overflow-x: clip; + max-width: 100%; + } +} + +/* Telefon & Tablet hochkant: volle Breite wie eine native App (kein 600px-Säulen-Layout) */ +.app-shell { + display: flex; + flex-direction: column; + min-height: 100%; + min-height: 100dvh; + width: 100%; + max-width: 100%; + margin: 0; +} .app-header { height: var(--header-h); display: flex; align-items: center; padding: 0 16px; background: var(--surface); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; } +/* iPhone: Web-App (standalone) / Safari mit viewport-fit=cover — Kopf unter Statusleiste ausrichten */ +@media (max-width: 1023px) { + .app-header.app-header--mobile { + height: auto; + min-height: calc(var(--header-h) + env(safe-area-inset-top, 0px)); + padding-top: env(safe-area-inset-top, 0px); + padding-left: max(16px, env(safe-area-inset-left, 0px)); + padding-right: max(16px, env(safe-area-inset-right, 0px)); + } +} .app-logo { font-size: 18px; font-weight: 700; color: var(--accent); letter-spacing: -0.02em; } + +/* === Seiten-Inhalt: volle Breite der Spalte, kein künstlicher Max-Wert auf großen Screens === */ +.app-page { + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; +} +/* Form-Grids: minmax(0,…) verhindert Grid-Overflow; eine Spalte bis zum ersten Breakpoint */ +.responsive-grid-2 { + display: grid; + gap: 12px; + grid-template-columns: minmax(0, 1fr); +} +@media (min-width: 480px) { + .responsive-grid-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.responsive-grid-3 { + display: grid; + gap: 1rem; + grid-template-columns: minmax(0, 1fr); +} +@media (min-width: 560px) { + .responsive-grid-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.responsive-grid-4 { + display: grid; + gap: 1rem; + grid-template-columns: minmax(0, 1fr); +} +@media (min-width: 560px) { + .responsive-grid-4 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (min-width: 900px) { + .responsive-grid-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} /* unten: Tab-Leiste + Abstand nach oben zur Leiste + Home-Indicator (iPhone) */ .app-main { flex: 1; + min-width: 0; + max-width: 100%; + overflow-x: clip; overflow-y: auto; - padding: 16px 16px calc(var(--nav-h) + var(--nav-pad-top) + env(safe-area-inset-bottom, 0px) + 20px); + padding: 16px max(16px, env(safe-area-inset-right, 0px)) calc(var(--nav-h) + var(--nav-pad-top) + env(safe-area-inset-bottom, 0px) + 20px) max(16px, env(safe-area-inset-left, 0px)); } .bottom-nav { position: fixed; bottom: 0; - left: 50%; - transform: translateX(-50%); - width: 100%; - max-width: 600px; + left: 0; + right: 0; + width: auto; + max-width: none; display: flex; align-items: center; background: var(--surface); @@ -66,7 +145,7 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we -ms-overflow-style: none; justify-content: flex-start; gap: 2px; - padding: var(--nav-pad-top) 6px env(safe-area-inset-bottom, 0px); + padding: var(--nav-pad-top) max(6px, env(safe-area-inset-right, 0px)) env(safe-area-inset-bottom, 0px) max(6px, env(safe-area-inset-left, 0px)); min-height: calc(var(--nav-h) + var(--nav-pad-top) + env(safe-area-inset-bottom, 0px)); height: auto; box-sizing: border-box; @@ -129,6 +208,7 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we gap: 8px; padding: 0; margin-bottom: 16px; + min-width: 0; } .form-label { @@ -149,6 +229,7 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we .form-input { width: 100%; + min-width: 0; padding: 10px 12px; text-align: left; font-family: var(--font); @@ -1409,7 +1490,7 @@ a.analysis-split__nav-item { border: 1px solid var(--border); border-radius: 12px; padding: 18px 20px; - max-width: 720px; + max-width: 100%; } .skills-catalog-detail__title { font-size: 15px; @@ -1538,7 +1619,7 @@ a.analysis-split__nav-item { } .exercise-filter-modal.admin-modal-sheet { - max-width: min(920px, calc(100vw - 16px)); + max-width: min(920px, calc(100dvw - 16px)); } .exercise-filter-modal .admin-modal-sheet__body.exercise-filter-modal__scroll { flex: 1; @@ -1615,7 +1696,7 @@ a.analysis-split__nav-item { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - max-width: min(260px, 88vw); + max-width: min(260px, 100%); } .exercise-filter-chip__x { flex-shrink: 0; @@ -2035,6 +2116,8 @@ a.analysis-split__nav-item { display: flex; flex-direction: column; flex: 1; + width: 100%; + max-width: 100%; min-width: 0; min-height: 0; } @@ -2042,7 +2125,7 @@ a.analysis-split__nav-item { .desktop-sidebar { display: none; flex-direction: column; - width: 220px; + width: var(--desktop-sidebar-width, 220px); height: 100vh; position: fixed; left: 0; @@ -2170,16 +2253,6 @@ a.analysis-split__nav-item { background: rgba(216, 90, 48, 0.08); } -@media (max-width: 1023px) { - .app-shell { - display: flex; - flex-direction: column; - height: 100%; - max-width: 600px; - margin: 0 auto; - } -} - @media (min-width: 1024px) { .app-shell { display: block; @@ -2187,15 +2260,21 @@ a.analysis-split__nav-item { margin: 0; width: 100%; min-height: 100%; + overflow-x: clip; } .desktop-sidebar { display: flex; } + /* Kein width:100% zusätzlich zu margin-left — sonst Spalte = Viewport + Sidebar → horizontaler Scroll */ .app-shell__column { - margin-left: 220px; + margin-left: var(--desktop-sidebar-width, 220px); + width: calc(100% - var(--desktop-sidebar-width, 220px)); + max-width: calc(100% - var(--desktop-sidebar-width, 220px)); + min-width: 0; min-height: 100vh; + box-sizing: border-box; } .app-header--mobile { @@ -2209,18 +2288,13 @@ a.analysis-split__nav-item { .app-main { padding: 24px 32px 32px; padding-bottom: max(32px, env(safe-area-inset-bottom, 0px)); - max-width: 1200px; - margin-left: auto; - margin-right: auto; width: 100%; + max-width: none; + margin-left: 0; + margin-right: 0; box-sizing: border-box; } - /* Admin: mehr horizontaler Raum für Tabellen auf großen Screens (:has ~2022+, sonst bleibt 1200px) */ - .app-main:has(.admin-shell) { - max-width: var(--admin-main-max); - } - /* Dashboard (P3): Begrüßung + Kennzahlen-Zeile */ .dashboard-greeting { display: flex; @@ -2324,7 +2398,7 @@ a.analysis-split__nav-item { color: var(--text3); margin: 4px 0 0 0; line-height: 1.35; - max-width: 640px; + max-width: none; } .dashboard-section__body { @@ -2540,8 +2614,9 @@ a.analysis-split__nav-item { } .exercise-detail-shell { - max-width: 640px; - margin: 0 auto; + max-width: none; + margin: 0; + width: 100%; } .exercise-detail-section { margin-bottom: 14px; @@ -2713,6 +2788,963 @@ a.analysis-split__nav-item { accent-color: var(--accent); } +/* Rahmenprogramm bearbeiten — Mobile: Stammdaten | Plan; Desktop: untereinander Ziele → Slots (synchron zu FRAMEWORK_DESKTOP_MIN_PX) */ +.framework-edit { + max-width: 100%; + margin: 0; + width: 100%; + min-width: 0; +} +@media (min-width: 900px) { + .framework-edit__tabbar { + display: none !important; + } + .framework-edit__plan-stack { + display: flex; + flex-direction: column; + gap: 16px; + } + .framework-edit__panel { + display: block !important; + } +} +.framework-edit__tabbar { + display: flex; + gap: 6px; + margin-bottom: 14px; + padding: 2px 0 12px; + border-bottom: 1px solid var(--border); + flex-wrap: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; +} +.framework-edit__tabbar::-webkit-scrollbar { + display: none; +} +.framework-edit__tab { + flex: 1 1 0; + min-width: 0; + padding: 10px 8px; + border: 1px solid var(--border2); + border-radius: 10px; + background: var(--surface2); + color: var(--text2); + font-size: 12px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + font-family: var(--font); +} +.framework-edit__tab--active { + background: var(--accent-light); + color: var(--accent-dark); + border-color: var(--accent); +} +.framework-edit__plan-stack { + display: flex; + flex-direction: column; + gap: 14px; +} +@media (max-width: 899px) { + .framework-edit .framework-edit__panel:not(.framework-edit__panel--active) { + display: none !important; + } +} + +.framework-plan-goals { + border-left: 3px solid var(--accent); +} + +.framework-goal-chips__hint { + font-size: 0.78rem; + color: var(--text3); + margin: 0 0 10px; + line-height: 1.45; +} + +.framework-goal-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.framework-popmenu-anchor { + position: relative; + display: inline-flex; + align-items: stretch; + vertical-align: middle; + max-width: 100%; +} + +.framework-goal-chip-wrap { + border-radius: 999px; + background: var(--surface2); + border: 1px solid var(--border2); +} + +.framework-goal-chip { + border: none; + background: transparent; + font: inherit; + padding: 6px 10px 6px 12px; + border-radius: 999px 0 0 999px; + cursor: pointer; + max-width: min(240px, 100%); + text-align: left; +} + +.framework-goal-chip--active { + background: var(--accent-light); +} + +.framework-goal-chip__text { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.88rem; + font-weight: 600; + color: var(--text1); +} + +.framework-goal-chip__kebab { + border: none; + background: transparent; + padding: 0 8px; + cursor: pointer; + color: var(--text3); + font-size: 1.05rem; + line-height: 1; + border-left: 1px solid var(--border); + border-radius: 0 999px 999px 0; +} + +.framework-goal-chip__kebab:hover, +.framework-goal-chip:hover { + filter: brightness(0.97); +} + +.framework-goal-editor { + margin-top: 12px; + padding: 12px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--surface2); +} + +.framework-catalog-checkgrid { + display: flex; + flex-wrap: wrap; + gap: 8px 18px; + max-height: 220px; + overflow-y: auto; + padding: 10px; + border: 1px solid var(--border2); + border-radius: 10px; + background: var(--surface2); +} + +.framework-catalog-check { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.88rem; + cursor: pointer; + user-select: none; +} + +.framework-catalog-check input { + accent-color: var(--accent); +} + +.framework-popmenu { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 30; + margin: 0; + padding: 4px 0; + list-style: none; + min-width: 200px; + max-width: min(300px, calc(100vw - 32px)); + background: var(--surface); + border: 1px solid var(--border2); + border-radius: 10px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); +} + +.framework-popmenu--align-end { + left: auto; + right: 0; +} + +.framework-popmenu li { + margin: 0; +} + +.framework-popmenu__item { + display: block; + width: 100%; + text-align: left; + border: none; + background: transparent; + font-family: var(--font); + font-size: 13px; + padding: 10px 14px; + cursor: pointer; + color: var(--text1); +} + +.framework-popmenu__item:hover { + background: var(--surface2); +} + +.framework-popmenu__item--danger { + color: var(--danger); +} + +.framework-ctrl.framework-ctrl--xs { + padding: 2px 8px; + font-size: 11px; + min-height: 26px; + line-height: 1.2; +} + +/* Horizontaler Überblick: äußerer Scroll‑Container (Desktop: breite Session‑Karten) */ +.framework-slots-board-outer { + container-type: inline-size; + width: 100%; + max-width: 100%; + min-width: 0; + overflow-x: auto; + overflow-y: visible; + padding-bottom: 10px; + margin-left: -4px; + margin-right: -4px; + padding-left: 4px; + padding-right: 4px; + -webkit-overflow-scrolling: touch; + scrollbar-gutter: stable; +} + +.framework-slots-board-outer--mobile-single { + overflow-x: visible; + scrollbar-gutter: auto; + margin-left: 0; + margin-right: 0; + padding-left: 0; + padding-right: 0; + padding-bottom: 4px; +} + +.framework-slots-board-outer--desktop { + scrollbar-gutter: stable; +} + +@media (max-width: 1023px) { + .framework-slots-board-outer:not(.framework-slots-board-outer--mobile-single) { + scrollbar-gutter: auto; + } +} + +.framework-slots-board { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + gap: 12px; + align-items: stretch; + width: max-content; + min-width: 100%; + padding: 4px 0 2px; + scroll-snap-type: x proximity; +} + +.framework-slots-board--desktop-wide .framework-slot-card { + flex: 0 0 min(760px, max(560px, calc(100cqw - 48px))); + width: min(760px, max(560px, calc(100cqw - 48px))); + min-width: min(760px, max(560px, calc(100cqw - 48px))); + height: auto; + max-height: none; + display: flex; + flex-direction: column; + margin-bottom: 0; + background: var(--surface); + border-style: dashed; + overflow: hidden; + scroll-snap-align: start; + box-sizing: border-box; +} + +.framework-slots-board--desktop-wide .framework-slot-card__plan-editor { + flex: 1; + min-height: 240px; + max-height: min(78vh, 1200px); + overflow-y: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; +} + +/* Ein Slot = nutzbare Bildschirm­breite; Chips oben/unten wechseln die Session */ +.framework-slot-mobile-panel { + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; +} + +.framework-slot-mobile-panel .framework-slot-card--mobile-single { + flex: none; + width: 100%; + max-width: 100%; + min-width: 0; + height: auto; + max-height: none; + overflow: visible; + scroll-snap-align: unset; +} + +.framework-slot-mobile-panel .framework-slot-card__plan-editor { + max-height: none; + overflow-x: hidden; +} + +.framework-slot-chips-bar { + display: flex; + flex-wrap: nowrap; + gap: 8px; + overflow-x: auto; + overflow-y: hidden; + padding: 8px 0 10px; + margin-bottom: 2px; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; +} + +.framework-slot-chips-bar--bottom { + margin-bottom: 0; + margin-top: 12px; + padding-top: 6px; + border-top: 1px solid var(--border); +} + +.framework-slot-chip { + flex: 0 0 auto; + appearance: none; + margin: 0; + cursor: pointer; + padding: 8px 16px; + border-radius: 999px; + border: 1px solid var(--border2); + background: var(--surface2); + color: var(--text2); + font-size: 0.86rem; + font-weight: 600; + line-height: 1.25; + max-width: 220px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.framework-slot-chip:hover { + border-color: var(--accent); + color: var(--accent-dark); +} + +.framework-slot-chip--active { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-text, #fff); +} + +/* ——— Trainings‑Einheit: Übungszeilen schlank + DnD ——— */ +.training-unit-sections-editor--wide .tu-ex-variant-select--wide { + max-width: 100%; +} + +.tu-item-row { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 6px 8px; + margin-top: 0.5rem; + padding: 0.4rem 6px 0.45rem; + border-top: 1px solid rgba(0, 0, 0, 0.06); + min-width: 0; + transition: outline 0.1s ease; + border-radius: 8px; +} + +.tu-item-row--drop-target { + outline: 2px dashed var(--accent); + outline-offset: 1px; +} + +.tu-item-row--dragging { + opacity: 0.52; +} + +.tu-row-grip { + flex: 0 0 auto; + display: inline-flex; + align-items: flex-start; + padding: 4px 2px; + margin-top: 2px; + border-radius: 6px; + color: var(--text3); + cursor: grab; + user-select: none; + touch-action: none; +} + +.tu-row-grip:active { + cursor: grabbing; +} + +.tu-item-row__nudge { + flex: 0 0 auto; + display: flex; + flex-direction: column; + gap: 0; + padding-top: 4px; +} + +.tu-item-row__nudge button { + padding: 0 5px; + line-height: 1.2; + font-size: 11px; + min-height: 20px; + border: none; + background: transparent; + color: var(--text2); + border-radius: 4px; +} + +.tu-item-row__nudge button:disabled { + opacity: 0.3; +} + +.tu-item-row__nudge button:not(:disabled):hover { + background: rgba(0, 0, 0, 0.06); +} + +.tu-item-row__mainline { + display: flex; + flex: 1; + flex-wrap: nowrap; + gap: 8px; + align-items: flex-start; + min-width: 0; + width: 100%; +} + +.tu-item-row--note .tu-icon-btn, +.tu-item-row--note .tu-item-row__remove { + align-self: center; +} + +.tu-item-row--note .tu-item-row__body--note { + flex: 1; + min-width: 0; +} + +.tu-item-row--note { + flex-wrap: nowrap; +} + +.tu-item-row__body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 5px; +} + +.tu-item-row__meta-label { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text3); +} + +.tu-item-row__preview { + margin: 0; + font-size: 0.86rem; + line-height: 1.35; + color: var(--text1); + word-break: break-word; +} + +.tu-item-row__preview--clamp { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; +} + +.tu-item-row__preview--empty { + color: var(--text3); +} + +.tu-icon-btn { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px; + border-radius: 8px; + border: 1px solid var(--border2); + background: var(--surface); + color: var(--text2); + cursor: pointer; + line-height: 0; +} + +.tu-icon-btn:hover { + border-color: var(--accent); + color: var(--accent-dark); +} + +.tu-item-row__remove { + flex: 0 0 auto; + padding: 5px 10px; + min-height: 32px; + font-size: 12px; + background: var(--danger); + color: #fff; + border: none; + border-radius: 7px; + cursor: pointer; + line-height: 1; +} + +.tu-item-row__remove:hover { + filter: brightness(1.05); +} + +.tu-item-row__side { + flex: 0 0 auto; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 5px; +} + +.tu-ex-duration { + margin: 0; + width: 4.75rem; + font-size: 0.84rem; +} + +.tu-ex-title-line { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px 12px; +} + +.tu-ex-title { + font-size: 0.95rem; + font-weight: 700; + line-height: 1.35; + flex: 1 1 220px; + min-width: 0; + word-break: break-word; +} + +.tu-ex-title-placeholder { + font-size: 0.9rem; + color: var(--text3); + font-style: italic; +} + +.tu-ex-inline-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.tu-ex-meta-line { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 14px; +} + +.tu-ex-variant-select { + margin: 0; + flex: 0 1 min(220px, 100%); + font-size: 0.82rem; + min-width: 0; +} + +.training-unit-sections-editor--wide .tu-ex-variant-select--wide { + flex-basis: min(320px, 100%); +} + +.tu-ex-annot { + flex: 1 1 140px; + min-width: 0; + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; +} + +.tu-ex-annot__text { + flex: 1; + min-width: 0; + font-size: 0.82rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tu-ex-run-block { + display: block; + width: 100%; + margin-top: 10px; + font-size: 0.78rem; +} + +.tu-ex-run-block__controls { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 5px; +} + +.tu-ex-run-block__controls .form-input:first-of-type { + max-width: 120px; +} + +.tu-textedit-backdrop { + position: fixed; + inset: 0; + z-index: 10060; + background: rgba(15, 23, 42, 0.42); + display: flex; + align-items: flex-start; + justify-content: center; + padding: 8vh 14px 40px; +} + +.tu-textedit-panel { + width: 100%; + max-width: 480px; + background: var(--surface); + border-radius: 14px; + border: 1px solid var(--border); + padding: 1rem 1.15rem 1.1rem; + box-shadow: 0 22px 52px rgba(0, 0, 0, 0.22); +} + +.tu-textedit-title { + margin: 0 0 0.65rem; + font-size: 1.03rem; +} + +.tu-textedit-textarea { + width: 100%; + resize: vertical; + min-height: 100px; + margin-bottom: 0.85rem; +} + +.tu-textedit-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: flex-end; +} + +.tu-section-shell { + contain: layout; +} + +.tu-section-dropband { + height: 10px; + margin: 0 2px 4px; + border-radius: 6px; + flex-shrink: 0; + box-sizing: border-box; +} + +.tu-section-dropband--end { + margin-top: 2px; + margin-bottom: 10px; +} + +.tu-section-dropband--active { + background: color-mix(in srgb, var(--accent) 26%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 42%, transparent); +} + +.tu-sec-drag-grip { + flex-shrink: 0; + display: inline-flex; + align-items: center; + padding: 4px; + cursor: grab; + color: var(--text3); + user-select: none; + touch-action: none; + border-radius: 6px; +} + +.tu-sec-drag-grip:active { + cursor: grabbing; +} + +.tu-item-append-drop { + min-height: 16px; + margin: 2px -2px 6px; + border-radius: 6px; + box-sizing: border-box; +} + +.tu-item-append-drop--active { + outline: 2px dashed var(--accent); + outline-offset: 1px; + background: color-mix(in srgb, var(--accent) 10%, transparent); +} + +.framework-slot-card__head { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 6px; + flex-shrink: 0; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} + +.framework-slot-card__drag-handle { + flex: 0 0 auto; + cursor: grab; + user-select: none; + font-size: 14px; + line-height: 1; + padding: 6px 4px; + color: var(--text3); + border-radius: 6px; +} + +.framework-slot-card__drag-handle:active { + cursor: grabbing; +} + +.framework-slot-card__head-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.framework-slot-card__slot-label { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text3); +} + +.framework-slot-card__title-input { + padding: 6px 10px; + font-size: 14px; + font-weight: 600; +} + +.framework-slot-card__slot-actions { + display: flex; + flex-direction: column; + gap: 4px; + flex-shrink: 0; +} + +.framework-slot-details { + flex-shrink: 0; + margin-top: 8px; + font-size: 0.88rem; + border-radius: 8px; + background: var(--surface2); + border: 1px solid var(--border); +} + +.framework-slot-details__summary { + cursor: pointer; + padding: 6px 10px; + font-weight: 600; + color: var(--text2); + list-style: none; +} + +.framework-slot-details__summary::-webkit-details-marker { + display: none; +} + +.framework-slot-details .form-row { + margin-bottom: 10px; + padding: 0 10px 8px; +} + +.framework-slot-card__exercises { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + margin-top: 10px; + gap: 6px; + overflow-y: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; +} + +.framework-slot-card__exercises-head { + display: flex; + justify-content: space-between; + align-items: center; + flex-shrink: 0; + gap: 8px; +} + +.framework-slot-card__exercises-title { + font-weight: 700; + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--accent-dark); +} + +.framework-slot-card__empty-hint { + font-size: 0.82rem; + color: var(--text2); + margin: 4px 0 6px; +} + +.framework-ex-row { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 6px; + padding: 8px 8px; + border-radius: 8px; + border: 1px solid var(--border2); + background: var(--surface2); + flex-shrink: 0; +} + +.framework-ex-row__grip { + flex: 0 0 auto; + cursor: grab; + user-select: none; + line-height: 1.4; + padding-top: 2px; + color: var(--text3); + font-size: 14px; +} + +.framework-ex-row__grip:active { + cursor: grabbing; +} + +.framework-ex-row__body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.framework-ex-row__title-line { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px 10px; +} + +.framework-ex-row__title { + font-size: 0.98rem; + line-height: 1.35; +} + +.framework-ex-row__title--muted { + color: var(--text3); + font-weight: 500; +} + +.framework-ex-row__id { + font-size: 11px; + color: var(--text3); +} + +.framework-ex-row__row2 { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 8px; + width: 100%; + min-width: 0; +} + +.framework-ex-row__variant-spacer { + flex: 1; + min-width: 0; +} + +.framework-ex-row__menu-anchor { + flex: 0 0 auto; +} + +.framework-ex-row__kebab { + width: 34px; + height: 34px; + padding: 0; + border-radius: 8px; + border: 1px solid var(--border2); + background: var(--surface); + cursor: pointer; + font-size: 1.1rem; + line-height: 1; + color: var(--text2); +} + +.framework-ex-row__kebab:hover { + border-color: var(--accent); + color: var(--accent-dark); +} + +.framework-ex-row__variant-select { + flex: 1 1 auto; + min-width: 100px; + max-width: 100%; + padding: 6px 8px; + font-size: 13px; +} + +.framework-slot-card__append-drop { + margin-top: 4px; + padding: 8px 10px; + font-size: 11px; + color: var(--text3); + border: 1px dashed var(--border2); + border-radius: 8px; + text-align: center; + flex-shrink: 0; +} + +@media (min-width: 900px) { + .framework-slot-card__slot-actions { + flex-direction: row; + align-items: center; + } +} + @media print { .desktop-sidebar, .bottom-nav, @@ -2746,8 +3778,8 @@ a.analysis-split__nav-item { display: flex; flex-direction: column; width: 100%; - max-width: 720px; - margin: 0 auto; + max-width: none; + margin: 0; min-height: calc(100dvh - var(--header-h) - var(--nav-h) - env(safe-area-inset-bottom, 0px) - 48px); } diff --git a/frontend/src/components/ExercisePeekModal.jsx b/frontend/src/components/ExercisePeekModal.jsx index 1063e7d..56a1c02 100644 --- a/frontend/src/components/ExercisePeekModal.jsx +++ b/frontend/src/components/ExercisePeekModal.jsx @@ -31,11 +31,22 @@ function TagMini({ exercise }) { ) } -export default function ExercisePeekModal({ open, exerciseId, onClose, titleFallback }) { +export default function ExercisePeekModal({ + open, + exerciseId, + variantId, + onClose, + titleFallback, +}) { const [loading, setLoading] = useState(false) const [err, setErr] = useState(null) const [exercise, setExercise] = useState(null) + const variant = + variantId != null && variantId !== '' && exercise?.variants?.length + ? exercise.variants.find((v) => String(v.id) === String(variantId)) || null + : null + useEffect(() => { if (!open) { setExercise(null) @@ -62,7 +73,7 @@ export default function ExercisePeekModal({ open, exerciseId, onClose, titleFall return () => { cancelled = true } - }, [open, exerciseId]) + }, [open, exerciseId, variantId]) if (!open) return null @@ -100,6 +111,37 @@ export default function ExercisePeekModal({ open, exerciseId, onClose, titleFall {!loading && err &&

{err}

} {!loading && exercise && ( <> + {variant ? ( +
+
+ Variante +
+
+ {variant.variant_name || `Variante #${variant.id}`} +
+ {variant.description ? ( +
+ +
+ ) : null} + {variant.execution_changes ? ( +
+

+ Durchführung (Variante) +

+ +
+ ) : null} +
+ ) : null} {exercise.summary && (
diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx index 8525252..7424b95 100644 --- a/frontend/src/components/ExercisePickerModal.jsx +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -22,7 +22,13 @@ const INITIAL_FILTERS = { status_any: [], } -export default function ExercisePickerModal({ open, onClose, onSelectExercise }) { +export default function ExercisePickerModal({ + open, + onClose, + onSelectExercise, + multiSelect = false, + onSelectExercises = null, +}) { const [catalogs, setCatalogs] = useState({ focusAreas: [], styleDirections: [], @@ -42,6 +48,13 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise }) const [loadingMore, setLoadingMore] = useState(false) const [offset, setOffset] = useState(0) const [hasMore, setHasMore] = useState(false) + const [multiPicked, setMultiPicked] = useState([]) + + const toggleMultiPick = (ex) => { + setMultiPicked((prev) => + prev.some((p) => p.id === ex.id) ? prev.filter((p) => p.id !== ex.id) : [...prev, ex] + ) + } useEffect(() => { const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 350) @@ -96,6 +109,7 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise }) setList([]) setOffset(0) setHasMore(false) + setMultiPicked([]) } }, [open]) @@ -230,7 +244,9 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise }) onClick={(e) => e.stopPropagation()} >
-

Übung auswählen

+

+ {multiSelect ? 'Übungen auswählen' : 'Übung auswählen'} +

@@ -391,29 +407,16 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise }) {list.length} angezeigt{hasMore ? ' · weiter unten „Mehr laden“' : ''}

    - {list.map((ex) => ( -
  • - -
  • - ))} + + ) + if (multiSelect) { + return ( +
  • + +
  • + ) + } + return ( +
  • + +
  • + ) + })}
{hasMore && (
@@ -432,6 +490,49 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
)} + {multiSelect && typeof onSelectExercises === 'function' ? ( +
+ + {multiPicked.length} ausgewählt + +
+ + +
+
+ ) : null} )}
diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx new file mode 100644 index 0000000..5314bc2 --- /dev/null +++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx @@ -0,0 +1,1053 @@ +/** + * Progressionsgraphen: Sequenz-Editor (mehrere Nachfolger auf einmal), Ketten-Ansicht, + * Varianten als eigene Knoten-Endpunkte, Schwester-Kanten gesondert, Tabelle als Fallback. + */ +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 || '—' +} + +/** Maximale lineare Segmente aus next_exercise-Kanten (jedes Segment deckt zusammenhängende „Pfade“ ab). */ +function maximalLinearChains(nextEdges) { + if (!nextEdges?.length) return [] + const outMap = new Map() + const inMap = new Map() + const nodeKey = (ex, v) => `${ex}:${v ?? ''}` + + for (const e of nextEdges) { + const f = nodeKey(e.from_exercise_id, e.from_exercise_variant_id) + const t = nodeKey(e.to_exercise_id, e.to_exercise_variant_id) + if (!outMap.has(f)) outMap.set(f, []) + outMap.get(f).push(e) + if (!inMap.has(t)) inMap.set(t, []) + inMap.get(t).push(e) + } + + const used = new Set() + const chains = [] + + for (const startEdge of nextEdges) { + if (used.has(startEdge.id)) continue + + const edgesSeq = [startEdge] + + let fk = nodeKey(startEdge.from_exercise_id, startEdge.from_exercise_variant_id) + while (true) { + const preds = inMap.get(fk) + if (!preds || preds.length !== 1) break + const pred = preds[0] + if (used.has(pred.id)) break + edgesSeq.unshift(pred) + fk = nodeKey(pred.from_exercise_id, pred.from_exercise_variant_id) + } + + let tk = nodeKey(startEdge.to_exercise_id, startEdge.to_exercise_variant_id) + while (true) { + const outs = outMap.get(tk) + if (!outs || outs.length !== 1) break + const nx = outs[0] + if (used.has(nx.id)) break + edgesSeq.push(nx) + tk = nodeKey(nx.to_exercise_id, nx.to_exercise_variant_id) + } + + edgesSeq.forEach((ed) => used.add(ed.id)) + + const first = edgesSeq[0] + const nodes = [ + { + exercise_id: first.from_exercise_id, + variant_id: first.from_exercise_variant_id ?? null, + title: first.from_exercise_title, + variant_name: first.from_variant_name ?? null, + }, + ] + for (const ed of edgesSeq) { + nodes.push({ + exercise_id: ed.to_exercise_id, + variant_id: ed.to_exercise_variant_id ?? null, + title: ed.to_exercise_title, + variant_name: ed.to_variant_name ?? null, + }) + } + chains.push({ nodes, edges: edgesSeq }) + } + return chains +} + +function emptySeqStep() { + return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [] } +} + +function emptyEndpoint() { + return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [] } +} + +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 [sequenceSteps, setSequenceSteps] = useState([emptySeqStep(), emptySeqStep()]) + const [sequenceBulkNotes, setSequenceBulkNotes] = useState('') + const [pickContext, setPickContext] = useState(null) + + const [relationKind, setRelationKind] = useState('progression') + const [firstEp, setFirstEp] = useState(emptyEndpoint) + const [secondEp, setSecondEp] = useState(emptyEndpoint) + const [edgeNotes, setEdgeNotes] = useState('') + + const [filterAnchorOnly, setFilterAnchorOnly] = useState(!!anchorExerciseId) + const [editingEdgeNotes, setEditingEdgeNotes] = useState(null) + const [notesDraft, setNotesDraft] = useState('') + const [uiTab, setUiTab] = useState('overview') + + 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 : []) + }, []) + + const loadVariantsForExercise = useCallback(async (exerciseId) => { + if (!exerciseId) return [] + const ex = await api.getExercise(exerciseId) + return Array.isArray(ex?.variants) ? ex.variants : [] + }, []) + + 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]) + + useEffect(() => { + let cancelled = false + ;(async () => { + if (!firstEp.exerciseId) return + const vars = await loadVariantsForExercise(firstEp.exerciseId) + if (!cancelled) setFirstEp((p) => ({ ...p, variants: vars })) + })() + return () => { + cancelled = true + } + }, [firstEp.exerciseId, loadVariantsForExercise]) + + useEffect(() => { + let cancelled = false + ;(async () => { + if (!secondEp.exerciseId) return + const vars = await loadVariantsForExercise(secondEp.exerciseId) + if (!cancelled) setSecondEp((p) => ({ ...p, variants: vars })) + })() + return () => { + cancelled = true + } + }, [secondEp.exerciseId, loadVariantsForExercise]) + + 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 nextEdgesFiltered = useMemo( + () => filteredEdges.filter((e) => e.edge_type === 'next_exercise'), + [filteredEdges], + ) + const siblingEdgesFiltered = useMemo( + () => filteredEdges.filter((e) => e.edge_type === 'sibling'), + [filteredEdges], + ) + + const flowChains = useMemo(() => maximalLinearChains(nextEdgesFiltered), [nextEdgesFiltered]) + + 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 patchSeqStep = (idx, patch) => { + setSequenceSteps((prev) => prev.map((s, i) => (i === idx ? { ...s, ...patch } : s))) + } + + const addSeqStep = () => setSequenceSteps((prev) => [...prev, emptySeqStep()]) + + const removeSeqStep = (idx) => { + setSequenceSteps((prev) => { + if (prev.length <= 2) return prev + return prev.filter((_, i) => i !== idx) + }) + } + + const moveSeqStep = (idx, dir) => { + setSequenceSteps((prev) => { + const j = idx + dir + if (j < 0 || j >= prev.length) return prev + const next = [...prev] + const t = next[idx] + next[idx] = next[j] + next[j] = t + return next + }) + } + + const submitSequence = async () => { + if (!selectedGraphId) { + alert('Zuerst einen Graphen wählen.') + return + } + const steps = sequenceSteps.filter((s) => s.exerciseId != null) + if (steps.length < 2) { + alert('Mindestens zwei Schritte mit gewählter Übung.') + return + } + const n = steps.length - 1 + const noteRaw = sequenceBulkNotes.trim() + const segment_notes = Array.from({ length: n }, () => (noteRaw ? noteRaw : null)) + + setBusy(true) + try { + await api.createExerciseProgressionSequence(selectedGraphId, { + steps: steps.map((s) => ({ + exercise_id: s.exerciseId, + variant_id: s.variantId || null, + })), + segment_notes, + }) + setSequenceBulkNotes('') + await refreshEdges(selectedGraphId) + alert(`${n} Nachfolger-Kante(n) angelegt.`) + } catch (err) { + alert(err.message || String(err)) + } finally { + setBusy(false) + } + } + + const deleteChain = async (edgeObjs) => { + if (!selectedGraphId || !edgeObjs?.length) return + if (!confirm(`${edgeObjs.length} Kante(n) dieser Reihe löschen?`)) return + setBusy(true) + try { + await api.deleteExerciseProgressionEdgesBatch( + selectedGraphId, + edgeObjs.map((e) => e.id), + ) + await refreshEdges(selectedGraphId) + } catch (err) { + alert(err.message || String(err)) + } finally { + setBusy(false) + } + } + + const handleAddEdge = async () => { + if (!selectedGraphId) { + alert('Zuerst einen Graphen wählen.') + return + } + if (!firstEp.exerciseId || !secondEp.exerciseId) { + alert('Beide Enden müssen eine Übung haben.') + return + } + if ( + firstEp.exerciseId === secondEp.exerciseId && + (firstEp.variantId == null || + secondEp.variantId == null || + firstEp.variantId === secondEp.variantId) + ) { + alert('Bei derselben Übung bitte zwei verschiedene Varianten wählen (oder unterschiedliche Übungen).') + return + } + const edge_type = relationKind === 'sibling' ? 'sibling' : 'next_exercise' + const notes = edgeNotes.trim() || null + const body = { + from_exercise_id: firstEp.exerciseId, + to_exercise_id: secondEp.exerciseId, + from_exercise_variant_id: firstEp.variantId || null, + to_exercise_variant_id: secondEp.variantId || null, + edge_type, + 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 = firstEp + setFirstEp(secondEp) + setSecondEp(a) + } + + const applyPickedExercise = async (ex) => { + const title = ex.title || `Übung #${ex.id}` + const variants = await loadVariantsForExercise(ex.id) + + if (pickContext?.kind === 'sequence') { + patchSeqStep(pickContext.index, { + exerciseId: ex.id, + exerciseTitle: title, + variantId: null, + variants, + }) + setPickContext(null) + return + } + if (pickContext?.kind === 'single') { + const patch = { + exerciseId: ex.id, + exerciseTitle: title, + variantId: null, + variants, + } + if (pickContext.slot === 'first') setFirstEp(patch) + else setSecondEp(patch) + setPickContext(null) + } + } + + function formatNodeLine(n) { + return ( + <> + {n.title} + {n.variant_name ? ( + {` · ${n.variant_name}`} + ) : null} + + ) + } + + const pickerOpen = pickContext != null + + return ( +
+ {anchorExerciseId != null && ( +

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

+ )} + +

+ Pro Graph mehrere Reihen und Alternativen: eine{' '} + Sequenz legt automatisch alle Schritte Übung1 → Übung2 → … als Nachfolger-Kanten an. + Optional pro Schritt eine Variante — sie wirkt wie ein eigener Knoten. Verzweigungen und + Schwestern trennst du weiterhin mit Einzelkanten oder mehreren Sequenzen aus dem gleichen Knoten. +

+ + {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)} /> +
+
+ +