Trainingsplanung und Rahmenplanung #9

Merged
Lars merged 29 commits from develop into main 2026-05-05 16:05:01 +02:00
55 changed files with 8807 additions and 894 deletions

View File

@ -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 + SlotBlueprint** (DB **036037**): Rahmenkopf nur als Vorlage mit KontextStammdaten; 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** (032034) 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, `<datalist>`-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 RahmenHydration (`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 **020030** und Smoke-Tests.
2. Optional: Server-Autocomplete für Suche; Progressions-Serien als Blöcke (siehe Feature-Doc).
1. KalenderUI: „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) | ✅ | 🔲 |
| 028029 | exercise_media / skills Stufen | ✅ | 🔲 |
| **030** | **training_unit_exercises.exercise_variant_id** | ✅ | 🔲 |
| **032034** | **Progressionsgraph Übung→Übung** | ✅ | 🔲 |
| **035037** | **Rahmenprogramm, BibliothekKopf, SlotBlueprintUnits** | ✅ | 🔲 |
---
@ -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) + **SlotBlueprints** in `training_units` (036037)
- [x] **Materialisierung** aus RahmenSlot (`POST …/training-units/from-framework-slot`; UIAnbindung 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. 036037) |
| 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

View File

@ -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 **036037:** Rahmen nur Bibliothek; SlotInhalt ü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, CURR002 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 (**CURR013**).
**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.
### TrainingsrahmenVorlage (Rahmenprogramm, CURR002 Stufe2 / CURR009)
**Abgrenzung:** Eine **einzeilige** TrainingsplanMikrovorlage (`training_plan_template`) strukturiert **eine** Einheit; das **Rahmenprogramm** ist eine **eigene Bibliotheksentität** mit **sortierten SessionSlots**, **mindestens einem** formulierten **Entwicklungsziel** (Zielliste, **CURR011**) und einem **vollständigen Ablauf** pro Slot (**`training_unit_sections` + `training_unit_section_items`** wie bei geplanten Einheiten — **CURR010** inhaltlich, technisch seit **037** identisch zur Planungsstruktur). Der persistierte **Progressionsgraph** zwischen Übungen bleibt **optional** (**CURR013**).
**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 SlotBlueprint an; **`origin_framework_slot_id`** dient als Herkunftsreferenz (**Lineage light**; weiteres Feedback/LineageKonzept: Konzeptpapier Schritt **E**).
---
## Methodenbezug (§11.5)

View File

@ -0,0 +1,225 @@
# Konzept: Trainingsplanung über Einheiten hinweg, Kurspläne, Governance, Assessments
**Status:** Arbeitspapier (lebend)
**Stand:** 2026-05-05 (RahmenBibliothek **036**, SlotBlueprint **037** / API `from-framework-slot`; CURR002 Stufe 1 Graph unverändert 032034)
**Zweck:** Erkenntnisse und **getroffene Entscheidungen** festhalten, um Spec- und Implementierungsdrift zu vermeiden.
**Kanons:** Bei Widersprüchen mit produktiven Specs zuerst diese Datei mit dem Team abstimmen; technische Details ergänzen später in `technical/`.
---
## 1. Kurz-Zielbild (Problem)
- **Heute:** Planung denkt stark pro **einer** Trainingseinheit (Struktur + Übungen).
- **Gewünscht:**
- **Trainingsrahmenprogramm** (über **mehrere** Session-Slots): **mehrere** **Entwicklungsziele** über denselben Zeitraum sowie **Zuordnung von Übungen** zu Sessions; unterstützt **manuelle** Verteilung (ohne Pflicht-Progressionsgraph) und optional **persistente Progressionsbäume** (v. a. Verwalter) — siehe CURR010, CURR011, CURR013.
- **Mehrwöchige / periodische Planung** (z.B. Monat) mit **Entwicklungszielen** und Verteilung aufbauender Elemente über **mehrere Einheiten** auch **ohne** vollständiges „Kursprogramm“-Produkt.
- **Standard-Kurs-/Stufenpläne** (zeitlos, mehrschrittig) als Basis für konkrete Durchführung; Instanzen **editierbar ohne** Änderung der Vorlage.
- **Governance** einheitlich über Domänen (Übungen, Verein, Trainingspläne, Kurspläne …), ohne spätere Modellbrüche.
- **Nachvollziehbarkeit:** real durchgeführte Einheiten an Pläne/Vorlagen zurückführen; **Feedback** zur Verbesserung von Standardplänen.
- **Assessments** als Spezialfall eines Plans (Tests, z.B. Gürtel), später ggf. Teilnehmerbezug und Erwartungsniveau.
---
## 2. Geführte Konzepterstellung (Arbeitsschritte)
**Konzept-Arbeit:** Ziele klären → Optionen → **Entscheidung** in §5 → Glossar §4 pflegen.
### 2.a Umsetzungs-Reihenfolge „Rahmenprogramm“ (product / binding laut CURR-001002)
| Stufe | Inhalt | Status |
|--------|--------|--------|
| **1** | **Progressionsbezüge** zwischen Übungen **persistent speicherbar** (Progressionsbaum / -graph zwischen Übungseinheiten, nicht nur UI) | ✅ **Zwischenstand im Produkt** (Migrationen 032034, UI/API); UX für **parallele gleichwertige AlternativPakete** noch kein ErstklassFall — siehe `technical/TRAINING_FRAMEWORK_SPEC.md` §4 |
| **2** | **Planungs-/Rahmenmodus:** Übungen auf **mehrere** Session-Slots verteilen; **mehrere Ziele**; speicherbare Rahmen-Vorlage (CURR002(2) i.V.m. **CURR010013**) | **in Arbeit**: BibliotheksBackend + SlotBlueprint + KopieAPI (**037**); **UI Kalender**/Bulk folgt (**CURR012**) |
| **3** | **Konkrete Einheit:** aus Rahmen-/Verteilungsplan **Vorschläge** beim Ausarbeiten laden; Bezug zur Idee **„Warenkorb“** bei der Übungsplanung | folgt nach 2 |
### 2.b Übrige Konzept-Schritte (noch durchzuarbeiten)
| Schritt | Thema | Status |
|--------|--------|--------|
| **A** | Scope & Reihenfolge relativ zu Kursprogramm, Backlog-Themen | ✅ siehe §5 CURR-001004 |
| **B** | **Governance-Muster** (einheitliche Sichtbarkeit; Bibliothek vs. Instanz) | ✅ Leitplan §2.c; Entscheidungen §5 CURR-005007 |
| **C** | **Rahmenprogramm** — §2.d (**C1C4** ✅ · **C5** Leitplan) | ✅ Kern i.V.m. **CURR009013** |
| **D** | **Kurs-/Stufenprogramm:** nach Rahmenprogramm; plantechnisch ähnlich | 📌 zeitlich nachgelagert (CURR-003) |
| **E** | **Lineage & Feedback** (Einheit ↔ Vorlage/Rahmen; Issues zur Nachbesserung) | **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-/PlanungsUI** 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`**); ReferenzMuster an Übung angleichen (**CURR007** erledigt für dieses Feld) |
| `training_framework_programs` | `visibility`, `club_id`, `created_by`; Kontext `focus_area_id`, `style_direction_id`; keine Kopf`group_id`; SlotInhalt über **Blueprint`training_units`** (**036037**) |
| `training_units` | `group_id`, `created_by`, `plan_template_id`**Instanz** oder **Blueprint** (`framework_slot_id`); LineageLight **`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 20260429
| Check | Ergebnis | Verweis |
|-------|----------|---------|
| **C1** | ✅ **Eigene Rahmen-Entität** (C1a) — nicht die Einheitenvorlage überladen | **CURR009** |
| **C2** | ✅ Slots erlauben **beliebige Übungen** über Trainings-/Slots zu verteilen; **persistenter Progressionsgraph** ist **unterstützend**, **PflichtPflege** im Graph gilt **nicht** (AdminAufwand; v.a. global) | **CURR010**, **CURR013** |
| **C3** | ✅ Über denselben Planungszeitraum **mehrere** gleichzeitige **Entwicklungsziele** (nicht nur ein Sammelfeld) | **CURR011** |
| **C4** | ✅ **Zwei Nutzungsbilder**, kein „entweder C4a oder C4b global“ — siehe Ausführungen unten zu **Konkret** vs. **Bibliothek** | **CURR012** |
| **C5** | ✅ `training_plan_template` bleibt **eineEinheitMikrovorlage**; Rahmen adressiert **n** Sessions; pro Slot weiterhin möglich: **optional** `training_plan_template_id` (Technical Spec entscheidet MVPPflicht) | Glossar |
---
#### C4 verständlich: **„Materialisierung“** = zwei echte Situationen
| Nutzungsbild | Kontext | Gruppe / Datum | Typische PersistenzSchicht |
|--------------|---------|----------------|----------------------------|
| **A Kurzfrist / Basisplanung („nächste Wochen“)** | Konkret für **eine Trainingsgruppe** geplant | **Immer:** Gruppe + Termin(e) wie heute beim Training | Existierende oder neu angelegte **`training_units`**; Rahmen fungiert als **Planhilfe**/Vorlage **über mehrere dieser Einheiten** |
| **B Kursprogramm-/LehrplanBibliothek** | Übergeordnete **Struktur** ohne laufenden Kurs | **In der Bibliotheksvorlage selbst oft:** keine Gruppe/Zeit | Rahmen-/KursVorlage **zeit und gruppenlos** gespeichert; **Übertrag** (`Instanziierung`) erst bei „wir machen einen Kurs daraus“: dann Wahl **Gruppe + Zeitraum** → Anlegen oder Befüllen von **`training_units`** |
**Klartext zur früheren Frage „C4a vs. C4b“:** Bei **ModusA** passen **automatisches Anlegen n Einheiten** (früheres C4a) **oder** Zuordnung zu **bereits geplanten** Einheiten (C4b) — je nach Produkt/UI. Bei **ModusB** existieren erst bei der Übernahme überhaupt Gruppe/Zeiten; die Bibliotheksvorlage bleibt **neutral**.
**Stand Code 036037:** Am Rahmenkopf gibt es **keine** **`plan_mode`/`group_id`** mehr — die Bibliothek ist immer „ModusB“; konkrete Gruppe/Zeit entstehen **nur** in **`training_units`** (KalenderZeilen oder ÜbernahmeAPI **`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 MikroVorlage)?“ — **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 RahmenEntität bestätigt (Chat 20260429).
---
*Konkretisierung technischer Felder (ein Objekt zwei Modi vs. zwei Typen) → **Technical Spec**; keine neuen Contradicts zu CURR001 ohne expliziten Beschluss.*
---
## 3. Richtungen aus Diskussion (nicht-binding, wenn nicht in §5)
| Thema | Richtung |
|--------|----------|
| Assessments als Plantyp | **Spezielle Form eines Trainingsplans**; Test-Übungen; später **Sportlerbezug**/Erwartungsniveau (Gürtel) — aktuell **Backlog**, siehe CURR-003. |
| Bibliothek vs. Durchführung | Konkrete Pläne/Einheiten editierbar **ohne** Änderung am Standard-/Rahmen-Template (**Kopien / Instanzen**). |
*Scope-Reihenfolge und Governance-Startpunkt sind ab 2026-04-29 in §5 festgehalten (CURR-001 bis CURR-004).*
---
## 4. Glossar (wird ergänzt)
| Begriff | Bedeutung (vorläufig) |
|---------|------------------------|
| **Bibliotheksobjekt** | Zeitlose Vorlage (Übung, Trainingsplan-Vorlage, Kursrahmen, später Assessment-Vorlage …) |
| **Governance-Kern** | Einheitliches Minimalset an Metadaten für Bibliotheksobjekte: v.a. `visibility`, `club_id`, `created_by` (siehe §2.c) |
| **Instanz (Training)** | `training_unit` o. Ä.; Zugriff über Gruppe/Rolle; Inhalt als **Kopie** aus Vorlagen, nicht schreibend an Vorlage gekoppelt |
| **Trainingsrahmenprogramm** | Über **mehrere Session-Slots**: **mehrere gleichzeitige Entwicklungsziele** und **Zuordnung von Übungen** zu Slots/Einheiten; **manuelle** Zuordnung + optional **Progression** (**CURR010**, **CURR011**, **CURR013**) |
| **Progressionsbaum / -graph** | Optionale gerichtete Beziehungen **zwischen Übungen** zur **Unterstützung** beim Planen (**CURR010** — **kein PflichtPflegeschritt für jede Zuordnung**); v. a. für globale Pflege |
| **Rahmen-Vorlage** („Framework“) | Bibliothekskontainer mit **ordered Slots**, **zeitlos möglich** (Modus B) oder im Konkretkontext an Gruppe geknüpfte Planung (**CURR012**); eigene Entität (**CURR009**) |
| **Slot** | Position in der Reihenfolge eines Rahmens; trägt **Übungszuweisungen** („Stückliste“), optional Hinweise/Text; Datum optional bis Materialisierung |
| **Materialisierung / Instanziierung** | Überführung aus (ggf. zeit/gruppenloser) **Rahmen-Bibliothek** in konkrete **`training_units`** mit Gruppe/Zeitraum — **CURR012 ModusB**. ModusA bleibt nahe bestehender Einheiten-Planung |
| **Konkret-Planung (Modus A)** | MehrWochen für **bekannte** Trainingsgruppe + Terminen — **`training_units`** |
| **Bibliotheks-Rahmen (Modus B)** | Strukturierte Vorlage ohne Gruppe/Uhrzeit („Kursprogramm“Wurzel bis zum Import in einen Kurs) |
| **Kursprogramm** | Wie Curriculum-/Stufen-Standard; **planerisch nachgelagert** an Rahmenprogramm (CURR-003) |
---
## 5. Entscheidungsprotokoll (binding)
| ID | Datum | Entscheidung | Begründung / Kontext |
|----|--------|---------------|---------------------|
| **CURR-013** | 2026-04-29 | Präzisierung zu **CURR002 (1)+(2)**: **Persistenter Progressionsgraph** ist **unterstützend**, **nicht** die alleinige Quelle für Slotgeplante Übungen. Der Planungsmodus **muss beliebige Übungen** auf Slots verteilen **ohne** Pflicht zur GraphPflege im Alltag. Reichhaltiges Pflegen von Progressionsbezügen v.a. durch **globale Verwalter** erwünscht, nicht verpflichtend pro Übungszuordnung. | Chat C2 |
| **CURR-012** | 2026-04-29 | **Zwei Nutzungsbilder:** **ModusA (Konkret)** — MehrWochenplan für **bekannte Trainingsgruppe + Termine** → bestehende/neue **`training_units`**; Rahmen als Planhilfe über mehrere Einheiten. **ModusB (Bibliothek)****Kurs-/StufenStruktur ohne** Gruppe/Uhrzeit bis zur **Übernahme**; dann Zuordnung von Gruppe+Zeitraum und **Instanziierung** in **`training_units`**. BulkAnlegen (**C4a**) und Verknüpfen existierender (**C4b**) sind **ModusAAlternativen**. | Chat C4 |
| **CURR-011** | 2026-04-29 | **Mehrere parallele Entwicklungsziele** im selben Planungszeitraum → Datenmodell: **Zielliste mit ≥1 Einträgen** auf RahmenEbene (Details Technical Spec). **Nicht** nur ein einziges SammelzielFeld. | Chat C3 |
| **CURR-010** | 2026-04-29 | **SlotInhalt:** tragend **direkte Zuordnung beliebiger Übungen** („Stückliste“); Option **Graph** für Vorschläge/Anreicherung. **`training_plan_template_id` pro Slot** weiterhin **optional** (MVP offen). | Chat C2 |
| **CURR-009** | 2026-04-29 | **C1a:** **Neue eigene BibliotheksEntität** für MehrSlotRahmen (**Framework**/`training_framework_*`-Arbeitscode); **`training_plan_template`** bleibt **eine Einheit**Mikrovorlage (**C5**). | Chat C1 |
| **CURR-008** | 2026-04-29 | **Migration / Backfill (Early-Installation):** Migrationen betreffen aktuell nur **frühe Systeme ohne weitere Nutzer**. VereinsZuordnung für Bestands-/DefaultZeilen erfolgt beim Backfill mit dem **StandardVerein der Installation** (konkret: ClubID bzw. Konvention im MigrateSkript dokumentieren — z.B. erster Verein oder `DEFAULT_CLUB_ID` in Env). **`visibility`**-Default beim Hinzufügen der Spalte: **`club`**, wenn fachlich alles diesem Vereinskontext zugeordnet wird; anderenfalls bei MultiTenant eigene MigrateAnweisung. | Nutzerfestlegung; pragmatisches Backfill ohne MehrMandantenHeuristik; §6 entsprechend vereinfacht. |
| **CURR-007** | 2026-04-29 | **`training_plan_templates`** weichen aktuell vom Übungs-Muster ab (**kein** `visibility`). **Festlegung:** Bei der nächsten sinnvollen Migration auf den **gemeinsamen Governance-Kern** angleichen (**`visibility`** zusätzlich zu `club_id` / `created_by`), Semantik **analog zu Übungen** im Vereinskontext; von dieser Linie nur abweichen, wenn ausdrücklich anders dokumentiert. | Bekannte Schulden bis Migration vermeiden; neue Objekttypen sollen CURR-005 folgen; Zuordnung/Backfill **CURR008**. |
| **CURR-006** | 2026-04-29 | **Instanz-Ebene (`training_unit` u. Ä.):** In der Rahmenprogramm-Phase **keine** neue parallele `visibility`-Schicht auf der Einheit; **Zugriff** über **`group_id`** und bestehende Trainer-/Mitgliedschaftslogik. **Lineage** zu Vorlagen/Rahmen nur als **optionale Metadaten-FKs** (`plan_template_id`, spätere Erweiterungen), ohne dass Schreiben in der Einheit die Vorlage ändert. | CURR-004-kompatibel: API-Policy später ergänzbar ohne Instanz-Umbau. |
| **CURR-005** | 2026-04-29 | **Governance-Kern für Bibliotheksobjekte** (Übung, neue/alte Vorlagen, künftig Rahmen-/Kurs-/Progressions-Container): **`visibility`** im Sinne von `exercises` (`private` \| `club` \| `official`), **`club_id`** optional (NULL wenn nicht vereinsspezifisch), **`created_by`**. Sparte später optional **`division_id`** oder Verknüpfungstabelle — **nicht** Blocker für ersten Progressions-/Rahmen-Entwurf. | Einheitliche Semantik; Altabweichungen gezielt nachziehen (CURR-007). |
| **CURR-004** | 2026-04-29 | **Sichtbarkeit:** Aktuell **globale Nutzungs-/Planungssicht** für alle; Architektur und Datenmodell aber **von Anfang an** so gestalten, dass **spätere** Einschränkungen nach Rollen und Zugehörigkeiten (Verein, Gruppe, Sparte …) ohne Bruch eingeführt werden können. | Vorbereitung einheitlicher Governance; siehe CURR-005/006 für Konkretisierung. |
| **CURR-003** | 2026-04-29 | **Nachgelagert / explizites Backlog (nicht Phase Rahmenprogramm):** **Kursprogramm** kommt nach dem Rahmenprogramm (planerisch ähnlich); **Assessments**, **Sportlerakte**, **KI-Optimierungen** ebenfalls zurückgestellt bis Rahmenkern steht. | Priorität liegt auf Persistenz Progression → Multi-Einheiten-Planung → Einheitenvorschläge. |
| **CURR-002** | 2026-04-29 | **Umsetzungsreihenfolge Rahmenprogramm:** **(1)** Progressionsbezüge **zwischen Übungen** müssen als **persistierter Graph/Baum** modellierbar sein. **(2)** **Planungsmodus**, der **Übungen** (u.a. aus Progression, **auch manuell**, **CURR010**) **auf mehrere Trainingseinheiten verteilt**, **mehrere Ziele** (**CURR011**) enthält und als **speicherbares RahmenTemplate** dient. **(3)** **Warenkorb**-Idee beim Ausarbeiten einer **einzelnen** Einheit. | Reihenfolge vom Datenkern zur UX; Zuordnung/Graph **CURR013** |
| **CURR-001** | 2026-04-29 | Vor dem separaten Produkt **„Kursprogramm“** wird das **„Trainingsrahmenprogramm“** (Ziele + Progression über mehrere Einheiten) angegangen — **nicht** umgekehrt. | Kursprogramm baut auf derselben Planungslogik auf; erst gemeinsamen Kern liefern. |
*Format:* Neue Zeile **oben** einfügen (neueste zuerst).
---
## 6. Offene Fragen (Backlog)
- **Minimal-UI** Rahmenprogramm vs. bestehende Kalender/Liste `training_units`?
- ~~Governance Migrate Default~~ → **CURR008**
- ~~Slots / C4 generisch~~**CURR012** (Modi A/B)
- ~~Relation zwei Vorlagenfamilien~~**CURR009** (**Rahmen** neu, **Einheit** bleibt `training_plan_template`)
- **Technical:** ~~gleiche DBEntität mit `plan_mode` (**A \| B**) vs. **konsequente Teilung** zweier Objekttypen?~~**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 · **CURR009 bis CURR013**
2. ~~Progressionsgraph Stufe1~~ ✅ siehe **`technical/TRAINING_FRAMEWORK_SPEC.md`** §3§4 · **Jetzt:** **`TRAINING_FRAMEWORK_SPEC.md`** §2 (Checkliste) mit **DDL-/API-Abschnitt Rahmen** ergänzen (**CURR002 (2)**); ModusA/B siehe Funktionskonzept §6
3. **Migrate** weiter **CURR007 / CURR008** (ideal parallel oder vor erster RahmenMigration mit neuem Bibliothekstyp)
4. Konzeptpaket optional **Schritt E** Lineage vor Implementierung Großrelease
---
## 9. Changelog dieser Datei
| Datum | Änderung |
|-------|-----------|
| 2026-04-30 | §8 Punkt2 angepasst (Graph ✅; nächster Fokus RahmenSpec **CURR002 (2)**). |
| 2026-04-28 | Technische Ausarbeitung gebündelt: neue Datei **`technical/TRAINING_FRAMEWORK_SPEC.md`** (Stub); Verweis §8. |
| 2026-04-29 | **CURR009013**, **CURR002** präzisiert; Glossar Modi A/B Slot; §2.d C geklärt; §6Backlog gekürzt. |
| 2026-04-29 | CURR008 (Migration StandardVerein); **§2.d Schritt C** Checkpoints C1C5; Glossar/§6 angepasst. |
| 2026-04-29 | CURR-001004; Umsetzungsreihenfolge §2.a; Glossar Rahmenprogramm/Progressionsgraph; Scope-Backlog. |
| 2026-04-28 | Erstanlage aus Konzept-Arbeitsphase Chat; Schritttabelle und Protokollstruktur. |

