chore: update versioning and enhance training framework features
Some checks failed
Deploy Development / deploy (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 39s

- Incremented APP_VERSION to 0.8.10 and DB_SCHEMA_VERSION to 20260505037.
- Updated project status and domain model documentation to reflect recent changes.
- Enhanced training framework program handling with new slot-blueprint structure.
- Introduced API endpoint for creating training units from framework slots.
- Improved documentation for training planning and governance concepts.
This commit is contained in:
Lars 2026-05-05 13:39:30 +02:00
parent 7e21b44604
commit c4fbabd8f6
12 changed files with 940 additions and 1070 deletions

View File

@ -1,30 +1,30 @@
# Shinkan Jinkendo - Projekt-Status
**Stand:** 2026-04-30
**Version (Code):** 0.8.7 (`backend/version.py`, APP_VERSION)
**DB-Schema-Version:** `20260430034`
**Stand:** 2026-05-05
**Version (Code):** 0.8.10 (`backend/version.py`, APP_VERSION)
**DB-Schema-Version:** `20260505037`
**Branch:** develop
---
## Executive Summary
**Aktueller Meilenstein:** **Progressionsgraph zwischen Übungen** (DB 032034, API `exercise-progression-graphs`, UI Tabs + Formularblock) — **Zwischenstand**: linear/Reihen/Schwestern gut nutzbar; **parallele gleichwertige Alternativ„Pakete“** noch ohne dedizierte UX (**TRAINING_FRAMEWORK_SPEC.md** §4). Ausreichend, um mit **Trainingsplanung / Rahmen** (**CURR002 (2)**) weiterzuarbeiten.
**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 **032034**: `exercise_progression_graphs`, `exercise_progression_edges` inkl. **`notes`**, optionale Varianten-Endpunkte.
- ✅ **`POST /api/exercise-progression-graphs/{id}/edges/sequence`** und **`…/edges/delete-batch`**.
- ✅ **Übungsliste:** Tabs Liste · Progressionsgraphen; **Übung bearbeiten:** Block Progressionsgraph.
- ✅ Zuvor geliefert: Varianten Ende-zu-Ende (030), Listen-Suche UX, Medien-Limits, RichText — siehe unten und Feature-Doc.
- ✅ 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) · Zwischenstand Graph → [`technical/TRAINING_FRAMEWORK_SPEC.md`](technical/TRAINING_FRAMEWORK_SPEC.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. **Trainingsplanungs-/Rahmenmodul** nach CURR002 (2), CURR009013 (Graph bleibt unterstützend).
2. Prod-Deployment Migrationen bis **034** und Smoke-Tests.
3. Optional Backlog Graph: Alternativgruppen / bessere Visualisierung verzweigter Graphen.
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 +42,7 @@
| 028029 | exercise_media / skills Stufen | ✅ | 🔲 |
| **030** | **training_unit_exercises.exercise_variant_id** | ✅ | 🔲 |
| **032034** | **Progressionsgraph Übung→Übung** | ✅ | 🔲 |
| **035037** | **Rahmenprogramm, BibliothekKopf, SlotBlueprintUnits** | ✅ | 🔲 |
---
@ -74,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:**
@ -122,7 +125,7 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu
### Dev
Branch `develop`; Migrations bis mindestens **034** 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
@ -134,16 +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-30 | ✅ Aktualisiert (032034) |
| Trainingsrahmen (Zwischenstand Graph) | `technical/TRAINING_FRAMEWORK_SPEC.md` | 2026-04-30 | ✅ |
| 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-30 | ✅ Aktualisiert (034) |
| Domain Model | `functional/DOMAIN_MODEL.md` | 2026-04-30 | ✅ 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-30 | ✅ Diese Datei |
| Projektstatus | `PROJECT_STATUS.md` | 2026-05-05 | ✅ Diese Datei |
---
@ -154,4 +157,4 @@ Deployment der oben genannten Migrationen und Datenabgleich nach internem Prozes
---
**Letzte Aktualisierung:** 2026-04-30
**Letzte Aktualisierung:** 2026-05-05

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo - Fachliches Domänenmodell
**Version:** 0.4.2
**Stand:** 2026-05-05 (Migration 035: RahmenVorlage `training_framework_programs`; Progressionsgraph unverändert 032034)
**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
---
@ -468,9 +468,11 @@ skill_level_definitions (
### 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 **direkten Übungszuordnungen** pro Slot („Stückliste“, **CURR010**). Der persistierte **Progressionsgraph** zwischen Übungen bleibt **optional** und ersetzt keine Slot-Zuordnung (**CURR013**).
**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**).
**Zwei Nutzungsmodi (CURR012):** **`concrete`** (Kurzfrist/Gruppenkontext; optional `group_id`, Slots dürfen **`training_unit_id`** tragen) vs. **`library`** (zeit/gruppenlose Vorlage; **`group_id`** und SlotEinheitsverknüpfungen sind fachlich gesperrt — technisch werden Einheits-FKs beim Wechsel geleert). **Materialisierung / BulkAnlegen** von `training_units` aus dem Rahmen ist ein **separater** Schritt (Stub/PR). **optional `training_plan_template_id` pro Slot** ist bewusst **deferred** (**C5**/CURR010).
**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**).
---

View File