View File

@ -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**. TrainingsrahmenBibliothek + SlotBlueprint: **`technical/TRAINING_FRAMEWORK_SPEC.md`** §2. **Progressionsgraph zwischen Übungen** (Zwischenstand, Grenzen): **§§34**. 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 |
|-----------|--------|
| **032034** | **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 (basisoptimierung), `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 GraphErsteller; 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`**.
- **BlueprintZeilen (`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 KalenderEinheit).
- Ü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 LineageLight.
---
## 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 (`<details>`), **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 + SlotBlueprint (DB **036037**)
- **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`; SlotVerknüpfungen zu KalenderEinheiten 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`** (TrainingsrahmenAbschnitt).
---
## 12. Nächste sinnvolle Schritte (nicht Lieferstand)
- Trainingsplanung: KalenderUIAnbindung **„aus Rahmen übernehmen“**; Visibility/Policies für geteilte Rahmen (**CURR004** 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` |

View File

@ -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** (RahmenSlotBlueprints 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 |
| 024031 | *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** | **SlotBlueprint:** `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 **035036**)
Kopf ohne Gruppenbindung (`training_framework_programs`), Ziele, Slots. Slotspezifischer 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 & RahmenBlueprint (Migrationen 006, 031, **037**)
Geplante Einheit und **RahmenSlotBlueprint** teilen sich **`training_units`** und den strukturierten Ablauf über **Sektionen** (031). BlueprintZeilen haben **`framework_slot_id`** gesetzt (genau eine Zeile pro Slot); KalenderZeilen 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 KalenderZeilen (CHECK)
planned_date DATE NULL, -- Pflicht für KalenderZeilen (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 032034)
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

View File

@ -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.
---

View File

@ -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 032034); 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

View File

@ -19,6 +19,8 @@
**Basis:** Migrationen 001-013 (bereits deployed)
**Progressionsgraph zwischen Übungen:** Migrationen **032034** — nicht Bestandteil dieses „Exercise Catalog“-Schemas-Dokuments; siehe **`TRAINING_FRAMEWORK_SPEC.md`** §3 und **`DATABASE_SCHEMA.md`** (Migrationshistorie).
---
## 2. Migration 014: Variant Progression + Search

View File

@ -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)

View File

@ -0,0 +1,193 @@
# Trainingsrahmenprogramm — Technische Spezifikation
**Status:** RahmenBibliothek + SlotBlueprint dokumentiert · **Stand:** 2026-05-05 (Migration **036037**)
**Bindendes Fachkonzept / Entscheide:** `.claude/docs/functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR001 bis CURR013)
**Relevant für nächsten Schritt:** CURR002 **(2)** Trainingsplanung / Rahmen über mehrere Einheiten — der hier dokumentierte **Progressionsgraph Stufe 1** ist bewusst **unterstützend**, keine Pflicht für Slot-Zuordnungen (**CURR013**).
---
## 1. Abgrenzung zu anderen Dokumenten
| Dokument | Rolle · warum **nicht** hier hineinmischen |
|----------|--------------------------------------------|
| `EXERCISES_DATABASE_FINAL.md`, `EXERCISES_ARCHITECTURE.md`, `EXERCISES_API_SPEC.md` | **Übungskatalog** inkl. Varianten-Progression **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, CURRTabelle). |
**Konsequenz:** Diese Datei bleibt der **technische Arbeitspool** für Rahmenprogramm Stufe 12. Abschnitt **§4** beschreibt explizit den **aktuellen Produktfreigabe-Umfang** und **bekannte Lücken** (damit Trainingsplanung weiter gebaut werden kann ohne falscher Erwartung an „AlternativePakete“ in der UI).
---
## 2. Rahmenprogramm (CURR002 Stufe2) — Checkliste & technische Ausarbeitung
### 2.0 Technische Entscheidung: nur Bibliothek + SlotBlueprint = `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).
**SlotInhalt (Migration 037):** Pro `training_framework_slot` existiert genau eine **BlueprintZeile** 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 BlueprintUnit (**tiefe Kopie**) mit **`group_id` + `planned_date`**; **`origin_framework_slot_id`** hält die Herkunft (LineageLight). **`GET /api/training-units`** blendet Einheiten mit **`framework_slot_id IS NOT NULL`** aus (Kalender/APIListe ohne RahmenBlueprints).
**CHECKConstraint auf `training_units`:** Zeile ist entweder **Blueprint** (`framework_slot_id` gesetzt, `group_id`/`planned_date` NULL, kein `origin_framework_slot_id`) oder **KalenderEinheit** (`framework_slot_id` NULL, `group_id` und `planned_date` gesetzt; `origin_framework_slot_id` optional).
**Konsequenz KonzeptCURR012 („concrete/library“):** Persistiert wird **ein** Kopf ohne Modus-Spalte: Immer BibliotheksRolle; Konkretisierung nur über Planung/APIKopie. 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` (**CURR009**); `training_plan_templates` unverändert **eineEinheitMikrovorlage** (**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 (**CURR011**).
- [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:** Stufe1 (**§3§4**); **kein Pflichtbezug** pro Slot (**CURR013**).
- [x] **Kein LiveWrite** von Kalendereinheiten zurück in die Vorlage (**CURR006**); Konkretisierung = **Kopie** (siehe **§2.4**).
- [x] **Instanziierung (MVP):** `POST /api/training-units/from-framework-slot` — weiterer Ausbau: Bulk, KalenderUIFlow, **`training_plan_template_id` pro Slot** weiterhin optional/deferred (**CURR010**).
- [x] **Governance:** `visibility`, `club_id`, **`training_plan_templates.visibility`** (**035**) — (**CURR005008**).
- [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. KalenderZeile)
-- 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`, KontextCounts |
| 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 BlueprintUnits) |
| DELETE | `/training-framework-programs/{id}` | Rahmen + Kinder |
**AuthZ:** wie zuvor — Planungsrolle zum Schreiben; Lesen/Schreiben/Löschen des Rahmens: Admin/Superadmin oder Ersteller.
**PayloadHinweise (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 **KalenderEinheiten** (`framework_slot_id IS NULL`) |
| GET/PUT | `/training-units/{id}` | Blueprint lesen/bearbeiten: möglich mit RahmenAuth; spezielle Regeln im **PUT** (kein TemplateReset, 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 **032034**.
- **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 JoinFelder **`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“.
- PanelFunktionen: **SequenzEditor** (mehrere Schritte → ein BulkSpeichern), zusammengefasste **ReihenLesart** für `next_exercise`, eigene Liste für **Schwestern**, Einzelkantenbereich, Tab **Alle Kanten (Tabelle)**.
- APIClient: `frontend/src/utils/api.js` (`createExerciseProgressionSequence`, `deleteExerciseProgressionEdgesBatch`, …).
---
## 4. Zwischenstand für Produkt / Trainingsplanung (bewusste Grenzen)
**Freigabe:** Der beschriebene Stand unterstützt **RahmenBibliothek mit vollem Ablauf pro Slot** (wie Planung) und **Kopie in die Gruppenplanung**; der **Progressionsgraph** bleibt **unterstützend** (**CURR013**). Offen: KalenderUIFlow, BulkInstanziierung, erweiterte Lineage/Feedback (**Konzept Schritt E**).
**Was gut nutzbar ist**
- Lineare **Reihen** mehrerer Übungen (bzw. VariantenKnoten) über **SequenzAPI** bzw. SequenzUI.
- **NachfolgerLesart** als zusammenhängende Kette in der Übersicht.
- **SchwesterKanten** 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 ersteKlassUX modelliert: mehrere Nachfolger aus einem Knoten sind technisch möglich (mehrere `next_exercise`Kanten), aber **keine** dedizierte Gruppe „AlternativSet“. Pflege kann mehrzeilig und koplastisch wirken.
- **Visualisierung echter Bäume** (JoinPoints, mehrere ausgehende Pfeile in einem Bild) ist nur eingeschränkt über ReihenZusammenfassung + Tabelle abbildbar.
**Nächste sinnvolle Ausbaustufen** (Backlog GraphUX, nicht Blocker Planung)
- Semantik **`alternative_group_id`** oder **Hyperkanten** (ein UXSchritt legt mehrere Kanten mit gemeinsamer Gruppe an).
- Komfort beim Pflegen **symmetrischer Schwestern** (ein Klick für zwei Richtungen / Dedupe).
- Karten-/BaumLayout 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 | **CURR002 (2):** §2 Rahmenprogramm — Entscheid **eine Tabelle + `plan_mode`**, DDLSkizze, RESTÜberblick; Migration **035**; `training_plan_templates.visibility`. *(Historisch — Modus-Spalten durch **036** ersetzt.)* |
| 2026-04-30 | **Zwischen-Doku:** §3 auf Migrationen 032034 + API **sequence/delete-batch** + Frontend erweitert; **§4** Produktfreigabe vs. Lücken (parallele Alternativen); Changelog §5. |
| 2026-04-30 | §3: erste Fassung Migration 032 + RESTBasis (CURR002 (1)). |
| 2026-04-28 | Erstanlage Stub mit Checkliste. |

View File

@ -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**, DBSchemaVersion **`20260505037`**; Kern: Übungen, Varianten, Medien, Planung mit Sektionen, **Trainingsrahmen Bibliothek + SlotBlueprint** (036037), Progressionsgraph, Reifegrad/MatrixStack — Details `PROJECT_STATUS.md` und `TRAINING_FRAMEWORK_SPEC.md` §2.
### Log (Auszug)
- 2026-05-05: Rahmen nur Bibliothek (**036**), SlotAblauf = `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` — KalenderInstanzen **und** RahmenSlotBlueprints (`framework_slot_id` ab **037**)
- `training_framework_programs` + Ziele + Slots (Migration **035036**) — BibliotheksRahmen
- Legacy: `training_templates` / `section_exercises` o. ä. — in älteren Skizzen; produktiver Pfad siehe Migrationen **006**/**031**
**Governance:**
- `content_change_requests` - Änderungsanfragen

View File

@ -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)

View File

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

View File

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

View File

@ -0,0 +1,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
);

View File

@ -0,0 +1,92 @@
-- Migration 035: Trainingsrahmenprogramm (RahmenVorlage, CURR002 Stufe 2 / CURR009013)
-- + CURR007/008: training_plan_templates.visibility (Backfill club, dann NOT NULL + Default)
-- ── TrainingsMikrovorlagen: gemeinsamer GovernanceKern (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);
-- ── RahmenHeader ────────────────────────────────────────────────────────────
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 (CURR011: ≥ 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“, CURR010) ────────────────────────
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);

View File

@ -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;

View File

@ -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
)
);

View File

@ -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;

View File

@ -0,0 +1,507 @@
"""
Progressionsgraph zwischen Übungen (Übung Übung), Migration 032034.
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 n1 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}

View File

@ -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}

View File

@ -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"]

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.4"
BUILD_DATE = "2026-04-27"
DB_SCHEMA_VERSION = "20260428031"
APP_VERSION = "0.8.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",

View File

@ -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 & PlanungsBlueprint (kurz)
- **Migration 036:** Rahmenkopf nur Bibliothek (Kontext: Fokusbereich, Stilrichtung; M:N Trainingsarten/Zielgruppen); keine `plan_mode`/keine Kopf`group_id`.
- **Migration 037:** Pro RahmenSlot 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. 024027 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. 024027 Reifegrad/Bindings; **035037** Rahmenprogramm / SlotBlueprint) |
| 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`**.

View File

@ -5,16 +5,21 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="description" content="Shinkan Jinkendo - Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung" />
<!-- PWA Meta Tags -->
<!-- PWA / iOS Web App -->
<meta name="theme-color" content="#1D9E75" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#181816" media="(prefers-color-scheme: dark)">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<!-- black-translucent: Inhalt bis unter die Statusleiste; passt zu viewport-fit=cover -->
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Shinkan">
<link rel="apple-touch-icon" href="/icon-192.png">
<link rel="manifest" href="/manifest.webmanifest" />
<!-- Icons -->
<!-- Icons (Dateien in public/) -->
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png">
<link rel="shortcut icon" href="/favicon.ico">
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png">
<title>Shinkan Jinkendo</title>
</head>

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 642 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -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"
}
]
}