@ -1,7 +1,7 @@
# Konzept: Trainingsplanung über Einheiten hinweg, Kurspläne, Governance, Assessments
**Status:** Arbeitspapier (lebend)
**Stand:** 2026-04-30 (CURR002 Stufe 1 Zwischenstand im Produkt; Rahmen CURR002 (2) als nächster Schritt)
**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/`.
@ -29,7 +29,7 @@
| 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 (beliebig oder aus Progression) auf **mehrere** Session-Slots / Trainingseinheiten **verteilen**, **mehrere Ziele**; speicherbare Rahmen-Vorlage (CURR002(2) i.V.m. **CURR010013**) | **nächster Implementierungsschwerpunkt** — Stufe1 ist nicht Blocker (**CURR013**) |
| **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)
@ -40,11 +40,11 @@
| **B** | **Governance-Muster** (einheitliche Sichtbarkeit; Bibliothek vs. Instanz) | ✅ Leitplan §2.c; Entscheidungen §5 CURR-005007 |
| **C** | **Rahmenprogramm** — §2.d (**C1C4** ✅ · **C5** Leitplan) | ✅ Kern i.V.m. **CURR009013** |
| **D** | **Kurs-/Stufenprogramm:** nach Rahmenprogramm; plantechnisch ähnlich | 📌 zeitlich nachgelagert (CURR-003) |
| **E** | **Lineage & Feedback** (Einheit ↔ Vorlage/Rahmen; Issues zur Nachbesserung) | offen |
| **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:** Umsetzung **Trainingsplanungs-/Rahmenmodul** (**CURR002 (2)**): Entitäten, Slots, mehrere Ziele — Progressionsgraph Stufe1 ist **bereits** als unterstützende Bibliotheksfunktion vorhanden (**TRAINING_FRAMEWORK_SPEC.md**). Schritt **E** (Lineage) als nächstes Konzeptpaket möglich.
**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`**.
---
@ -59,8 +59,9 @@ Ein **wiedererkennbares Muster** für alle **Bibliotheksobjekte** (Übung, Train
| Objekt | Relevante Felder / Muster |
|--------|---------------------------|
| `exercises`, `exercise_blocks` | `visibility``private` \| `club` \| `official`, `club_id`, `created_by`**Referenzmuster** |
| `training_plan_templates` | `club_id`, `created_by`, **kein** `visibility` — Abweichung; nachziehen oder Semantik explizit festlegen (siehe CURR-007) |
| `training_units` | `group_id`, `created_by`, `plan_template_id`**Instanz**; Zugriff fachlich über Gruppe/Trainer |
| `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)
@ -114,12 +115,14 @@ Ein **wiedererkennbares Muster** für alle **Bibliotheksobjekte** (Übung, Train
**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)** **direkt** (wie „Stückliste“) ist **tragend**; **Progressionsgraph** liefert **Vorschläge / Pakete**, wenn admins sie pflegen — **Trainer** können **ohne** Graph planen.
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.
---

View File

@ -1,9 +1,9 @@
# Gelieferte Features & technische Basis (April 2026)
# Gelieferte Features & technische Basis (Q2 2026)
**Stand:** 2026-04-30
**Referenz:** `backend/version.py`**APP_VERSION 0.8.7**, **DB_SCHEMA_VERSION 20260430034**
**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**. **Progressionsgraph zwischen Übungen** (Zwischenstand, Grenzen): **`technical/TRAINING_FRAMEWORK_SPEC.md`** §3§4. 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/`.
---
@ -15,6 +15,9 @@ Dieses Dokument bündelt die in der Entwicklungsphase erreichten **lieferbaren**
| **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`** |
---
@ -55,9 +58,10 @@ Logik: `_upload_limit_bytes(session)` vor `read()`-Prüfung.
## 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.
---
@ -109,16 +113,26 @@ Hinweis: Es gibt **keine** separaten Routen `/exercises/:id/variants/...` — Be
---
## 11. Nächste sinnvolle Schritte (nicht Lieferstand)
## 11. Trainingsrahmen: Bibliothek + SlotBlueprint (DB **036037**)
- Trainingsplanungs-/Rahmenmodul (**CURR002 (2)**) — Progressionsgraph ist unterstützend, siehe **`TRAINING_FRAMEWORK_SPEC.md`** §4.
- **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).
---
## 12. Verweise
## 13. Verweise
| Thema | Dokument |
|--------|----------|

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo - Datenbank-Schema (Technisch)
**Version:** 0.5.1
**Version:** 0.5.2
**Stand:** 2026-05-05
**Hinweis:** Produktiver Deploy sollte mindestens bis Migration **035** (Trainingsrahmenprogramm + Vorlagen-`visibility`) geführt sein — Details siehe `backend/version.py` (`DB_SCHEMA_VERSION`).
**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`).
---
@ -44,7 +44,9 @@ Dieses Dokument beschreibt die **technische Datenbankstruktur** von Shinkan Jink
| **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, Slot-Übungen); **`training_plan_templates.visibility`** (Backfill `club`) — siehe `TRAINING_FRAMEWORK_SPEC.md` | ✅ |
| **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`** | ✅ |
---
@ -270,14 +272,51 @@ 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
```

View File