View File

@ -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() {
</Route>
<Route path="clubs" element={<ClubsPage />} />
<Route path="skills" element={<SkillsPage />} />
<Route path="planning" element={<TrainingPlanningPage />} />
<Route path="planning/framework-programs/new" element={<TrainingFrameworkProgramEditPage />} />
<Route path="planning/framework-programs/:id" element={<TrainingFrameworkProgramEditPage />} />
<Route path="planning/framework-programs" element={<TrainingFrameworkProgramsListPage />} />
<Route path="planning/run/:unitId/coach" element={<TrainingCoachPage />} />
<Route path="planning/run/:unitId" element={<TrainingUnitRunPage />} />
<Route path="planning" element={<TrainingPlanningPage />} />
<Route path="admin" element={<Navigate to="/admin/hierarchy" replace />} />
<Route path="admin/hierarchy" element={<AdminHierarchyPage />} />
<Route path="admin/maturity-models" element={<AdminMaturityModelsPage />} />

File diff suppressed because it is too large Load Diff

View File

@ -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 && <p style={{ color: 'var(--danger)' }}>{err}</p>}
{!loading && exercise && (
<>
{variant ? (
<div
style={{
marginBottom: '0.75rem',
padding: '8px 10px',
borderRadius: '8px',
background: 'var(--surface2)',
border: '1px solid var(--border)',
}}
>
<div style={{ fontSize: '0.78rem', fontWeight: 700, color: 'var(--text3)', marginBottom: 4 }}>
Variante
</div>
<div style={{ fontWeight: 700, fontSize: '0.95rem' }}>
{variant.variant_name || `Variante #${variant.id}`}
</div>
{variant.description ? (
<div style={{ marginTop: 8, fontSize: '0.9rem', color: 'var(--text2)' }}>
<HtmlBlock html={variant.description} />
</div>
) : null}
{variant.execution_changes ? (
<div style={{ marginTop: 10 }}>
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', marginBottom: 6 }}>
Durchführung (Variante)
</h4>
<HtmlBlock html={variant.execution_changes} />
</div>
) : null}
</div>
) : null}
{exercise.summary && (
<div style={{ fontSize: '0.95rem', color: 'var(--text2)' }}>
<HtmlBlock html={exercise.summary} />

View File

@ -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()}
>
<div className="admin-modal-sheet__header">
<h3 className="admin-modal-sheet__title">Übung auswählen</h3>
<h3 className="admin-modal-sheet__title">
{multiSelect ? 'Übungen auswählen' : 'Übung auswählen'}
</h3>
<button type="button" className="btn btn-secondary admin-modal-sheet__close" onClick={onClose}>
Schließen
</button>
@ -391,29 +407,16 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
{list.length} angezeigt{hasMore ? ' · weiter unten „Mehr laden“' : ''}
</p>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{list.map((ex) => (
<li key={ex.id}>
<button
type="button"
onClick={() => {
onSelectExercise(ex)
onClose()
}}
style={{
width: '100%',
textAlign: 'left',
padding: '10px 12px',
marginBottom: 8,
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
cursor: 'pointer',
}}
>
{list.map((ex) => {
const picked = multiPicked.some((p) => p.id === ex.id)
const rowInner = (
<>
<strong style={{ display: 'block' }}>{ex.title}</strong>
{(ex.summary || '').trim().length > 0 && (
<span style={{ fontSize: '12px', color: 'var(--text2)' }}>
{(ex.summary || '').length > 120 ? `${(ex.summary || '').slice(0, 120)}` : ex.summary}
{(ex.summary || '').length > 120
? `${(ex.summary || '').slice(0, 120)}`
: ex.summary}
</span>
)}
{ex.focus_area && (
@ -421,9 +424,64 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
{ex.focus_area}
</span>
)}
</button>
</li>
))}
</>
)
if (multiSelect) {
return (
<li key={ex.id}>
<label
className="tu-ex-picker-multi-row"
style={{
display: 'flex',
gap: '10px',
alignItems: 'flex-start',
width: '100%',
textAlign: 'left',
padding: '10px 12px',
marginBottom: 8,
borderRadius: '8px',
border: picked ? '2px solid var(--accent)' : '1px solid var(--border)',
background: 'var(--surface2)',
cursor: 'pointer',
boxSizing: 'border-box',
}}
>
<input
type="checkbox"
checked={picked}
onChange={() => toggleMultiPick(ex)}
style={{ marginTop: '0.35rem', flexShrink: 0 }}
aria-label={ex.title ? `Auswahl: ${ex.title}` : 'Auswahl'}
/>
<div style={{ flex: 1, minWidth: 0 }}>{rowInner}</div>
</label>
</li>
)
}
return (
<li key={ex.id}>
<button
type="button"
onClick={() => {
onSelectExercise(ex)
onClose()
}}
style={{
width: '100%',
textAlign: 'left',
padding: '10px 12px',
marginBottom: 8,
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
cursor: 'pointer',
}}
>
{rowInner}
</button>
</li>
)
})}
</ul>
{hasMore && (
<div style={{ textAlign: 'center', marginTop: 12 }}>
@ -432,6 +490,49 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
</button>
</div>
)}
{multiSelect && typeof onSelectExercises === 'function' ? (
<div
className="exercise-picker-multi-footer"
style={{
position: 'sticky',
bottom: 0,
marginTop: 16,
paddingTop: 12,
borderTop: '1px solid var(--border)',
background: 'var(--surface)',
display: 'flex',
flexWrap: 'wrap',
gap: '10px',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<span style={{ fontSize: '0.92rem', color: 'var(--text2)' }}>
{multiPicked.length} ausgewählt
</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
<button
type="button"
className="btn btn-secondary"
onClick={() => setMultiPicked([])}
disabled={!multiPicked.length}
>
Auswahl leeren
</button>
<button
type="button"
className="btn btn-primary"
disabled={!multiPicked.length}
onClick={() => {
onSelectExercises([...multiPicked])
onClose()
}}
>
Übernehmen
</button>
</div>
</div>
) : null}
</>
)}
</div>

File diff suppressed because it is too large Load Diff

View File

@ -23,8 +23,9 @@ function Navigation() {
zIndex: 1000
}}>
<div style={{
maxWidth: '1200px',
margin: '0 auto',
width: '100%',
maxWidth: 'none',
margin: '0',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',

View File

@ -0,0 +1,910 @@
import React, { Fragment, useCallback, useEffect, useState } from 'react'
import { GripVertical, Pencil } from 'lucide-react'
import {
defaultSection,
exerciseRow,
noteRow,
sectionPlannedMinutes,
} from '../utils/trainingUnitSectionsForm'
const DND_TU_ITEM = 'application/x-shinkan-training-unit-item'
const DND_TU_SECTION = 'application/x-shinkan-training-section-v1'
function dtHasType(e, mime) {
const t = e?.dataTransfer?.types
if (!t || !mime) return false
if (typeof t.contains === 'function' && t.contains(mime)) return true
return Array.from(t).includes(mime)
}
function truncatePreview(text, max = 160) {
const t = (text || '').replace(/\s+/g, ' ').trim()
if (t.length <= max) return t
return `${t.slice(0, max - 1)}`
}
function reorderBlocksImmutable(blocks, fromI, toBeforeIdx) {
const b = [...blocks]
if (fromI < 0 || fromI >= b.length) return blocks
const [moved] = b.splice(fromI, 1)
let insertAt = toBeforeIdx
if (fromI < toBeforeIdx) insertAt = toBeforeIdx - 1
insertAt = Math.max(0, Math.min(insertAt, b.length))
b.splice(insertAt, 0, moved)
return b
}
/**
* @param {(updater: (prev: Array) => Array) => void} props.onSectionsChange wie React setState
* @param {(p: { fromSlot: number, fromSectionIdx: number, toSlot: number, toSectionIdx: number }) => void} [props.onMoveSectionsAcrossSlots] Rahmenprogramm: Abschnitt zwischen Slots verschieben
*/
export default function TrainingUnitSectionsEditor({
sections,
onSectionsChange,
onRequestExercisePick,
onPeekExercise,
showExecutionExtras = false,
heading = 'Abschnitte & Übungen',
hideHeading = false,
headingAccessory = null,
wideExerciseGrid = false,
enableItemDragReorder = true,
enableSectionDragReorder = true,
slotIndex = null,
onMoveSectionsAcrossSlots = null,
}) {
const ensure = (prev) =>
prev && prev.length ? prev : [defaultSection()]
const patch = useCallback(
(updater) => {
onSectionsChange((prev) => updater(ensure(prev)))
},
[onSectionsChange]
)
const sectionToSlot =
slotIndex !== null && slotIndex !== undefined ? Number(slotIndex) : -1
const updateSectionField = (sIdx, field, val) => {
patch((prev) =>
prev.map((s, i) => (i === sIdx ? { ...s, [field]: val } : s))
)
}
const addSection = () => {
patch((prev) => [...prev, defaultSection(`Abschnitt ${prev.length + 1}`)])
}
const removeSection = (sIdx) => {
patch((prev) => {
const next = prev.filter((_, i) => i !== sIdx)
return next.length ? next : [defaultSection()]
})
}
const moveSection = (sIdx, dir) => {
patch((prev) => {
const p = [...prev]
const ta = sIdx + dir
if (ta < 0 || ta >= p.length) return p
;[p[sIdx], p[ta]] = [p[ta], p[sIdx]]
return p
})
}
const addItem = (sIdx, kind) => {
patch((prev) =>
prev.map((s, i) =>
i !== sIdx
? s
: {
...s,
items: [...(s.items || []), kind === 'note' ? noteRow() : exerciseRow()],
}
)
)
}
const removeItem = (sIdx, iIdx) => {
patch((prev) =>
prev.map((s, si) =>
si !== sIdx ? s : { ...s, items: (s.items || []).filter((_, ii) => ii !== iIdx) }
)
)
}
const moveItem = (sIdx, iIdx, dir) => {
patch((prev) =>
prev.map((s, si) => {
if (si !== sIdx) return s
const items = [...(s.items || [])]
const ta = iIdx + dir
if (ta < 0 || ta >= items.length) return s
;[items[iIdx], items[ta]] = [items[ta], items[iIdx]]
return { ...s, items }
})
)
}
const updateItem = (sIdx, iIdx, field, val) => {
patch((prev) =>
prev.map((s, si) =>
si !== sIdx
? s
: {
...s,
items: (s.items || []).map((row, ii) =>
ii === iIdx ? { ...row, [field]: val } : row
),
}
)
)
}
const [textEdit, setTextEdit] = useState(null)
const [draggingPos, setDraggingPos] = useState(null)
const [dropTargetPos, setDropTargetPos] = useState(null)
const [dropSectionBand, setDropSectionBand] = useState(null)
/** { slot: number, beforeIdx: number } */
useEffect(() => {
if (!textEdit) return
const onKey = (e) => {
if (e.key === 'Escape') setTextEdit(null)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [textEdit])
const clearSectionDnD = () => setDropSectionBand(null)
const onSectionDragStart = (e, sIdx) => {
if (!enableSectionDragReorder) return
e.stopPropagation()
try {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData(
DND_TU_SECTION,
JSON.stringify({
fromSlot: sectionToSlot,
fromSectionIdx: sIdx,
})
)
} catch {
/* ignore */
}
setDropSectionBand(null)
}
const onSectionBandDragOver = (e, beforeIdx) => {
if (!enableSectionDragReorder) return
if (!dtHasType(e, DND_TU_SECTION)) return
e.preventDefault()
e.stopPropagation()
try {
e.dataTransfer.dropEffect = 'move'
} catch {
/* ignore */
}
setDropSectionBand({ slot: sectionToSlot, beforeIdx })
}
const onSectionBandDrop = (e, insertBeforeIdx) => {
if (!enableSectionDragReorder) return
e.preventDefault()
e.stopPropagation()
clearSectionDnD()
let raw = ''
try {
raw = e.dataTransfer.getData(DND_TU_SECTION)
} catch {
return
}
if (!raw) return
let data
try {
data = JSON.parse(raw)
} catch {
return
}
const fromSi = data.fromSectionIdx
const fromSlot = typeof data.fromSlot === 'number' ? data.fromSlot : -1
if (typeof fromSi !== 'number') return
if (
typeof onMoveSectionsAcrossSlots === 'function' &&
sectionToSlot >= 0 &&
fromSlot >= 0
) {
onMoveSectionsAcrossSlots({
fromSlot,
fromSectionIdx: fromSi,
toSlot: sectionToSlot,
toSectionIdx: insertBeforeIdx,
})
return
}
patch((prev) => reorderBlocksImmutable(prev, fromSi, insertBeforeIdx))
}
const onItemDragStart = (e, sIdx, iIdx) => {
if (!enableItemDragReorder) return
e.stopPropagation()
try {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData(
DND_TU_ITEM,
JSON.stringify({ sectionIndex: sIdx, itemIndex: iIdx })
)
} catch {
/* ignore */
}
setDraggingPos({ sIdx, iIdx })
}
const clearDragChrome = () => {
setDraggingPos(null)
setDropTargetPos(null)
}
const onItemDragOverRow = (e, sIdx, iIdx) => {
if (!enableItemDragReorder) return
if (!dtHasType(e, DND_TU_ITEM)) return
e.preventDefault()
e.stopPropagation()
try {
e.dataTransfer.dropEffect = 'move'
} catch {
/* ignore */
}
setDropTargetPos({ sIdx, iIdx })
}
const onItemDropRow = (e, toSIdx, toIdx) => {
if (!enableItemDragReorder) return
e.preventDefault()
e.stopPropagation()
let raw = ''
try {
raw = e.dataTransfer.getData(DND_TU_ITEM)
} catch {
clearDragChrome()
return
}
if (!raw) {
clearDragChrome()
return
}
let data
try {
data = JSON.parse(raw)
} catch {
clearDragChrome()
return
}
const fromS = data.sectionIndex
const fromI = data.itemIndex
if (typeof fromS !== 'number' || typeof fromI !== 'number') {
clearDragChrome()
return
}
if (fromS === toSIdx && fromI === toIdx) {
clearDragChrome()
return
}
patch((prev) => {
const list = ensure(prev)
if (
fromS < 0 ||
fromS >= list.length ||
toSIdx < 0 ||
toSIdx >= list.length ||
typeof toIdx !== 'number'
) {
return prev
}
const fromItems = [...(list[fromS].items || [])]
if (fromI < 0 || fromI >= fromItems.length) return prev
const moved = fromItems[fromI]
fromItems.splice(fromI, 1)
if (fromS === toSIdx) {
let insertAt = toIdx
if (fromI < toIdx) insertAt = toIdx - 1
const bounded = Math.max(0, Math.min(insertAt, fromItems.length))
fromItems.splice(bounded, 0, moved)
return list.map((sec, i) => (i === fromS ? { ...sec, items: fromItems } : sec))
}
const toItems = [...(list[toSIdx].items || [])]
const insertAt = Math.max(0, Math.min(toIdx, toItems.length))
toItems.splice(insertAt, 0, moved)
return list.map((sec, i) => {
if (i === fromS) return { ...sec, items: fromItems }
if (i === toSIdx) return { ...sec, items: toItems }
return sec
})
})
clearDragChrome()
}
const applyTextEdit = () => {
if (!textEdit) return
const { kind, sIdx, iIdx, draft } = textEdit
if (kind === 'zwischen-note') {
updateItem(sIdx, iIdx, 'note_body', draft)
} else if (kind === 'exercise-notes') {
updateItem(sIdx, iIdx, 'notes', draft)
}
setTextEdit(null)
}
const list = ensure(sections)
return (
<div
className={
'training-unit-sections-editor' +
(wideExerciseGrid ? ' training-unit-sections-editor--wide' : '')
}
>
{(!hideHeading || headingAccessory) ? (
<div
className="tu-editor-heading-toolbar"
style={{
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignItems: 'center',
gap: '10px',
marginBottom: '0.75rem',
}}
>
{!hideHeading ? (
<h3 style={{ margin: 0, fontSize: '1rem', flex: '1 1 200px', minWidth: 0 }}>
{heading}
</h3>
) : headingAccessory ? (
<span style={{ flex: '1 1 auto', minWidth: 0 }} />
) : null}
{headingAccessory ? (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
justifyContent: 'flex-end',
alignItems: 'center',
}}
>
{headingAccessory}
</div>
) : null}
</div>
) : null}
{list.map((sec, sIdx) => {
const planMin = sectionPlannedMinutes(sec)
const itemCount = sec.items?.length ?? 0
const bandActiveBefore = (bx) =>
enableSectionDragReorder &&
dropSectionBand &&
dropSectionBand.slot === sectionToSlot &&
dropSectionBand.beforeIdx === bx
return (
<Fragment key={`secFrag-${sIdx}`}>
{enableSectionDragReorder ? (
<div
className={'tu-section-dropband' + (bandActiveBefore(sIdx) ? ' tu-section-dropband--active' : '')}
title="Abschnitt hier einfügen"
onDragOver={(e) => {
if (!enableSectionDragReorder) return
if (!dtHasType(e, DND_TU_SECTION)) return
onSectionBandDragOver(e, sIdx)
}}
onDragLeave={(e) => {
if (e.currentTarget.contains(e.relatedTarget)) return
clearSectionDnD()
}}
onDrop={(e) => onSectionBandDrop(e, sIdx)}
/>
) : null}
<div
className="tu-section-shell"
style={{
marginBottom: '1rem',
padding: '0.75rem',
background: 'var(--surface2)',
borderRadius: '10px',
border: '1px solid var(--border, rgba(0,0,0,0.08))',
}}
>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
marginBottom: '0.5rem',
alignItems: 'flex-start',
}}
>
{enableSectionDragReorder ? (
<span
className="tu-sec-drag-grip"
draggable
onDragStart={(e) => onSectionDragStart(e, sIdx)}
role="button"
tabIndex={0}
aria-label="Abschnitt ziehen"
title="Abschnitt ziehen"
>
<GripVertical size={16} strokeWidth={2} aria-hidden />
</span>
) : null}
<input
className="form-input"
style={{ flex: '2 1 180px', marginBottom: 0 }}
value={sec.title}
onChange={(e) => updateSectionField(sIdx, 'title', e.target.value)}
placeholder="Abschnittstitel (z. B. Aufwärmen)"
/>
<div style={{ display: 'flex', gap: '4px', alignSelf: 'center' }}>
<button
type="button"
aria-label="Abschnitt hoch"
onClick={() => moveSection(sIdx, -1)}
disabled={sIdx === 0}
style={{ padding: '4px 10px', opacity: sIdx === 0 ? 0.35 : 1 }}
>
</button>
<button
type="button"
aria-label="Abschnitt runter"
onClick={() => moveSection(sIdx, 1)}
disabled={sIdx === list.length - 1}
style={{
padding: '4px 10px',
opacity: sIdx === list.length - 1 ? 0.35 : 1,
}}
>
</button>
</div>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => removeSection(sIdx)}
>
Abschnitt entfernen
</button>
</div>
<textarea
className="form-input"
rows={2}
value={sec.guidance_notes}
onChange={(e) =>
updateSectionField(sIdx, 'guidance_notes', e.target.value)
}
placeholder="Hinweise zum Abschnitt (Material, Zeit, Zielrichtung …)"
/>
{planMin > 0 && (
<p style={{ fontSize: '0.78rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
Geplant in diesem Abschnitt: ca. {planMin} Min. (Übungen)
</p>
)}
{(sec.items || []).map((it, iIdx) => {
const dropHere =
enableItemDragReorder &&
dropTargetPos?.sIdx === sIdx &&
dropTargetPos?.iIdx === iIdx
const dragHere =
enableItemDragReorder &&
draggingPos?.sIdx === sIdx &&
draggingPos?.iIdx === iIdx
const rowCommon =
'tu-item-row' +
(dropHere ? ' tu-item-row--drop-target' : '') +
(dragHere ? ' tu-item-row--dragging' : '')
const dndRowProps = enableItemDragReorder
? {
onDragOverCapture: (ev) => onItemDragOverRow(ev, sIdx, iIdx),
onDrop: (ev) => onItemDropRow(ev, sIdx, iIdx),
}
: {}
if (it.item_type === 'note') {
const notePv = truncatePreview(it.note_body || '', 260)
const noteHasText = Boolean((it.note_body || '').trim())
return (
<div
key={`note-${sIdx}-${iIdx}`}
className={`${rowCommon} tu-item-row--note`}
{...dndRowProps}
>
{enableItemDragReorder ? (
<span
className="tu-row-grip"
draggable
onDragStart={(e) => onItemDragStart(e, sIdx, iIdx)}
onDragEnd={clearDragChrome}
role="button"
tabIndex={0}
aria-label="Eintrag ziehen"
>
<GripVertical size={15} strokeWidth={2} aria-hidden />
</span>
) : null}
<div className="tu-item-row__nudge">
<button
type="button"
aria-label="Eintrag nach oben"
onClick={() => moveItem(sIdx, iIdx, -1)}
disabled={iIdx === 0}
>
</button>
<button
type="button"
aria-label="Eintrag nach unten"
onClick={() => moveItem(sIdx, iIdx, 1)}
disabled={iIdx === sec.items.length - 1}
>
</button>
</div>
<div className="tu-item-row__body tu-item-row__body--note">
<span className="tu-item-row__meta-label">Zwischen-Anmerkung</span>
<p
className={`tu-item-row__preview tu-item-row__preview--clamp${noteHasText ? '' : ' tu-item-row__preview--empty'}`}
title={noteHasText ? (it.note_body || '').trim() : undefined}
>
{noteHasText ? notePv : '—'}
</p>
</div>
<button
type="button"
className="tu-icon-btn"
title="Zwischen-Anmerkung bearbeiten"
aria-label="Zwischen-Anmerkung bearbeiten"
onClick={() =>
setTextEdit({
kind: 'zwischen-note',
sIdx,
iIdx,
draft: it.note_body || '',
})
}
>
<Pencil size={15} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className="tu-item-row__remove"
title="Entfernen"
aria-label="Zwischen-Anmerkung entfernen"
onClick={() => removeItem(sIdx, iIdx)}
>
</button>
</div>
)
}
const variantOpts = Array.isArray(it.variants) ? it.variants : []
const exTitle =
it.exercise_title || (it.exercise_id ? `Übung #${it.exercise_id}` : '')
const annotPrev = truncatePreview(it.notes || '', 220)
const annotHasText = Boolean((it.notes || '').trim())
const hasVariants = variantOpts.length > 0 && it.exercise_id
const variantIdPeek =
it.exercise_variant_id === '' || it.exercise_variant_id == null
? undefined
: Number(it.exercise_variant_id)
return (
<div
key={`ex-${sIdx}-${iIdx}`}
className={`${rowCommon} tu-item-row--exercise`}
{...dndRowProps}
>
<div className="tu-item-row__mainline">
{enableItemDragReorder ? (
<span
className="tu-row-grip"
draggable
onDragStart={(e) => onItemDragStart(e, sIdx, iIdx)}
onDragEnd={clearDragChrome}
role="button"
tabIndex={0}
aria-label="Eintrag ziehen"
>
<GripVertical size={15} strokeWidth={2} aria-hidden />
</span>
) : null}
<div className="tu-item-row__nudge">
<button
type="button"
aria-label="Eintrag nach oben"
onClick={() => moveItem(sIdx, iIdx, -1)}
disabled={iIdx === 0}
>
</button>
<button
type="button"
aria-label="Eintrag nach unten"
onClick={() => moveItem(sIdx, iIdx, 1)}
disabled={iIdx === sec.items.length - 1}
>
</button>
</div>
<div className="tu-item-row__body tu-item-row__body--exercise">
<div className="tu-ex-title-line">
{exTitle ? (
<strong className="tu-ex-title">{exTitle}</strong>
) : (
<span className="tu-ex-title-placeholder">Keine Übung gewählt</span>
)}
<span className="tu-ex-inline-actions">
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() =>
onRequestExercisePick?.({
sectionIndex: sIdx,
itemIndex: iIdx,
})
}
>
{exTitle ? 'Wechseln' : 'Übung suchen…'}
</button>
{it.exercise_id && onPeekExercise ? (
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() =>
onPeekExercise(Number(it.exercise_id), variantIdPeek)
}
>
Vorschau
</button>
) : null}
</span>
</div>
<div className="tu-ex-meta-line">
{hasVariants ? (
<select
className={`form-input tu-ex-variant-select${
wideExerciseGrid ? ' tu-ex-variant-select--wide' : ''
}`}
value={
it.exercise_variant_id === '' ||
it.exercise_variant_id == null
? ''
: String(it.exercise_variant_id)
}
onChange={(e) => {
const raw = e.target.value
updateItem(
sIdx,
iIdx,
'exercise_variant_id',
raw === '' ? '' : parseInt(raw, 10)
)
}}
title="Übungsvariante"
>
<option value="">Stammübung</option>
{variantOpts.map((v) => (
<option key={v.id} value={v.id}>
{v.variant_name || `Variante #${v.id}`}
</option>
))}
</select>
) : null}
<div className="tu-ex-annot">
<span
className={`tu-item-row__preview tu-ex-annot__text${annotHasText ? '' : ' tu-item-row__preview--empty'}`}
title={annotHasText ? (it.notes || '').trim() : undefined}
>
{annotHasText ? annotPrev : '—'}
</span>
<button
type="button"
className="tu-icon-btn"
title="Anmerkung zur Übung"
aria-label="Anmerkung zur Übung bearbeiten"
onClick={() =>
setTextEdit({
kind: 'exercise-notes',
sIdx,
iIdx,
draft: it.notes || '',
})
}
>
<Pencil size={15} strokeWidth={2} aria-hidden />
</button>
</div>
</div>
</div>
<div className="tu-item-row__side">
<input
type="number"
className="form-input tu-ex-duration"
min={1}
value={it.planned_duration_min}
onChange={(e) =>
updateItem(sIdx, iIdx, 'planned_duration_min', e.target.value)
}
placeholder="Min"
title="Geplante Dauer (Minuten)"
/>
<button
type="button"
className="tu-item-row__remove"
title="Übung entfernen"
aria-label="Übung entfernen"
onClick={() => removeItem(sIdx, iIdx)}
>
</button>
</div>
</div>
{showExecutionExtras ? (
<label className="tu-ex-run-block form-label">
Ist-Dauer / Anpassungen
<span className="tu-ex-run-block__controls">
<input
type="number"
className="form-input"
min={1}
value={it.actual_duration_min}
onChange={(e) =>
updateItem(sIdx, iIdx, 'actual_duration_min', e.target.value)
}
placeholder="IST min"
/>
<textarea
className="form-input"
rows={2}
value={it.modifications || ''}
onChange={(e) =>
updateItem(sIdx, iIdx, 'modifications', e.target.value)
}
placeholder="Abweichungen beim Durchführen"
/>
</span>
</label>
) : null}
</div>
)
})}
{enableItemDragReorder ? (
<div
className={`tu-item-append-drop${
dropTargetPos?.sIdx === sIdx && dropTargetPos?.iIdx === itemCount
? ' tu-item-append-drop--active'
: ''
}`}
title="Hierhin ziehen, um nach unten einzufügen"
onDragOverCapture={(e) => onItemDragOverRow(e, sIdx, itemCount)}
onDrop={(e) => onItemDropRow(e, sIdx, itemCount)}
/>
) : null}
<div style={{ marginTop: '0.65rem', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => onRequestExercisePick?.({ sectionIndex: sIdx })}
>
+ Übung
</button>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => addItem(sIdx, 'note')}
>
+ Anmerkung
</button>
</div>
</div>
</Fragment>
)
})}
{enableSectionDragReorder ? (
<div
className={
'tu-section-dropband tu-section-dropband--end' +
(dropSectionBand &&
dropSectionBand.slot === sectionToSlot &&
dropSectionBand.beforeIdx === list.length
? ' tu-section-dropband--active'
: '')
}
title="Abschnitt am Ende einfügen"
onDragOver={(e) => {
if (!dtHasType(e, DND_TU_SECTION)) return
onSectionBandDragOver(e, list.length)
}}
onDragLeave={(e) => {
if (e.currentTarget.contains(e.relatedTarget)) return
clearSectionDnD()
}}
onDrop={(e) => onSectionBandDrop(e, list.length)}
/>
) : null}
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={addSection}
>
+ Abschnitt hinzufügen
</button>
{textEdit ? (
<div
className="tu-textedit-backdrop"
role="presentation"
onMouseDown={(e) => {
if (e.target === e.currentTarget) setTextEdit(null)
}}
>
<div
className="tu-textedit-panel"
role="dialog"
aria-modal="true"
aria-labelledby="tu-textedit-title"
onMouseDown={(e) => e.stopPropagation()}
>
<h4 id="tu-textedit-title" className="tu-textedit-title">
{textEdit.kind === 'zwischen-note'
? 'Zwischen-Anmerkung'
: 'Anmerkung zur Übung'}
</h4>
<textarea
className="form-input tu-textedit-textarea"
rows={5}
value={textEdit.draft}
onChange={(e) =>
setTextEdit((prev) => (prev ? { ...prev, draft: e.target.value } : prev))
}
placeholder={
textEdit.kind === 'zwischen-note'
? 'Hinweise zwischen Übungen …'
: 'Kurze Anmerkung zur Übung'
}
/>
<div className="tu-textedit-actions">
<button type="button" className="btn btn-primary" onClick={applyTextEdit}>
Übernehmen
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => setTextEdit(null)}
>
Abbrechen
</button>
</div>
</div>
</div>
) : null}
</div>
)
}