@ -1,6 +1,6 @@
# Trainingsrahmenprogramm — Technische Spezifikation
**Status:** Zwischenstand dokumentiert · **Stand:** 2026-05-05
**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**).
@ -14,7 +14,7 @@
| `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** (Modus A/B, Governance, CURRTabelle). |
| `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).
@ -22,84 +22,91 @@
## 2. Rahmenprogramm (CURR002 Stufe2) — Checkliste & technische Ausarbeitung
### 2.0 Technische Entscheidung: eine Tabelle + `plan_mode` (Modus A|B)
### 2.0 Technische Entscheidung: nur Bibliothek + SlotBlueprint = `training_units`
**Entscheid:** **Eine** Hauptentität `training_framework_programs` mit **`plan_mode` ∈ {`concrete`, `library`}** statt zweier getrennter Tabellentypen.
**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).
**Begründung:** Gleiche Lebenszyklus und CRUDForm (Header, Ziele, Slots, Übungen); die fachliche Unterscheidung A/B lässt sich mit **CHECK**- und APIRegeln ausdrücken (`library` ⇒ `group_id IS NULL`, keine `training_unit_id` an Slots), ohne doppelte Router/Joins. Zwei physische Typen würden ohne Mehrwert Polymorphismus in der API erzwingen oder eine künstliche SupertypTabelle nach sich ziehen.
**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.
**Abgrenzung Konzept §6:** Dokumentiert hier; Funktionskonzept kann auf diesen Abschnitt verweisen.
**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):** eigene Bibliotheks-Entität `training_framework_programs` (**CURR009**); `training_plan_templates` unverändert **eineEinheitMikrovorlage** (**C5**).
- [x] **Modus A vs. B:** ein Datensatz + `plan_mode` + Nullables (**§2.0**); `library` erzwingt `group_id` NULL; **`training_plan_template_id` pro Slot** — **deferred** (Technical: MVP ohne Spalte, bis Nutzen geklärt, **CURR010**).
- [x] **Zielliste:** `training_framework_goals`, API erzwingt **1** Ziel beim Anlegen/Ersetzen (**CURR011**).
- [x] **Slots:** `training_framework_slots` mit **`sort_order`**, optional **Titel/Notizen**; Übungen über **`training_framework_slot_exercises`** (Sortierung **`order_index`**, optional **`exercise_variant_id`**).
- [x] **Progressionsgraph:** Stufe1 besteht (**§3§4**); **kein Pflichtbezug** pro Slot (**CURR013**).
- [x] **Konkretkontakt ModusA:** optional **`training_unit_id`** pro Slot; bei gesetzter Rahmen-**`group_id`** muss die Einheit zur gleichen Gruppe gehören. **LiveWrites** zurück in die Bibliotheksvorlage: **nicht** vorgesehen (**CURR006**).
- [x] **Instanziierung (ModusB):** Persistenz der Vorlage (**MVP**); Bulk-Anlage von **`training_units`** aus dem Rahmen — **Ausbauschritt**/zweiter PR (**CURR012** C4a/b).
- [x] **Governance neue Objekte:** `visibility`, `club_id`, `created_by` wie Progressionsgraph (**CURR005**). **`training_plan_templates.visibility`** nachgezogen in derselben Migration **035** mit Backfill **`club`** (**CURR007**, **CURR008**; frühe Installationen).
- [x] **REST Rahmenprogramm:** `/api/training-framework-programs` (**§2.3**); ProgressionsAPI weiter **§3.3**.
- [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 DDLSkizze (Migration **035**, Kurzüberblick)
### 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
-- Header
-- Kopf (ohne Modus-Spalten nach 036)
training_framework_programs (
id, title NOT NULL, description,
plan_mode NOT NULL CHECK (IN 'concrete','library'),
group_id FK training_groups NULL,
planned_period_start, planned_period_end NULL, -- reine Meta-/UIHilfe
planned_period_start, planned_period_end NULL,
visibility NOT NULL, club_id, created_by,
CHECK ((plan_mode = 'library' AND group_id IS NULL) OR plan_mode = 'concrete'),
timestamps + update_trigger
focus_area_id, style_direction_id NULL REFERENCES …,
timestamps
)
-- Ziele (≥1 über API beim Speichern)
training_framework_goals (
id, framework_program_id FK CASCADE, sort_order UNIQUE per framework,
title, notes
)
-- Slots
training_framework_slots (
id, framework_program_id FK CASCADE, sort_order UNIQUE per framework,
title, notes, training_unit_id FK training_units SET NULL
title, notes,
training_unit_id FK … (Spalte technisch noch vorhanden; fachlich ungenutzt, per 036 geleert)
)
-- Stückliste pro Slot (Übung → FK CASCADE beim Löschen der Übung)
training_framework_slot_exercises (
id, slot_id FK CASCADE,
exercise_id FK exercises CASCADE,
exercise_variant_id FK exercise_variants NULL,
order_index UNIQUE per slot
)
-- 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:** löschen eines **Rahmens** ⇒ Ziele + Slots ⇒ SlotÜbungen (alles **`ON DELETE CASCADE`** vom Rahmen über Slots). löschen eines **`training_unit`** ⇒ FK am Slot **`SET NULL`**. löschen einer **Übung** ⇒ Zeilen in **`training_framework_slot_exercises`** entfallen (`CASCADE`).
**Deferred (nicht im MVP):** `training_plan_templates.id` FK je Slot (**CURR010** Optional).
**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`); Aggregation `goals_count`, `slots_count` |
| GET | `/training-framework-programs/{id}` | Detail inkl. `goals[]`, `slots[]` mit jeweils `exercises[]` (inkl. TitelJoins) |
| POST | `/training-framework-programs` | Neu; **Pflicht:** `title`, **`plan_mode`**, **`goals`** (≥1 Eintrag mit `title`); optional `slots` |
| PUT | `/training-framework-programs/{id}` | HeaderFelder; optional volles Ersetzen von **`goals`** und/oder **`slots`** wie bei VorlagenSektionen |
| DELETE | `/training-framework-programs/{id}` | Rahmen löschen (**CASCADE** Kinder) |
| 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:** Schreibzugriff nur mit **`_has_planning_role`** (wie `training_plan_templates`); Lesen/Ändern/Löschen: **Admin/Superadmin** oder **Ersteller** (`created_by`).
**AuthZ:** wie zuvor — Planungsrolle zum Schreiben; Lesen/Schreiben/Löschen des Rahmens: Admin/Superadmin oder Ersteller.
**PayloadHinweise (JSON):**
**PayloadHinweise (JSON), Slots:**
- `goals`: `[{ sort_order?, title, notes? }, …]`**`sort_order`** default Reihenfolge im Array
- `slots`: `[{ sort_order?, title?, notes?, training_unit_id?, exercises: [{ exercise_id, exercise_variant_id?, order_index? }] }, …]`
- Bei **`library`:** **`training_unit_id`** an Slots → **400**; nach Wechsel auf **`library`** werden bestehende Slot-Verknüpfungen zu **`training_units`** geleert.
- 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).
**MinimalUI:** im Lieferumfang dieser Iteration nicht enthalten (**OpenAPI `/docs`** / Postman); siehe Funktionskonzept §6.
### 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`.
---
@ -151,7 +158,7 @@ Listenqueries liefern JoinFelder **`from_exercise_title`**, **`to_exercise_ti
## 4. Zwischenstand für Produkt / Trainingsplanung (bewusste Grenzen)
**Freigabe:** Der beschriebene Stand gilt als **ausreichend**, um mit dem **Trainingsplanungsmodul** und später Rahmen/Slots (**CURR002 (2)**) weiterzuarbeiten — ohne dass Pflicht zur Pflege komplexer GraphStrukturen entsteht (**CURR013**).
**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**
@ -179,7 +186,8 @@ Details weiterhin Diskussionsgrundlage in `TRAINING_CURRICULUM_AND_GOVERNANCE_CO
| Datum | Änderung |
|-------|----------|
| 2026-05-05 | **CURR002 (2):** §2 Rahmenprogramm — Entscheid **eine Tabelle + `plan_mode`**, DDLSkizze, RESTÜberblick; Migration **035**; `training_plan_templates.visibility`. |
| 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

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

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

@ -0,0 +1,442 @@
import React, { useCallback } from 'react'
import {
defaultSection,
exerciseRow,
noteRow,
sectionPlannedMinutes,
} from '../utils/trainingUnitSectionsForm'
/**
* @param {(updater: (prev: Array) => Array) => void} props.onSectionsChange wie React setState
*/
export default function TrainingUnitSectionsEditor({
sections,
onSectionsChange,
onRequestExercisePick,
onPeekExercise,
showExecutionExtras = false,
heading = 'Abschnitte & Übungen',
hideHeading = false,
}) {
const ensure = (prev) =>
prev && prev.length ? prev : [defaultSection()]
const patch = useCallback(
(updater) => {
onSectionsChange((prev) => updater(ensure(prev)))
},
[onSectionsChange]
)
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 list = ensure(sections)
return (
<div className="training-unit-sections-editor">
{!hideHeading ? (
<h3 style={{ margin: '0 0 0.75rem', fontSize: '1rem' }}>{heading}</h3>
) : null}
{list.map((sec, sIdx) => {
const planMin = sectionPlannedMinutes(sec)
return (
<div
key={`sec-${sIdx}`}
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',
}}
>
<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) =>
it.item_type === 'note' ? (
<div key={`note-${sIdx}-${iIdx}`} style={{ marginTop: '0.65rem' }}>
<div style={{ fontSize: '0.75rem', color: 'var(--text2)', marginBottom: '4px' }}>
Zwischen-Anmerkung
</div>
<div style={{ display: 'flex', gap: '6px', alignItems: 'start' }}>
<div style={{ display: 'flex', flexDirection: 'column', paddingTop: '4px' }}>
<button
type="button"
onClick={() => moveItem(sIdx, iIdx, -1)}
disabled={iIdx === 0}
style={{ padding: '2px', opacity: iIdx === 0 ? 0.3 : 1 }}
>
</button>
<button
type="button"
onClick={() => moveItem(sIdx, iIdx, 1)}
disabled={iIdx === sec.items.length - 1}
style={{
padding: '2px',
opacity: iIdx === sec.items.length - 1 ? 0.3 : 1,
}}
>
</button>
</div>
<textarea
className="form-input"
rows={2}
style={{ flex: 1 }}
value={it.note_body}
onChange={(e) => updateItem(sIdx, iIdx, 'note_body', e.target.value)}
placeholder="Hinweise zwischen Übungen …"
/>
<button
type="button"
style={{ background: 'var(--danger)', color: 'white', border: 'none' }}
onClick={() => removeItem(sIdx, iIdx)}
>
</button>
</div>
</div>
) : (
<div
key={`ex-${sIdx}-${iIdx}`}
style={{
display: 'grid',
gridTemplateColumns: '28px minmax(0, 1fr) minmax(0, 64px) 36px',
gap: '6px',
alignItems: 'start',
marginTop: '0.65rem',
paddingTop: '0.45rem',
borderTop: '1px solid rgba(0,0,0,0.06)',
minWidth: 0,
}}
>
<div style={{ display: 'flex', flexDirection: 'column', paddingTop: '6px' }}>
<button
type="button"
onClick={() => moveItem(sIdx, iIdx, -1)}
disabled={iIdx === 0}
style={{ padding: '2px', opacity: iIdx === 0 ? 0.3 : 1 }}
>
</button>
<button
type="button"
onClick={() => moveItem(sIdx, iIdx, 1)}
disabled={iIdx === sec.items.length - 1}
style={{
padding: '2px',
opacity: iIdx === sec.items.length - 1 ? 0.3 : 1,
}}
>
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', minWidth: 0 }}>
<div
style={{
display: 'flex',
gap: '6px',
flexWrap: 'wrap',
alignItems: 'center',
}}
>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
style={{ margin: 0, whiteSpace: 'nowrap' }}
onClick={() =>
onRequestExercisePick?.({ sectionIndex: sIdx, itemIndex: iIdx })
}
>
Übung suchen
</button>
{it.exercise_id && onPeekExercise ? (
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
style={{ margin: 0 }}
onClick={() => onPeekExercise(Number(it.exercise_id))}
>
Vorschau
</button>
) : null}
{(it.exercise_title || it.exercise_id) && (
<span
style={{
fontSize: '0.82rem',
flex: 1,
minWidth: 0,
wordBreak: 'break-word',
}}
>
{it.exercise_title ||
(it.exercise_id ? `Übung #${it.exercise_id}` : '')}
</span>
)}
</div>
{(() => {
const variantOpts = Array.isArray(it.variants) ? it.variants : []
return (
<select
className="form-input"
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)
)
}}
disabled={!it.exercise_id || variantOpts.length === 0}
style={{ margin: 0, fontSize: '0.82rem' }}
>
<option value="">
{variantOpts.length === 0 ? 'Keine Varianten' : 'Stammübung'}
</option>
{variantOpts.map((v) => (
<option key={v.id} value={v.id}>
{v.variant_name || `Variante #${v.id}`}
</option>
))}
</select>
)
})()}
<textarea
className="form-input"
rows={2}
value={it.notes || ''}
onChange={(e) => updateItem(sIdx, iIdx, 'notes', e.target.value)}
placeholder="Kurze Anmerkung zur Übung"
style={{ fontSize: '0.82rem' }}
/>
</div>
<input
type="number"
className="form-input"
min={1}
value={it.planned_duration_min}
onChange={(e) =>
updateItem(sIdx, iIdx, 'planned_duration_min', e.target.value)
}
placeholder="min"
title="Geplante Dauer (Minuten)"
style={{ margin: 0 }}
/>
<button
type="button"
style={{
padding: '0.45rem',
background: 'var(--danger)',
color: 'white',
border: 'none',
}}
onClick={() => removeItem(sIdx, iIdx)}
>
</button>
{showExecutionExtras ? (
<label
className="form-label"
style={{
gridColumn: '2 / -1',
marginTop: 4,
display: 'block',
fontSize: '0.78rem',
}}
>
Ist-Dauer / Anpassungen
<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"
style={{ maxWidth: '120px' }}
/>
<textarea
className="form-input"
rows={2}
value={it.modifications || ''}
onChange={(e) =>
updateItem(sIdx, iIdx, 'modifications', e.target.value)
}
placeholder="Abweichungen beim Durchführen"
style={{ marginTop: '6px', fontSize: '0.82rem' }}
/>
</label>
) : null}
</div>
)
)}
<div style={{ marginTop: '0.65rem', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => addItem(sIdx, 'exercise')}
>
+ Übung
</button>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => addItem(sIdx, 'note')}
>
+ Anmerkung
</button>
</div>
</div>
)
})}
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={addSection}
>
+ Abschnitt hinzufügen
</button>
</div>
)
}