View File

@ -98,7 +98,7 @@ function AccountSettingsPage() {
}
return (
<div className="page-padding" style={{ padding: '1rem', maxWidth: '640px', margin: '0 auto' }}>
<div className="page-padding app-page" style={{ padding: '1rem' }}>
<h1 style={{ marginBottom: '0.35rem', fontSize: '1.5rem' }}>Einstellungen</h1>
<p style={{ color: 'var(--text2)', marginBottom: '1.25rem', fontSize: '0.95rem' }}>
Konto &amp; Sicherheit

View File

@ -313,7 +313,7 @@ export default function AdminCatalogsPage() {
}
return (
<div style={{ padding: '16px', maxWidth: '1200px', margin: '0 auto' }}>
<div className="app-page">
<AdminPageNav />
<h1 style={{ marginBottom: '24px' }}>Stammdaten-Kataloge</h1>

View File

@ -93,7 +93,7 @@ function AdminHierarchyPage() {
]
return (
<div style={{ padding: '20px' }}>
<div className="app-page">
<AdminPageNav />
<h1 style={{ marginTop: 0 }}>Admin: Katalog-Hierarchie</h1>

View File

@ -143,8 +143,7 @@ function ClubsPage() {
}
return (
<div style={{ padding: '2rem' }}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
<div className="app-page">
<h1 style={{ marginBottom: '0.75rem' }}>Vereinsverwaltung</h1>
<p style={{ color: 'var(--text2)', marginBottom: '1.35rem', maxWidth: '46rem', lineHeight: 1.55 }}>
Für die Trainingsplanung wird mindestens ein <strong>Verein</strong> und eine <strong>Trainingsgruppe</strong> gebraucht.
@ -696,7 +695,6 @@ function ClubsPage() {
</div>
</div>
)}
</div>
</div>
)
}

View File

@ -1,18 +1,86 @@
import React, { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
import EmailVerificationBanner from '../components/EmailVerificationBanner'
function unitWhenLabel(u) {
const d = u.planned_date ? String(u.planned_date).slice(0, 10) : ''
const t = u.planned_time_start ? String(u.planned_time_start).slice(0, 5) : ''
const bits = [d, t].filter(Boolean)
return bits.length ? bits.join(' · ') : 'Termin'
}
function Dashboard() {
const [version, setVersion] = useState(null)
const [profile, setProfile] = useState(null)
const [loading, setLoading] = useState(true)
const [trainingHome, setTrainingHome] = useState(null)
const [trainingHomeErr, setTrainingHomeErr] = useState(null)
const { user } = useAuth()
useEffect(() => {
loadData()
}, [])
useEffect(() => {
if (!user?.id) {
setTrainingHome(null)
setTrainingHomeErr(null)
return undefined
}
let cancelled = false
;(async () => {
setTrainingHomeErr(null)
try {
const today = new Date().toISOString().slice(0, 10)
const [upcomingRaw, recentRaw, plannedPool] = await Promise.all([
api.listTrainingUnits({
assigned_to_me: true,
status: 'planned',
start_date: today,
sort: 'asc',
limit: 8
}),
api.listTrainingUnits({
assigned_to_me: true,
status: 'completed',
sort: 'desc',
limit: 6
}),
api.listTrainingUnits({
assigned_to_me: true,
status: 'planned',
start_date: today,
sort: 'asc',
limit: 40
})
])
const noteHits = (plannedPool || []).filter((u) => {
const tn = (u.trainer_notes || '').trim()
const n = (u.notes || '').trim()
return Boolean(tn || n)
}).slice(0, 5)
if (!cancelled) {
setTrainingHome({
upcoming: Array.isArray(upcomingRaw) ? upcomingRaw : [],
recent: Array.isArray(recentRaw) ? recentRaw : [],
plannedWithNotes: noteHits
})
}
} catch (e) {
if (!cancelled) {
console.error('Dashboard Trainingsübersicht:', e)
setTrainingHomeErr(e.message || 'Konnte Trainingsdaten nicht laden')
setTrainingHome(null)
}
}
})()
return () => {
cancelled = true
}
}, [user?.id])
const loadData = async () => {
try {
const [versionData, profileData] = await Promise.all([
@ -30,7 +98,7 @@ function Dashboard() {
if (loading) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="app-page" style={{ padding: '2rem 0', textAlign: 'center' }}>
<div className="spinner"></div>
<p>Laden...</p>
</div>
@ -38,8 +106,7 @@ function Dashboard() {
}
return (
<div style={{ minHeight: '100vh', background: 'var(--bg)', padding: '2rem' }}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
<div className="app-page">
<h1>Dashboard</h1>
<p style={{ color: 'var(--text2)', marginTop: '0.5rem' }}>
Willkommen, {user?.name || user?.email}!
@ -53,10 +120,105 @@ function Dashboard() {
</p>
</div>
{user?.id && (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 280px), 1fr))',
gap: '1rem',
marginBottom: '1.5rem'
}}
>
<div className="card">
<h3 style={{ fontSize: '1.05rem', marginBottom: '0.65rem' }}>Deine nächsten Trainings</h3>
{trainingHomeErr ? (
<p style={{ color: 'var(--danger)', fontSize: '0.9rem' }}>{trainingHomeErr}</p>
) : trainingHome?.upcoming?.length ? (
<ul style={{ margin: 0, paddingLeft: '1.15rem', color: 'var(--text2)', fontSize: '0.9rem', lineHeight: 1.55 }}>
{trainingHome.upcoming.map((u) => (
<li key={u.id} style={{ marginBottom: '0.35rem' }}>
<Link to={`/planning/run/${u.id}`} style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
{unitWhenLabel(u)}
</Link>
{u.group_name ? (
<span style={{ color: 'var(--text3)' }}>{`${u.group_name}`}</span>
) : null}
{u.lead_trainer_name ? (
<span style={{ display: 'block', fontSize: '0.82rem', color: 'var(--text3)', marginTop: '2px' }}>
Leitung: {u.lead_trainer_name}
</span>
) : null}
</li>
))}
</ul>
) : (
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', margin: 0 }}>
Keine anstehenden Termine mit dir als Leitung oder CoTrainer. Unter{' '}
<Link to="/planning" style={{ color: 'var(--accent-dark)' }}>
Trainingsplanung
</Link>{' '}
kannst du den Vereins oder GruppenZeitraum einblenden.
</p>
)}
</div>
<div className="card">
<h3 style={{ fontSize: '1.05rem', marginBottom: '0.65rem' }}>Vermerk / Hinweise (anstehend)</h3>
{trainingHomeErr ? (
<p style={{ color: 'var(--danger)', fontSize: '0.9rem' }}>{trainingHomeErr}</p>
) : trainingHome?.plannedWithNotes?.length ? (
<ul style={{ margin: 0, paddingLeft: '1.15rem', color: 'var(--text2)', fontSize: '0.88rem', lineHeight: 1.5 }}>
{trainingHome.plannedWithNotes.map((u) => {
const snippet = (u.trainer_notes || u.notes || '').trim().slice(0, 120)
return (
<li key={`n-${u.id}`} style={{ marginBottom: '0.5rem' }}>
<Link to={`/planning/run/${u.id}`} style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
{unitWhenLabel(u)}
</Link>
{u.group_name ? <span style={{ color: 'var(--text3)' }}>{` · ${u.group_name}`}</span> : null}
<div style={{ color: 'var(--text2)', marginTop: '4px' }}>
{snippet}
{(u.trainer_notes || u.notes || '').trim().length > 120 ? '…' : ''}
</div>
</li>
)
})}
</ul>
) : (
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', margin: 0 }}>
Keine Einträge mit Allgemein oder TrainerNotizen in deinen nächsten geplanten Terminen.
</p>
)}
</div>
<div className="card">
<h3 style={{ fontSize: '1.05rem', marginBottom: '0.65rem' }}>Rückschau (durchgeführt)</h3>
{trainingHomeErr ? (
<p style={{ color: 'var(--danger)', fontSize: '0.9rem' }}>{trainingHomeErr}</p>
) : trainingHome?.recent?.length ? (
<ul style={{ margin: 0, paddingLeft: '1.15rem', color: 'var(--text2)', fontSize: '0.9rem', lineHeight: 1.55 }}>
{trainingHome.recent.map((u) => (
<li key={`r-${u.id}`} style={{ marginBottom: '0.35rem' }}>
<Link to={`/planning/run/${u.id}`} style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
{(u.actual_date || u.planned_date || '').toString().slice(0, 10) || 'Datum'}
</Link>
{u.group_name ? (
<span style={{ color: 'var(--text3)' }}>{`${u.group_name}`}</span>
) : null}
</li>
))}
</ul>
) : (
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', margin: 0 }}>Noch keine abgeschlossenen Einheiten in der Kurzliste.</p>
)}
</div>
</div>
)}
{/* Status Grid */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 260px), 1fr))',
gap: '1rem',
marginBottom: '1.5rem'
}}>
@ -122,7 +284,6 @@ function Dashboard() {
</div>
</div>
)}
</div>
</div>
)
}

View File

@ -144,7 +144,7 @@ function ExerciseDetailPage() {
if (error) {
const msg = error.message || String(error)
return (
<div style={{ padding: '1rem', maxWidth: '640px', margin: '0 auto' }}>
<div style={{ padding: '1rem' }} className="app-page">
<div className="card">
<h2>Übung</h2>
<p style={{ color: 'var(--danger)' }}>{msg}</p>

View File

@ -2,6 +2,7 @@ import React, { useEffect, useState, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import api, { buildExerciseApiPayload } from '../utils/api'
import RichTextEditor from '../components/RichTextEditor'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
const INTENSITY_OPTIONS = [
@ -698,7 +699,7 @@ function ExerciseFormPage() {
}
return (
<div style={{ padding: '12px', maxWidth: '720px', margin: '0 auto' }}>
<div style={{ padding: '12px' }} className="app-page">
<div style={{ marginBottom: '12px' }}>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}>
Übersicht
@ -1151,6 +1152,18 @@ function ExerciseFormPage() {
</details>
)}
{isEdit && (
<details className="card exercise-variants-details" style={{ marginTop: '16px' }}>
<summary className="exercise-variants-summary">
<span className="exercise-variants-summary__title">Progressionsgraph</span>
<span className="exercise-variants-summary__badge">Übung Übung</span>
</summary>
<div className="exercise-variants-details__body">
<ExerciseProgressionGraphPanel anchorExerciseId={exerciseId} anchorTitle={formData.title} />
</div>
</details>
)}
{isEdit && (
<div className="card" style={{ marginTop: '16px' }}>
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Medien</h2>

View File

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

View File

@ -103,7 +103,7 @@ export default function MediaWikiImportPage() {
}
return (
<div style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto' }}>
<div className="app-page">
<AdminPageNav />
<h1>MediaWiki Import (Semantic MediaWiki)</h1>

View File

@ -143,8 +143,7 @@ function SkillsPage() {
const methodsByCategory = groupByCategory(methods)
return (
<div style={{ padding: '2rem' }}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
<div className="app-page">
<h1 style={{ marginBottom: '1.5rem' }}>Fähigkeiten & Methoden</h1>
{/* Tabs */}
@ -509,7 +508,6 @@ function SkillsPage() {
</div>
</div>
)}
</div>
</div>
)
}

View File

@ -95,7 +95,7 @@ export default function TrainerContextsPage() {
}
return (
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
<div className="app-page">
<h1>Meine Trainer-Bereiche</h1>
<p style={{ color: 'var(--text2)', marginBottom: '32px' }}>
Definiere deine Tätigkeitsbereiche für fokussierte Ansichten und Filter.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,197 @@
import React, { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
function dashIfEmpty(val) {
const s = (val ?? '').toString().trim()
return s.length ? s : '—'
}
function FrameworkSummaryMeta({ r }) {
const trainingTypes =
typeof r.training_type_names_agg === 'string' ? r.training_type_names_agg.trim() : ''
const targetGroups =
typeof r.target_group_names_agg === 'string' ? r.target_group_names_agg.trim() : ''
const styleDir = typeof r.style_direction_name === 'string' ? r.style_direction_name.trim() : ''
const focus = typeof r.focus_area_name === 'string' ? r.focus_area_name.trim() : ''
const rowStyle = {
display: 'grid',
gridTemplateColumns: 'minmax(6.5rem, 32%) 1fr',
gap: '0.25rem 0.75rem',
alignItems: 'start',
marginTop: '0.35rem',
lineHeight: 1.45,
}
return (
<dl style={{ margin: '0.5rem 0 0', padding: 0, fontSize: '0.875rem', color: 'var(--text2)' }}>
<div style={rowStyle}>
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Fokusbereich</dt>
<dd style={{ margin: 0 }}>{dashIfEmpty(focus)}</dd>
</div>
{styleDir ? (
<div style={rowStyle}>
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Stilrichtung</dt>
<dd style={{ margin: 0 }}>{styleDir}</dd>
</div>
) : null}
<div style={rowStyle}>
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Trainingsarten</dt>
<dd style={{ margin: 0 }}>{trainingTypes.length ? trainingTypes : '—'}</dd>
</div>
<div style={rowStyle}>
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Zielgruppen</dt>
<dd style={{ margin: 0 }}>{targetGroups.length ? targetGroups : '—'}</dd>
</div>
<div style={{ ...rowStyle, marginTop: '0.5rem' }}>
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Kurzbeschreibung</dt>
<dd style={{ margin: 0, whiteSpace: 'pre-wrap' }}>
{(r.description && String(r.description).trim()) || '—'}
</dd>
</div>
</dl>
)
}
export default function TrainingFrameworkProgramsListPage() {
const [rows, setRows] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
const list = await api.listTrainingFrameworkPrograms()
setRows(Array.isArray(list) ? list : [])
} catch (e) {
setError(e.message || 'Laden fehlgeschlagen')
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
async function handleDelete(id, title) {
if (!confirm(`Rahmenprogramm „${title || id}“ wirklich löschen?`)) return
try {
await api.deleteTrainingFrameworkProgram(id)
await load()
} catch (e) {
alert(e.message || 'Löschen fehlgeschlagen')
}
}
return (
<div className="app-page">
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: '1rem',
marginBottom: '1.25rem',
}}
>
<div>
<h1 style={{ marginBottom: '0.35rem' }}>Trainingsrahmenprogramme</h1>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '36rem' }}>
Wiederverwendbare Vorlagen für Ziele und Sessions. Die Verknüpfung mit{' '}
<strong>konkreten Gruppeneinheiten</strong> erfolgt aus der <strong>Planung der Gruppe</strong> (Übernahme
mit Bezug zum Rahmen).
</p>
</div>
<Link
to="/planning/framework-programs/new"
className="btn btn-primary"
style={{ textDecoration: 'none', whiteSpace: 'nowrap' }}
>
Rahmenprogramm anlegen
</Link>
</div>
<p style={{ marginBottom: '1rem' }}>
<Link to="/planning" style={{ color: 'var(--accent-dark)' }}>
Zurück zur Trainingsplanung
</Link>
</p>
{error && (
<div className="card" style={{ borderLeft: '4px solid var(--danger)', marginBottom: '1rem' }}>
{error}
</div>
)}
{loading ? (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<div className="spinner" />
<p>Laden</p>
</div>
) : rows.length === 0 ? (
<div className="card">
<p style={{ color: 'var(--text2)', marginBottom: '1rem' }}>
Noch kein Rahmenprogramm gespeichert. Lege ein neues an mit Titel, mindestens einem Ziel und optional
Slots samt Übungen.
</p>
<Link
to="/planning/framework-programs/new"
className="btn btn-primary btn-full"
style={{ textDecoration: 'none' }}
>
Rahmenprogramm anlegen
</Link>
</div>
) : (
<ul style={{ listStyle: 'none' }}>
{rows.map((r) => (
<li key={r.id} className="card" style={{ marginBottom: '12px' }}>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: '0.75rem',
}}
>
<div style={{ minWidth: 0, flex: '1 1 220px' }}>
<Link
to={`/planning/framework-programs/${r.id}`}
style={{ fontWeight: 600, fontSize: '1.05rem', color: 'var(--text1)' }}
>
{r.title || `Rahmen #${r.id}`}
</Link>
<div style={{ fontSize: '0.85rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
<span>
{(r.goals_count ?? '—') + ' Ziele · '}
{(r.slots_count ?? '—') + ' Slots'}
</span>
</div>
<FrameworkSummaryMeta r={r} />
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
<Link
to={`/planning/framework-programs/${r.id}`}
className="btn btn-secondary"
style={{ textDecoration: 'none' }}
>
Bearbeiten
</Link>
<button type="button" className="btn btn-secondary" onClick={() => handleDelete(r.id, r.title)}>
Löschen
</button>
</div>
</div>
</li>
))}
</ul>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -141,7 +141,7 @@ export default function TrainingUnitRunPage() {
}
return (
<div className="training-run-page" style={{ maxWidth: '720px', margin: '0 auto', paddingBottom: '2rem' }}>
<div className="training-run-page app-page" style={{ paddingBottom: '2rem' }}>
<ExercisePeekModal
open={peekExerciseId != null}
exerciseId={peekExerciseId}

View File

@ -443,6 +443,74 @@ export async function reorderExerciseVariants(exerciseId, variantIds) {
})
}
// Progressionsgraphen (Übung → Übung), Migration 032/033
export async function listExerciseProgressionGraphs() {
return request('/api/exercise-progression-graphs')
}
export async function getExerciseProgressionGraph(id, { includeEdges = false } = {}) {
const q = includeEdges ? '?include_edges=true' : ''
return request(`/api/exercise-progression-graphs/${id}${q}`)
}
export async function createExerciseProgressionGraph(data) {
return request('/api/exercise-progression-graphs', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateExerciseProgressionGraph(id, data) {
return request(`/api/exercise-progression-graphs/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteExerciseProgressionGraph(id) {
return request(`/api/exercise-progression-graphs/${id}`, { method: 'DELETE' })
}
export async function listExerciseProgressionEdges(graphId, query = {}) {
const q = new URLSearchParams()
if (query.from_exercise_id != null) q.set('from_exercise_id', String(query.from_exercise_id))
if (query.to_exercise_id != null) q.set('to_exercise_id', String(query.to_exercise_id))
const qs = q.toString()
return request(`/api/exercise-progression-graphs/${graphId}/edges${qs ? `?${qs}` : ''}`)
}
export async function createExerciseProgressionEdge(graphId, data) {
return request(`/api/exercise-progression-graphs/${graphId}/edges`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateExerciseProgressionEdge(graphId, edgeId, data) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/${edgeId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteExerciseProgressionEdge(graphId, edgeId) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/${edgeId}`, { method: 'DELETE' })
}
export async function createExerciseProgressionSequence(graphId, data) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/sequence`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function deleteExerciseProgressionEdgesBatch(graphId, edgeIds) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/delete-batch`, {
method: 'POST',
body: JSON.stringify({ edge_ids: edgeIds }),
})
}
/** KI-Ausbaustufe (EXERCISES_API_SPEC): benötigt Backend + z. B. OPENROUTER_API_KEY. */
export async function suggestExerciseAi(payload) {
return request('/api/exercises/ai/suggest', {
@ -814,15 +882,21 @@ export async function deleteTrainerContext(id) {
// Training Planning
// ============================================================================
/** Query-Parameter wie GET /api/training-units (group_id, start_date, end_date, status). */
/** Query-Parameter wie GET /api/training-units. */
export async function listTrainingUnits(filters = {}) {
const q = new URLSearchParams()
if (filters.group_id != null && filters.group_id !== '') {
q.set('group_id', String(filters.group_id))
}
if (filters.club_id != null && filters.club_id !== '') {
q.set('club_id', String(filters.club_id))
}
if (filters.start_date) q.set('start_date', filters.start_date)
if (filters.end_date) q.set('end_date', filters.end_date)
if (filters.status) q.set('status', filters.status)
if (filters.assigned_to_me === true) q.set('assigned_to_me', 'true')
if (filters.sort) q.set('sort', String(filters.sort))
if (filters.limit != null && filters.limit !== '') q.set('limit', String(filters.limit))
const qs = q.toString()
return request(`/api/training-units${qs ? `?${qs}` : ''}`)
}
@ -856,6 +930,14 @@ export async function quickCreateTrainingUnit(data) {
})
}
/** Rahmen-Slot → geplante Einheit (tiefe Kopie, origin_framework_slot_id). */
export async function createTrainingUnitFromFrameworkSlot(data) {
return request('/api/training-units/from-framework-slot', {
method: 'POST',
body: JSON.stringify(data)
})
}
export async function listTrainingPlanTemplates() {
return request('/api/training-plan-templates')
}
@ -882,6 +964,32 @@ export async function deleteTrainingPlanTemplate(id) {
return request(`/api/training-plan-templates/${id}`, { method: 'DELETE' })
}
export async function listTrainingFrameworkPrograms() {
return request('/api/training-framework-programs')
}
export async function getTrainingFrameworkProgram(id) {
return request(`/api/training-framework-programs/${id}`)
}
export async function createTrainingFrameworkProgram(data) {
return request('/api/training-framework-programs', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateTrainingFrameworkProgram(id, data) {
return request(`/api/training-framework-programs/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteTrainingFrameworkProgram(id) {
return request(`/api/training-framework-programs/${id}`, { method: 'DELETE' })
}
// ============================================================================
// Version & Health
// ============================================================================
@ -951,6 +1059,17 @@ export const api = {
updateExerciseMedia,
deleteExerciseMedia,
reorderExerciseMedia,
listExerciseProgressionGraphs,
getExerciseProgressionGraph,
createExerciseProgressionGraph,
updateExerciseProgressionGraph,
deleteExerciseProgressionGraph,
listExerciseProgressionEdges,
createExerciseProgressionEdge,
updateExerciseProgressionEdge,
deleteExerciseProgressionEdge,
createExerciseProgressionSequence,
deleteExerciseProgressionEdgesBatch,
// Training Planning
listTrainingUnits,
@ -959,11 +1078,17 @@ export const api = {
updateTrainingUnit,
deleteTrainingUnit,
quickCreateTrainingUnit,
createTrainingUnitFromFrameworkSlot,
listTrainingPlanTemplates,
getTrainingPlanTemplate,
createTrainingPlanTemplate,
updateTrainingPlanTemplate,
deleteTrainingPlanTemplate,
listTrainingFrameworkPrograms,
getTrainingFrameworkProgram,
createTrainingFrameworkProgram,
updateTrainingFrameworkProgram,
deleteTrainingFrameworkProgram,
// Catalogs
listFocusAreas,

View File

@ -0,0 +1,191 @@
import api from './api'
export function defaultSection(title = 'Hauptteil') {
return { title, guidance_notes: '', items: [] }
}
export function exerciseRow() {
return {
item_type: 'exercise',
exercise_id: '',
exercise_variant_id: '',
exercise_title: '',
variants: [],
planned_duration_min: '',
actual_duration_min: '',
notes: '',
modifications: '',
}
}
export async function hydrateExercisePlanningRow(exercise) {
let variants = Array.isArray(exercise?.variants) ? exercise.variants : []
let title = exercise?.title || ''
const id = exercise?.id
if (!id) return null
if (!variants.length) {
try {
const full = await api.getExercise(id)
variants = Array.isArray(full?.variants) ? full.variants : []
title = full?.title || title
} catch {
variants = []
}
}
const row = exerciseRow()
row.exercise_id = id
row.exercise_variant_id = ''
row.exercise_title = title
row.variants = variants
return row
}
export function noteRow() {
return { item_type: 'note', note_body: '' }
}
export function normalizeUnitToForm(fullUnit) {
if (fullUnit.sections && fullUnit.sections.length) {
return fullUnit.sections.map((sec) => ({
title: sec.title,
guidance_notes: sec.guidance_notes || '',
items: (sec.items || []).map((it) => {
if (it.item_type === 'note') {
return { item_type: 'note', note_body: it.note_body || '' }
}
return {
item_type: 'exercise',
exercise_id: it.exercise_id,
exercise_variant_id: it.exercise_variant_id ?? '',
exercise_title: it.exercise_title || '',
variants: [],
planned_duration_min:
it.planned_duration_min !== null && it.planned_duration_min !== undefined
? String(it.planned_duration_min)
: '',
actual_duration_min:
it.actual_duration_min !== null && it.actual_duration_min !== undefined
? String(it.actual_duration_min)
: '',
notes: it.notes ?? '',
modifications: it.modifications ?? '',
}
}),
}))
}
if (fullUnit.exercises && fullUnit.exercises.length) {
return [
{
title: 'Übungen',
guidance_notes: '',
items: fullUnit.exercises.map((ex) => ({
item_type: 'exercise',
exercise_id: ex.exercise_id,
exercise_variant_id: ex.exercise_variant_id ?? '',
exercise_title: ex.exercise_title || '',
variants: [],
planned_duration_min:
ex.planned_duration_min !== null && ex.planned_duration_min !== undefined
? String(ex.planned_duration_min)
: '',
actual_duration_min:
ex.actual_duration_min !== null && ex.actual_duration_min !== undefined
? String(ex.actual_duration_min)
: '',
notes: ex.notes ?? '',
modifications: ex.modifications ?? '',
})),
},
]
}
return [defaultSection()]
}
export async function enrichSectionsWithVariants(sections) {
if (!sections?.length) return sections
const ids = []
for (const sec of sections) {
for (const it of sec.items || []) {
if (it.item_type === 'note') continue
if (it.exercise_id) ids.push(it.exercise_id)
}
}
const unique = [...new Set(ids)]
const cache = new Map()
await Promise.all(
unique.map(async (id) => {
try {
const ex = await api.getExercise(id)
cache.set(id, {
title: ex.title || '',
variants: Array.isArray(ex.variants) ? ex.variants : [],
})
} catch {
cache.set(id, { title: '', variants: [] })
}
})
)
return sections.map((sec) => ({
...sec,
items: (sec.items || []).map((it) => {
if (it.item_type === 'note') return it
if (!it.exercise_id) return it
const c = cache.get(it.exercise_id)
if (!c) return it
return {
...it,
exercise_title: it.exercise_title || c.title,
variants:
Array.isArray(it.variants) && it.variants.length > 0 ? it.variants : c.variants,
}
}),
}))
}
export function parseMin(v) {
if (v === '' || v === null || v === undefined) return null
const n = parseInt(String(v), 10)
return Number.isFinite(n) ? n : null
}
export function buildSectionsPayload(sections) {
return sections.map((sec, si) => ({
order_index: si,
title: (sec.title || '').trim() || 'Abschnitt',
guidance_notes: sec.guidance_notes?.trim() ? sec.guidance_notes.trim() : null,
items: (sec.items || [])
.map((it, ii) => {
if (it.item_type === 'note') {
return {
item_type: 'note',
order_index: ii,
note_body: it.note_body ?? '',
}
}
if (it.exercise_id === '' || it.exercise_id == null || Number.isNaN(Number(it.exercise_id))) {
return null
}
const vid = it.exercise_variant_id
return {
item_type: 'exercise',
order_index: ii,
exercise_id: parseInt(it.exercise_id, 10),
exercise_variant_id:
vid !== '' && vid != null && !Number.isNaN(Number(vid)) ? parseInt(vid, 10) : null,
planned_duration_min: parseMin(it.planned_duration_min),
actual_duration_min: parseMin(it.actual_duration_min),
notes: it.notes?.trim() ? it.notes.trim() : null,
modifications: it.modifications?.trim() ? it.modifications.trim() : null,
}
})
.filter(Boolean),
}))
}
export function sectionPlannedMinutes(sec) {
return (sec.items || []).reduce((sum, it) => {
if (it.item_type !== 'exercise') return sum
const m = parseMin(it.planned_duration_min)
return sum + (m || 0)
}, 0)
}

View File

@ -1,7 +1,7 @@
// Shinkan Jinkendo Frontend Version
export const APP_VERSION = "0.5.0"
export const BUILD_DATE = "2026-04-23"
export const APP_VERSION = "0.5.1"
export const BUILD_DATE = "2026-05-05"
export const PAGE_VERSIONS = {
LoginPage: "1.0.0",
@ -10,7 +10,9 @@ export const PAGE_VERSIONS = {
ExercisesPage: "1.1.0", // Updated: Katalog-Integration
ClubsPage: "1.0.0",
SkillsPage: "1.0.0",
TrainingPlanningPage: "1.3.0",
TrainingPlanningPage: "1.3.1",
TrainingFrameworkProgramsListPage: "1.1.0",
TrainingFrameworkProgramEditPage: "1.5.0",
TrainingUnitRunPage: "1.1.0",
TrainingCoachPage: "1.0.0",
AdminCatalogsPage: "2.2.0", // Updated: Frontend API Calls & Field Names für renamed tables