View File

@ -3,13 +3,19 @@ import { Link, useNavigate, useParams, useLocation } from 'react-router-dom'
import api from '../utils/api'
import ExercisePickerModal from '../components/ExercisePickerModal'
import ExercisePeekModal from '../components/ExercisePeekModal'
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
import {
defaultSection,
normalizeUnitToForm,
enrichSectionsWithVariants,
buildSectionsPayload,
} from '../utils/trainingUnitSectionsForm'
const DND_FW_SLOT = 'application/x-shinkan-framework-slot'
/** Unter dieser Breite: 2 Tabs (Stammdaten | Plan); darüber: alles untereinander */
const FRAMEWORK_DESKTOP_MIN_PX = 900
const DND_FW_EX = 'application/x-shinkan-framework-exercise'
const DND_FW_SLOT = 'application/x-shinkan-framework-slot'
function reorderArray(arr, from, to) {
if (from === to || from < 0 || from >= arr.length) return [...arr]
const next = [...arr]
@ -23,12 +29,21 @@ function emptyGoal() {
return { title: '', notes: '' }
}
function emptyExercise() {
return { exercise_id: '', exercise_variant_id: '', exercise_title: '', variants: [] }
}
function emptySlot() {
return { title: '', notes: '', exercises: [] }
return { title: '', notes: '', sections: [defaultSection('Ablauf')] }
}
async function enrichFrameworkSlotSections(slots) {
const out = []
for (const s of slots || []) {
const sec = normalizeUnitToForm({ sections: s.sections, exercises: s.exercises })
out.push({
...s,
sections: await enrichSectionsWithVariants(sec),
})
}
return out
}
/** Native-Tooltip für Ziel-Chips (Hover); kurz halten für OS-Tooltip-Limits */
@ -81,52 +96,11 @@ function serverFrameworkToForm(fw) {
slots: (fw.slots || []).map((s) => ({
title: s.title || '',
notes: s.notes || '',
exercises: (s.exercises || []).map((ex) => ({
exercise_id: ex.exercise_id,
exercise_variant_id: ex.exercise_variant_id != null ? String(ex.exercise_variant_id) : '',
exercise_title: ex.exercise_title || '',
variants: [],
})),
sections: normalizeUnitToForm({ sections: s.sections, exercises: s.exercises }),
})),
}
}
async function enrichSlotExercisesWithVariants(formSlots) {
const ids = new Set()
for (const s of formSlots || []) {
for (const it of s.exercises || []) {
if (it.exercise_id) ids.add(Number(it.exercise_id))
}
}
const cache = new Map()
await Promise.all(
[...ids].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 (formSlots || []).map((s) => ({
...s,
exercises: (s.exercises || []).map((it) => {
if (!it.exercise_id) return it
const c = cache.get(Number(it.exercise_id))
if (!c) return it
return {
...it,
exercise_title: it.exercise_title || c.title,
variants: it.variants?.length ? it.variants : c.variants,
}
}),
}))
}
function buildApiPayload(form) {
const goals = (form.goals || [])
.map((g, i) => ({
@ -140,23 +114,13 @@ function buildApiPayload(form) {
}
const slots = (form.slots || []).map((s, si) => {
const exercises = (s.exercises || [])
.map((ex, j) => {
if (!ex.exercise_id) return null
const vid = ex.exercise_variant_id
return {
exercise_id: parseInt(ex.exercise_id, 10),
exercise_variant_id:
vid !== '' && vid != null && !Number.isNaN(Number(vid)) ? parseInt(vid, 10) : null,
order_index: j,
}
})
.filter(Boolean)
const secList = s.sections && s.sections.length ? s.sections : [defaultSection('Ablauf')]
const sectionsPayload = buildSectionsPayload(secList)
return {
sort_order: si,
title: (s.title || '').trim() || null,
notes: (s.notes || '').trim() || null,
exercises,
sections: sectionsPayload,
}
})
@ -212,11 +176,10 @@ export default function TrainingFrameworkProgramEditPage() {
const [styleDirections, setStyleDirections] = useState([])
const [trainingTypesCatalog, setTrainingTypesCatalog] = useState([])
const [targetGroupsCatalog, setTargetGroupsCatalog] = useState([])
const [pickerSlotIdx, setPickerSlotIdx] = useState(null)
const [sectionPickerCtx, setSectionPickerCtx] = useState(null)
const [peekId, setPeekId] = useState(null)
const [editingGoalIdx, setEditingGoalIdx] = useState(null)
const [goalMenuGi, setGoalMenuGi] = useState(null)
const [exerciseMenu, setExerciseMenu] = useState(null)
/** Nur schmal: Stammdaten | Plan (Ziele übereinander, darunter Slots) — Desktop: alles sichtbar */
const [frameworkTab, setFrameworkTab] = useState('meta')
const [desktopLayout, setDesktopLayout] = useState(
@ -237,7 +200,6 @@ export default function TrainingFrameworkProgramEditPage() {
const onPointerDown = (e) => {
const t = e.target
if (t.closest?.('.framework-popmenu-anchor')) return
setExerciseMenu(null)
setGoalMenuGi(null)
}
document.addEventListener('pointerdown', onPointerDown, true)
@ -289,7 +251,7 @@ export default function TrainingFrameworkProgramEditPage() {
const fw = await api.getTrainingFrameworkProgram(fid)
if (cancelled) return
let next = serverFrameworkToForm(fw)
next = { ...next, slots: await enrichSlotExercisesWithVariants(next.slots) }
next = { ...next, slots: await enrichFrameworkSlotSections(next.slots) }
setForm(next)
} catch (e) {
alert(e.message || 'Laden fehlgeschlagen')
@ -360,85 +322,6 @@ export default function TrainingFrameworkProgramEditPage() {
}))
}
const addExerciseToSlot = (sIdx) => {
setForm((prev) => ({
...prev,
slots: prev.slots.map((s, i) =>
i === sIdx ? { ...s, exercises: [...(s.exercises || []), emptyExercise()] } : s
),
}))
}
const moveExercise = (sIdx, eIdx, dir) => {
setForm((prev) => ({
...prev,
slots: prev.slots.map((s, i) => {
if (i !== sIdx) return s
const j = eIdx + dir
const ex = [...(s.exercises || [])]
if (j < 0 || j >= ex.length) return s
;[ex[eIdx], ex[j]] = [ex[j], ex[eIdx]]
return { ...s, exercises: ex }
}),
}))
}
const removeExercise = (sIdx, eIdx) => {
setForm((prev) => ({
...prev,
slots: prev.slots.map((s, i) => {
if (i !== sIdx) return s
return { ...s, exercises: (s.exercises || []).filter((_, ei) => ei !== eIdx) }
}),
}))
}
const setExerciseChoice = async (sIdx, eIdx, exercise) => {
const vid = ''
let variants = Array.isArray(exercise.variants) ? exercise.variants : []
let title = exercise.title || ''
if (!variants.length) {
try {
const full = await api.getExercise(exercise.id)
variants = Array.isArray(full.variants) ? full.variants : []
title = full.title || title
} catch {
variants = []
}
}
setForm((prev) => ({
...prev,
slots: prev.slots.map((s, i) => {
if (i !== sIdx) return s
const exRows = [...(s.exercises || [])]
const row = exRows[eIdx] || emptyExercise()
exRows[eIdx] = {
...row,
exercise_id: exercise.id,
exercise_variant_id: vid,
exercise_title: title,
variants,
}
return { ...s, exercises: exRows }
}),
}))
}
const exerciseField = (sIdx, eIdx, key, val) => {
setForm((prev) => ({
...prev,
slots: prev.slots.map((s, i) => {
if (i !== sIdx) return s
return {
...s,
exercises: (s.exercises || []).map((ex, ei) =>
ei === eIdx ? { ...ex, [key]: val } : ex
),
}
}),
}))
}
const handleSave = async () => {
if (!(form.title || '').trim()) {
alert('Titel ist Pflichtfeld.')
@ -465,7 +348,7 @@ export default function TrainingFrameworkProgramEditPage() {
await api.updateTrainingFrameworkProgram(fid, payload)
const refreshed = await api.getTrainingFrameworkProgram(fid)
let next = serverFrameworkToForm(refreshed)
next = { ...next, slots: await enrichSlotExercisesWithVariants(next.slots) }
next = { ...next, slots: await enrichFrameworkSlotSections(next.slots) }
setForm(next)
}
} catch (e) {
@ -487,44 +370,6 @@ export default function TrainingFrameworkProgramEditPage() {
}
}
const insertExerciseBefore = (fromS, fromE, toS, toE) => {
setForm((prev) => {
const slots = prev.slots.map((s) => ({ ...s, exercises: [...(s.exercises || [])] }))
if (fromS < 0 || fromS >= slots.length || toS < 0 || toS >= slots.length) return prev
const fromList = slots[fromS].exercises
if (fromE < 0 || fromE >= fromList.length) return prev
const [moved] = fromList.splice(fromE, 1)
let insertAt = toE
if (fromS === toS && fromE < toE) insertAt -= 1
insertAt = Math.max(0, Math.min(insertAt, slots[toS].exercises.length))
slots[toS].exercises.splice(insertAt, 0, moved)
return { ...prev, slots }
})
}
const appendExerciseToSlot = (fromS, fromE, toS) => {
setForm((prev) => {
const slots = prev.slots.map((s) => ({ ...s, exercises: [...(s.exercises || [])] }))
if (fromS < 0 || fromS >= slots.length || toS < 0 || toS >= slots.length) return prev
const fromList = slots[fromS].exercises
if (fromE < 0 || fromE >= fromList.length) return prev
const [moved] = fromList.splice(fromE, 1)
slots[toS].exercises.push(moved)
return { ...prev, slots }
})
}
const onExerciseDragStart = (e, fromS, fromE) => {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData(DND_FW_EX, JSON.stringify({ fromS, fromE }))
setExerciseMenu(null)
}
const onExerciseDragOver = (e) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}
const onSlotDragStart = (e, slotIdx) => {
e.stopPropagation()
e.dataTransfer.effectAllowed = 'move'
@ -539,17 +384,10 @@ export default function TrainingFrameworkProgramEditPage() {
const onSlotColumnDrop = (e, targetSi) => {
e.preventDefault()
const slotRaw = e.dataTransfer.getData(DND_FW_SLOT)
if (slotRaw) {
const { slotIdx } = JSON.parse(slotRaw)
if (slotIdx !== targetSi) {
setForm((prev) => ({ ...prev, slots: reorderArray([...prev.slots], slotIdx, targetSi) }))
}
return
}
const exRaw = e.dataTransfer.getData(DND_FW_EX)
if (exRaw) {
const { fromS, fromE } = JSON.parse(exRaw)
appendExerciseToSlot(fromS, fromE, targetSi)
if (!slotRaw) return
const { slotIdx } = JSON.parse(slotRaw)
if (slotIdx !== targetSi) {
setForm((prev) => ({ ...prev, slots: reorderArray([...prev.slots], slotIdx, targetSi) }))
}
}
@ -625,11 +463,9 @@ export default function TrainingFrameworkProgramEditPage() {
<div className="card" style={{ marginBottom: '1rem', background: 'var(--surface2)', borderStyle: 'dashed' }}>
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.55, margin: 0 }}>
<strong style={{ color: 'var(--text1)' }}>Rahmenprogramm (Bibliothek):</strong> Wiederverwendbare Vorlage mit
Zielen und SessionSlots. <strong>Zuordnung zu einer Trainingsgruppe</strong> oder zu{' '}
<strong>konkreten Einheiten</strong> erfolgt aus der <strong>GruppenPlanung</strong> (Übernahme mit Link /
Lineage) nicht mehr direkt an diesem Datensatz. Pro Slot ist derzeit eine Übungsliste (Stückliste) hinterlegt;
die strukturierte Einheitenplanung (Abschnitte wie in der Trainingsplanung) folgt über{' '}
<strong>CURR010</strong> (VorlagenModell pro Slot).
Zielen und SessionSlots. <strong>Zuordnung zu Gruppe oder Kalendertermin</strong> erfolgt aus der{' '}
<strong>GruppenPlanung</strong> (Übernahme). Pro Slot planst du den Ablauf wie bei einer Trainingsseinheit:{' '}
<strong>Abschnitte</strong>, Übungen mit Varianten und Dauer, <strong>ZwischenAnmerkungen</strong>.
</p>
</div>
@ -987,7 +823,7 @@ export default function TrainingFrameworkProgramEditPage() {
<div className="card framework-plan-slots" style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
<h3 className="card-title" style={{ marginBottom: 0 }}>
SessionSlots & Übungen
SessionSlots & Ablauf
</h3>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={addSlot}>
+ Slot
@ -996,16 +832,15 @@ export default function TrainingFrameworkProgramEditPage() {
{form.slots.length === 0 ? (
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>
Noch keine Slots mit <strong>+ Slot</strong> legst du Spalten an (z.B. Woche 1) und ordnest Übungen
zu. Bei mehreren Spalten erscheint eine horizontale Scroll-Leiste.
Noch keine Slots mit <strong>+ Slot</strong> legst du Spalten an (z.B. Woche 1). In jedem Slot
strukturierst du wie in der Trainingsplanung: Abschnitte, Übungen, Anmerkungen.
</p>
) : (
<p
className="framework-slots-hint"
style={{ fontSize: '0.8rem', color: 'var(--text3)', marginBottom: '10px', lineHeight: 1.45 }}
>
Sessions nebeneinander nach rechts scrollen bzw. wischen. Reihenfolge mit Griff ( / )
per Drag & Drop möglich (Browser-abhängig bei Touch); zusätzlich Pfeile und Menüs nutzbar.
Reihenfolge der Spalten per Griff () ziehen; Inhalt eines Slots wie in der Planung bearbeiten.
</p>
)}
@ -1079,190 +914,35 @@ export default function TrainingFrameworkProgramEditPage() {
</div>
</details>
<div className="framework-slot-card__exercises">
<div className="framework-slot-card__exercises-head">
<span className="framework-slot-card__exercises-title">Übungen</span>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => addExerciseToSlot(si)}
>
+ Übung
</button>
</div>
{(slot.exercises || []).length === 0 ? (
<p className="framework-slot-card__empty-hint">
Noch keine Übung <strong>+ Übung</strong> oder Übung mit dem Griff hierher ziehen.
</p>
) : null}
{(slot.exercises || []).map((ex, ei) => (
<div
key={ei}
className="framework-ex-row"
onDragOver={onExerciseDragOver}
onDrop={(e) => {
e.preventDefault()
e.stopPropagation()
const raw = e.dataTransfer.getData(DND_FW_EX)
if (!raw) return
const { fromS, fromE } = JSON.parse(raw)
if (fromS === si && fromE === ei) return
insertExerciseBefore(fromS, fromE, si, ei)
}}
>
<span
className="framework-ex-row__grip"
draggable
onDragStart={(e) => {
e.stopPropagation()
onExerciseDragStart(e, si, ei)
}}
aria-hidden
>
</span>
<div className="framework-ex-row__body">
<div className="framework-ex-row__title-line">
{ex.exercise_id ? (
<strong className="framework-ex-row__title">
{ex.exercise_title || `Übung #${ex.exercise_id}`}
</strong>
) : (
<span className="framework-ex-row__title framework-ex-row__title--muted">
Keine Übung gewählt
</span>
)}
{ex.exercise_id ? (
<span className="framework-ex-row__id">#{ex.exercise_id}</span>
) : null}
</div>
<div className="framework-ex-row__row2">
{ex.exercise_id && (ex.variants || []).length > 0 ? (
<select
className="form-input framework-ex-row__variant-select"
value={ex.exercise_variant_id}
onChange={(e) => exerciseField(si, ei, 'exercise_variant_id', e.target.value)}
>
<option value="">Variante</option>
{(ex.variants || []).map((v) => (
<option key={v.id} value={String(v.id)}>
{v.variant_name || v.name || `V ${v.id}`}
</option>
))}
</select>
) : (
<span className="framework-ex-row__variant-spacer" aria-hidden />
)}
<div className="framework-popmenu-anchor framework-ex-row__menu-anchor">
<button
type="button"
className="framework-ex-row__kebab"
aria-label="Übung-Aktionen"
aria-haspopup="menu"
aria-expanded={
exerciseMenu?.slotIdx === si && exerciseMenu?.exIdx === ei ? 'true' : 'false'
<div className="framework-slot-card__plan-editor" style={{ marginTop: '0.65rem', minHeight: '120px' }}>
<TrainingUnitSectionsEditor
heading={`Ablauf · Session ${si + 1}`}
sections={slot.sections}
showExecutionExtras={false}
onSectionsChange={(updater) => {
setForm((prev) => ({
...prev,
slots: prev.slots.map((sl, ii) =>
ii !== si
? sl
: {
...sl,
sections: updater(
sl.sections && sl.sections.length ? sl.sections : [defaultSection('Ablauf')]
),
}
onClick={(e) => {
e.stopPropagation()
setExerciseMenu((prev) =>
prev?.slotIdx === si && prev?.exIdx === ei
? null
: { slotIdx: si, exIdx: ei }
)
}}
>
</button>
{exerciseMenu?.slotIdx === si && exerciseMenu?.exIdx === ei ? (
<ul className="framework-popmenu framework-popmenu--align-end" role="menu">
<li>
<button
type="button"
role="menuitem"
className="framework-popmenu__item"
onClick={() => {
setPickerSlotIdx({ slotIdx: si, exerciseIdx: ei })
setExerciseMenu(null)
}}
>
Übung wählen
</button>
</li>
{ex.exercise_id ? (
<li>
<button
type="button"
role="menuitem"
className="framework-popmenu__item"
onClick={() => {
setPeekId(Number(ex.exercise_id))
setExerciseMenu(null)
}}
>
Vorschau
</button>
</li>
) : null}
<li>
<button
type="button"
role="menuitem"
className="framework-popmenu__item"
onClick={() => {
moveExercise(si, ei, -1)
setExerciseMenu(null)
}}
>
Nach oben in diesem Slot
</button>
</li>
<li>
<button
type="button"
role="menuitem"
className="framework-popmenu__item"
onClick={() => {
moveExercise(si, ei, 1)
setExerciseMenu(null)
}}
>
Nach unten in diesem Slot
</button>
</li>
<li>
<button
type="button"
role="menuitem"
className="framework-popmenu__item framework-popmenu__item--danger"
onClick={() => {
removeExercise(si, ei)
setExerciseMenu(null)
}}
>
Entfernen
</button>
</li>
</ul>
) : null}
</div>
</div>
</div>
</div>
))}
<div
className="framework-slot-card__append-drop"
onDragOver={onExerciseDragOver}
onDrop={(e) => {
e.preventDefault()
e.stopPropagation()
const raw = e.dataTransfer.getData(DND_FW_EX)
if (!raw) return
const { fromS, fromE } = JSON.parse(raw)
appendExerciseToSlot(fromS, fromE, si)
),
}))
}}
>
Ans Ende ziehen
</div>
onRequestExercisePick={({ sectionIndex, itemIndex }) =>
setSectionPickerCtx({
slotIdx: si,
sectionIndex,
itemIndex,
})
}
onPeekExercise={(id) => setPeekId(id)}
/>
</div>
</div>
))}
@ -1288,12 +968,54 @@ export default function TrainingFrameworkProgramEditPage() {
</div>
<ExercisePickerModal
open={pickerSlotIdx != null}
onClose={() => setPickerSlotIdx(null)}
onSelectExercise={(exercise) => {
if (!pickerSlotIdx) return
setExerciseChoice(pickerSlotIdx.slotIdx, pickerSlotIdx.exerciseIdx, exercise)
setPickerSlotIdx(null)
open={sectionPickerCtx != null}
onClose={() => setSectionPickerCtx(null)}
onSelectExercise={async (exercise) => {
if (!sectionPickerCtx) return
const { slotIdx, sectionIndex: sIdx, itemIndex: iIdx } = sectionPickerCtx
let variants = Array.isArray(exercise.variants) ? exercise.variants : []
let title = exercise.title || ''
if (!variants.length) {
try {
const full = await api.getExercise(exercise.id)
variants = Array.isArray(full.variants) ? full.variants : []
title = full.title || title
} catch {
variants = []
}
}
setForm((prev) => ({
...prev,
slots: prev.slots.map((sl, ii) =>
ii !== slotIdx
? sl
: {
...sl,
sections: (sl.sections && sl.sections.length ? sl.sections : [defaultSection('Ablauf')]).map(
(sec, si) =>
si !== sIdx
? sec
: {
...sec,
items: (sec.items || []).map((row, ji) =>
ji !== iIdx
? row
: row.item_type !== 'exercise'
? row
: {
...row,
exercise_id: exercise.id,
exercise_variant_id: '',
exercise_title: title,
variants,
}
),
}
),
}
),
}))
setSectionPickerCtx(null)
}}
/>

View File

@ -4,175 +4,13 @@ import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import ExercisePickerModal from '../components/ExercisePickerModal'
import ExercisePeekModal from '../components/ExercisePeekModal'
function defaultSection(title = 'Hauptteil') {
return { title, guidance_notes: '', items: [] }
}
function exerciseRow() {
return {
item_type: 'exercise',
exercise_id: '',
exercise_variant_id: '',
exercise_title: '',
variants: [],
planned_duration_min: '',
actual_duration_min: '',
notes: '',
modifications: ''
}
}
function noteRow() {
return { item_type: 'note', note_body: '' }
}
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()]
}
/** Lädt Varianten/Titel nach, wenn Einheit vom Server ohne variants[] im Client-State ist. */
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,
}
}),
}))
}
function parseMin(v) {
if (v === '' || v === null || v === undefined) return null
const n = parseInt(String(v), 10)
return Number.isFinite(n) ? n : null
}
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)
}))
}
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)
}
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
import {
defaultSection,
normalizeUnitToForm,
enrichSectionsWithVariants,
buildSectionsPayload,
} from '../utils/trainingUnitSectionsForm'
function TrainingPlanningPage() {
const { user } = useAuth()
@ -435,93 +273,6 @@ function TrainingPlanningPage() {
setFormData((prev) => ({ ...prev, [field]: value }))
}
const updateSectionField = (sIdx, field, value) => {
setFormData((prev) => ({
...prev,
sections: prev.sections.map((s, i) => (i === sIdx ? { ...s, [field]: value } : s))
}))
}
const addSection = () => {
setFormData((prev) => ({
...prev,
sections: [...prev.sections, defaultSection(`Abschnitt ${prev.sections.length + 1}`)]
}))
}
const removeSection = (sIdx) => {
setFormData((prev) => {
const next = prev.sections.filter((_, i) => i !== sIdx)
return { ...prev, sections: next.length ? next : [defaultSection()] }
})
}
const moveSection = (sIdx, dir) => {
setFormData((prev) => {
const ta = sIdx + dir
if (ta < 0 || ta >= prev.sections.length) return prev
const copy = [...prev.sections]
;[copy[sIdx], copy[ta]] = [copy[ta], copy[sIdx]]
return { ...prev, sections: copy }
})
}
const addItem = (sIdx, kind) => {
setFormData((prev) => ({
...prev,
sections: prev.sections.map((s, i) => {
if (i !== sIdx) return s
const row = kind === 'note' ? noteRow() : exerciseRow()
return { ...s, items: [...s.items, row] }
})
}))
}
const updateItem = (sIdx, iIdx, field, value) => {
setFormData((prev) => ({
...prev,
sections: prev.sections.map((s, si) => {
if (si !== sIdx) return s
return {
...s,
items: s.items.map((it, ii) => {
if (ii !== iIdx) return it
const next = { ...it, [field]: value }
if (field === 'exercise_id') {
next.exercise_variant_id = ''
next.exercise_title = ''
next.variants = []
}
return next
})
}
})
}))
}
const removeItem = (sIdx, iIdx) => {
setFormData((prev) => ({
...prev,
sections: prev.sections.map((s, i) =>
i !== sIdx ? s : { ...s, items: s.items.filter((_, j) => j !== iIdx) }
)
}))
}
const moveItem = (sIdx, iIdx, dir) => {
setFormData((prev) => ({
...prev,
sections: prev.sections.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 }
})
}))
}
if (loading) {
return (
<div className="app-page" style={{ padding: '2rem 0', textAlign: 'center' }}>
@ -958,316 +709,24 @@ function TrainingPlanningPage() {
</button>
</div>
{formData.sections.map((sec, sIdx) => {
const planMin = sectionPlannedMinutes(sec)
return (
<div
key={`sec-${sIdx}`}
style={{
marginBottom: '1.25rem',
padding: '1rem',
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.75rem' }}>
<input
className="form-input"
style={{ flex: '2 1 220px', 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 === formData.sections.length - 1}
style={{
padding: '4px 10px',
opacity: sIdx === formData.sections.length - 1 ? 0.35 : 1
}}
>
</button>
</div>
<button type="button" className="btn" 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 (Aufbau, Zielrichtung der Gruppe, Material …)"
/>
{planMin > 0 && (
<p style={{ fontSize: '0.8rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
Geplant in diesem Abschnitt: ca. {planMin} Min. (Übungen)
</p>
)}
<TrainingUnitSectionsEditor
hideHeading
sections={formData.sections}
onSectionsChange={(updater) =>
setFormData((prev) => ({
...prev,
sections: updater(prev.sections),
}))
}
onRequestExercisePick={({ sectionIndex, itemIndex }) => {
setExercisePickerTarget({ sIdx: sectionIndex, iIdx: itemIndex })
setExercisePickerOpen(true)
}}
onPeekExercise={(id) => setPlanningPeekExerciseId(id)}
showExecutionExtras={!!editingUnit}
/>
{(sec.items || []).map((it, iIdx) =>
it.item_type === 'note' ? (
<div key={`note-${sIdx}-${iIdx}`} style={{ marginTop: '0.75rem' }}>
<div style={{ fontSize: '0.78rem', color: 'var(--text2)', marginBottom: '4px' }}>
Zwischen-Anmerkung
</div>
<div style={{ display: 'flex', gap: '6px', alignItems: 'start' }}>
<div style={{ display: 'flex', flexDirection: 'column', paddingTop: '4px' }}>
<button
type="button"
onClick={() => moveItem(sIdx, iIdx, -1)}
disabled={iIdx === 0}
style={{ padding: '2px', opacity: iIdx === 0 ? 0.3 : 1 }}
>
</button>
<button
type="button"
onClick={() => moveItem(sIdx, iIdx, 1)}
disabled={iIdx === sec.items.length - 1}
style={{
padding: '2px',
opacity: iIdx === sec.items.length - 1 ? 0.3 : 1
}}
>
</button>
</div>
<textarea
className="form-input"
rows={2}
style={{ flex: 1 }}
value={it.note_body}
onChange={(e) => updateItem(sIdx, iIdx, 'note_body', e.target.value)}
placeholder="Hinweise (Gruppen teilen, Hallenführung, Auf- und Abbau …)"
/>
<button
type="button"
style={{ background: 'var(--danger)', color: 'white', border: 'none' }}
onClick={() => removeItem(sIdx, iIdx)}
>
</button>
</div>
</div>
) : (
<div
key={`ex-${sIdx}-${iIdx}`}
style={{
display: 'grid',
gridTemplateColumns: '32px minmax(0, 1fr) minmax(0, 72px) 40px',
gap: '6px',
alignItems: 'start',
marginTop: '0.75rem',
paddingTop: '0.5rem',
borderTop: '1px solid rgba(0,0,0,0.06)',
minWidth: 0
}}
>
<div style={{ display: 'flex', flexDirection: 'column', paddingTop: '6px' }}>
<button
type="button"
onClick={() => moveItem(sIdx, iIdx, -1)}
disabled={iIdx === 0}
style={{ padding: '2px', opacity: iIdx === 0 ? 0.3 : 1 }}
>
</button>
<button
type="button"
onClick={() => moveItem(sIdx, iIdx, 1)}
disabled={iIdx === sec.items.length - 1}
style={{
padding: '2px',
opacity: iIdx === sec.items.length - 1 ? 0.3 : 1
}}
>
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', minWidth: 0 }}>
<div
style={{
display: 'flex',
gap: '8px',
flexWrap: 'wrap',
alignItems: 'center'
}}
>
<button
type="button"
className="btn btn-secondary"
style={{ margin: 0, whiteSpace: 'nowrap' }}
onClick={() => {
setExercisePickerTarget({ sIdx, iIdx })
setExercisePickerOpen(true)
}}
>
Übung suchen
</button>
{it.exercise_id ? (
<button
type="button"
className="btn btn-secondary"
style={{ margin: 0, whiteSpace: 'nowrap', fontSize: '0.8rem', padding: '6px 10px' }}
onClick={() => setPlanningPeekExerciseId(it.exercise_id)}
>
Katalog kurz zeigen
</button>
) : null}
{(it.exercise_title || it.exercise_id) && (
<span
style={{
fontSize: '0.875rem',
flex: 1,
minWidth: 0,
wordBreak: 'break-word'
}}
title={
it.exercise_title ||
(it.exercise_id ? `Übung #${it.exercise_id}` : '')
}
>
{it.exercise_title ||
(it.exercise_id ? `Übung #${it.exercise_id}` : '')}
</span>
)}
</div>
{(() => {
const variantOpts = Array.isArray(it.variants) ? it.variants : []
return (
<select
className="form-input"
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)
)
}}
disabled={!it.exercise_id || variantOpts.length === 0}
style={{ margin: 0, fontSize: '0.875rem' }}
>
<option value="">
{variantOpts.length === 0
? 'Keine Varianten'
: 'Stammübung'}
</option>
{variantOpts.map((v) => (
<option key={v.id} value={v.id}>
{v.variant_name || `Variante #${v.id}`}
</option>
))}
</select>
)
})()}
<textarea
className="form-input"
rows={editingUnit ? 2 : 1}
value={it.notes || ''}
onChange={(e) => updateItem(sIdx, iIdx, 'notes', e.target.value)}
placeholder="Kurze Anmerkung zur Übung"
style={{ fontSize: '0.875rem' }}
/>
</div>
<input
type="number"
className="form-input"
min={1}
value={it.planned_duration_min}
onChange={(e) =>
updateItem(sIdx, iIdx, 'planned_duration_min', e.target.value)
}
placeholder="min"
title="Geplante Dauer (Minuten)"
style={{ margin: 0 }}
/>
<button
type="button"
style={{
padding: '0.5rem',
background: 'var(--danger)',
color: 'white',
border: 'none'
}}
onClick={() => removeItem(sIdx, iIdx)}
>
</button>
{editingUnit && (
<label
className="form-label"
style={{
gridColumn: '2 / -1',
marginTop: 4,
display: 'block',
fontSize: '0.8rem'
}}
>
Ist-Dauer / Anpassungen
<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"
style={{ maxWidth: '120px' }}
/>
<textarea
className="form-input"
rows={2}
value={it.modifications || ''}
onChange={(e) =>
updateItem(sIdx, iIdx, 'modifications', e.target.value)
}
placeholder="Abweichungen beim Durchführen"
style={{ marginTop: '6px', fontSize: '0.875rem' }}
/>
</label>
)}
</div>
)
)}
<div style={{ marginTop: '0.85rem', display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
<button type="button" className="btn btn-secondary" onClick={() => addItem(sIdx, 'exercise')}>
+ Übung
</button>
<button type="button" className="btn btn-secondary" onClick={() => addItem(sIdx, 'note')}>
+ Anmerkung
</button>
</div>
</div>
)
})}
<button type="button" className="btn btn-secondary" onClick={addSection} style={{ marginBottom: '1.75rem' }}>
+ Abschnitt hinzufügen
</button>
<div style={{ marginBottom: '1.75rem' }} />
{editingUnit && (
<>

View File

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