Merge pull request 'Module und Kombinationsübnungen in Version 0.8' (#31) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 54s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / playwright-tests (push) Successful in 1m9s

Reviewed-on: #31
This commit is contained in:
Lars 2026-05-13 16:16:49 +02:00
commit 3214055531
57 changed files with 9322 additions and 374 deletions

View File

@ -1,21 +1,25 @@
# Shinkan Jinkendo - Projekt-Status
**Stand:** 2026-05-08
**Version (Code):** 0.8.64 (`backend/version.py`, APP_VERSION)
**DB-Schema-Version:** `20260508049` (`backend/version.py`, DB_SCHEMA_VERSION)
**Stand:** 2026-05-12
**Version (Code):** 0.8.96 (`backend/version.py`, APP_VERSION)
**DB-Schema-Version:** `20260511053` (`backend/version.py`, DB_SCHEMA_VERSION)
**Branch:** develop
---
## Executive Summary
**Aktueller Meilenstein (Medien):** Das **Medien-Archiv** (`media_assets` + `exercise_media.media_asset_id`) ist **produktiv nutzbar**: zentrale Bibliothek **`/media`** (Kacheln/Liste, Filter inkl. Lifecycle, Suche/Tags, Copyright, Bulk-Lifecycle und Bulk-PATCH), **Verknüpfung aus dem Archiv** in der Übungsbearbeitung (`POST …/media/from-asset`), **deduplizierter Speicher** unter **`library/…`** (Vereinsordner aus Name, Medienkind-Unterordner, Governance-Umzug bei Sichtbarkeitsänderung), **Papierkorb & Lifecycle** (Reaktivierung, Soft-Trash, Superadmin-Purge), plus **Inline-Medien im Rich-Text** (Modal-Picker, Größenwahl, Drag&Drop mit Auto-Scroll, Vorschau-/Rückweg-UX). **Governance:** Sichtbarkeit **`official`** nur noch **Superadmin** (Übungen und Medien); Plattform-Admin wie Trainer für Vereins-/Private-Inhalte. **Vereinsübungen** mit Datei-Assets: **Copyright-Pflicht** (API/UI). **Aktiver Verein:** Dropdown, Profilfeld `active_club_id`, Header `X-Active-Club-Id` und `effective_club_id` sind nach **0.8.59** synchronisiert (inkl. Plattform-Admin ohne Header beim ersten Request).
**Aktueller Meilenstein (Medien):** Das **Medien-Archiv** (`media_assets` + `exercise_media.media_asset_id`) ist **produktiv nutzbar**: zentrale Bibliothek **`/media`** (Kacheln/Liste, Filter inkl. Lifecycle, Suche/Tags, Copyright, Bulk-Lifecycle und Bulk-PATCH), **Verknüpfung aus dem Archiv** in der Übungsbearbeitung (`POST …/media/from-asset`), **deduplizierter Speicher** unter **`library/…`**, **Papierkorb & Lifecycle**, plus **Inline-Medien im Rich-Text** (Modal-Picker, Größenwahl, Drag-and-Drop mit Auto-Scroll). **Governance:** Sichtbarkeit **`official`** nur **Superadmin** (Übungen und Medien). **Vereinsübungen** mit Datei-Assets: **Copyright-Pflicht** (API/UI). **Aktiver Verein:** Dropdown, Profilfeld `active_club_id`, Header `X-Active-Club-Id` und `effective_club_id` sind nach **0.8.59** synchronisiert.
**Melde- und Transparenzpfad (P-13, seit 0.8.87 ff.):** **Inhaltsmeldungen** mit Workflow im Posteingang, Club-Admin-Beteiligung für Vereinsmedien, Legal-Hold-Anbindung, Badges in der Medienbibliothek; Folgepakete P-14P-16 bewusst offen (siehe `docs/HANDOVER.md`).
**Plattform-Rechtstexte (P-01, 0.8.950.8.96):** Admin-Editor mit **Abschnitts- und Vollvorschau** (Markdown); fortlaufende Abschnittsnummerierung in der Anzeige/PDF (Darstellung, nicht DB-persistent).
**Parallel weiter relevant:** **Trainingsrahmenprogramm** (036037), **Progressionsgraph** (032034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**.
**Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) §12 · Medien-Norm: [`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`](technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md) (inkl. **§11 Inline-Medien**, umgesetzt)
**Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) Abschnitt 12 · Medien-Norm: [`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`](technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md) (inkl. **Abschnitt 11 Inline-Medien**, umgesetzt) · **Fachlicher Nutzerüberblick:** [`../../docs/FACHLICHE_NUTZERFUNKTIONEN.md`](../../docs/FACHLICHE_NUTZERFUNKTIONEN.md)
**Nächste Schritte — Medien & Archiv** (Stand 2026-05-08, für **neue Session**):
**Nächste Schritte — Medien & Archiv** (Stand 2026-05-12, für **neue Session**):
1. ~~**Übung → `official` Promotion** inkl. Medien-Anhebung + **Copyright-Pflicht** bei `official` (Spec §4.2)~~ — umgesetzt (0.8.47).
2. ~~**Eigenständige Medienmanager-Seite**~~**Basis umgesetzt** (`/media`); Ausbau nach Bedarf: Quotas, feinere Bulk-Workflows, Sichtbarkeits-PATCH in der UI vereinheitlichen.
@ -23,7 +27,7 @@
4. **S3 / externes Backend** hinter Speicher-Abstraktion (Spec §7) — nach stabiler Nutzung lokaler/NAS-Pfade.
5. **Inline-Medien im Fließtext (Spec §11)****Basis umgesetzt (0.8.600.8.64)**: Platzhalter-Syntax, zentraler Renderer, Modal-Picker, Drag&Drop + Auto-Scroll; offen: weitere UX-Politur und ggf. strategischer Umbau auf reine Asset-Referenz (separat zu entscheiden).
**Inline:** verbindliche Leitplanken **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §11**; Umsetzung aktiv im Produktpfad (RTE + Anzeige).
**Inline:** verbindliche Leitplanken **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`** Abschnitt 11; Umsetzung aktiv im Produktpfad (RTE + Anzeige).
---
@ -150,17 +154,18 @@ Deployment der oben genannten Migrationen und Datenabgleich nach internem Prozes
| Dokument | Pfad | Stand | Status |
|----------|------|-------|--------|
| Lieferliste Q2 2026 | `library/FEATURES_DELIVERED_2026-Q2.md` | 2026-05-08 | ✅ Aktualisiert (§12 Medien inkl. Inline 0.8.600.8.64) |
| Fachliche Nutzerfunktionen (Design/Product) | `docs/FACHLICHE_NUTZERFUNKTIONEN.md` | 2026-05-12 | neu, Ist-Überblick |
| Lieferliste Q2 2026 | `library/FEATURES_DELIVERED_2026-Q2.md` | 2026-05-12 | Verweis Version siehe `version.py` |
| Trainingsrahmen + Graph | `technical/TRAINING_FRAMEWORK_SPEC.md` | 2026-05-05 | ✅ §2 Blueprint |
| Anforderungen (Index) | `functional/SHINKAN_REQUIREMENTS.md` | 2026-04-27 | ✅ Neu |
| Anforderungen (Index) | `functional/SHINKAN_REQUIREMENTS.md` | 2026-05-12 | Verweis Nutzerüberblick |
| Database Schema | `technical/DATABASE_SCHEMA.md` | 2026-05-07 | ✅ Hinweis 040046 Medien (Kurz) |
| Domain Model | `functional/DOMAIN_MODEL.md` | 2026-05-07 | ✅ Abschnitt Medien-Archiv |
| Domain Model | `functional/DOMAIN_MODEL.md` | 2026-05-12 | Version 0.4.5, Verweis Nutzerüberblick |
| API Übungen | `technical/EXERCISES_API_SPEC.md` | 2026-05-08 | ✅ Medien/Inline-Workflow ergänzt |
| 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-05-07 | ✅ Verweis Archiv/Inline |
| Medien-Archiv & Lifecycle | `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` | 2026-05-08 | ✅ Ist-Changelog + §11 Inline erweitert |
| Projektstatus | `PROJECT_STATUS.md` | 2026-05-08 | ✅ auf 0.8.64 aktualisiert |
| Projektstatus | `PROJECT_STATUS.md` | 2026-05-12 | auf 0.8.96 + P-13/P-01 + Nutzerüberblick |
---
@ -171,4 +176,4 @@ Deployment der oben genannten Migrationen und Datenabgleich nach internem Prozes
---
**Letzte Aktualisierung:** 2026-05-08 (Inline-/Medien-Workflow 0.8.600.8.64 konsolidiert)
**Letzte Aktualisierung:** 2026-05-12 (Version 0.8.96, Executive Summary P-13/P-01, `docs/FACHLICHE_NUTZERFUNKTIONEN.md`)

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo - Fachliches Domänenmodell
**Version:** 0.4.4
**Stand:** 2026-05-08 (Medien-Archiv **`media_assets`** / Bibliothek **`/media`** im Ist; **Inline-Medien** im Fließtext umgesetzt — `MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §11)
**Version:** 0.4.5
**Stand:** 2026-05-12 (Fachlicher Nutzerüberblick: `docs/FACHLICHE_NUTZERFUNKTIONEN.md`)
**Basis:** `shinkan_anforderungsdokument_entwurf.md` + Fähigkeitsmatrix
---

View File

@ -4,11 +4,15 @@ Ausführliche fachliche Inhalte:
| Dokument | Inhalt |
|----------|--------|
| [**Fachliche Nutzerfunktionen (Ist, Überblick)**](../../../docs/FACHLICHE_NUTZERFUNKTIONEN.md) | Kompakte **Nutzer-/Rollen-Perspektive** zur Übergabe an Design & Product (ohne Implementierungsdetail) |
| [shinkan_anforderungsdokument_entwurf.md](./shinkan_anforderungsdokument_entwurf.md) | Gesamtentwurf Anforderungen |
| [DOMAIN_MODEL.md](./DOMAIN_MODEL.md) | Domänenmodell, Variantenlogik (§11.2), **Medien-Archiv** (Abschnitt 2026-05) |
| [MEDIA_ASSETS_AND_ARCHIVE_SPEC.md](../technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md) | Medien-Archiv, Lifecycle, **geplante Inline-Medien §11** |
| [DOMAIN_MODEL.md](./DOMAIN_MODEL.md) | Domänenmodell, Variantenlogik (Abschnitt 11.2), **Medien-Archiv** (Abschnitt 2026-05) |
| [MEDIA_ASSETS_AND_ARCHIVE_SPEC.md](../technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md) | Medien-Archiv, Lifecycle, **Inline-Medien** (Spec Abschnitt 11, umgesetzt) |
| [MULTI_TENANCY_RBAC_ARCHITECTURE.md](../technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md) | Zielarchitektur Mandanten/Rollen/Membership & Umsetzungsplan |
| [**Trainingsmodule & Kombinationsübungen (Fachspez V3)**](./Shinkan%20Trainingsmodule%20Kombinationsuebungen%20Spezifikation%20V2.md) | Produktlogik Module/Kombinationen, **Methoden-Archetypen**, **Coaching-Stufen (§10.4)**, kanonische Archetyp-IDs **§10.2.1**, **Anhang A** Implementierungsabgleich |
| [**Umsetzungsplan Trainingsmodule & Kombination**](../working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md) | Phase 15, Coaching-Pakete 4a4d, Verweis auf Code-Stand |
| [**Technischer Entwurf Module/Kombination**](../technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md) | API/Daten-Ideen; aktueller Coach-/Archetyp-Abgleich im Kopfabschnitt |
**Lieferstand & Umsetzung (Stand Code):** [`../PROJECT_STATUS.md`](../PROJECT_STATUS.md), [`../library/FEATURES_DELIVERED_2026-Q2.md`](../library/FEATURES_DELIVERED_2026-Q2.md) §12, Repo-Root **`docs/HANDOVER.md`**.
**Lieferstand & Umsetzung (Stand Code):** [`../PROJECT_STATUS.md`](../PROJECT_STATUS.md), [`../library/FEATURES_DELIVERED_2026-Q2.md`](../library/FEATURES_DELIVERED_2026-Q2.md) (Abschnitt 12), Repo-Root **`docs/HANDOVER.md`**, **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`**.
`CLAUDE.md` (Repo-Root) verweist hierher als Einstieg.

View File

@ -0,0 +1,814 @@
# Trainingsmodule und Kombinationsübungen — fachliche Spezifikation V3
**Status:** fachlicher Spezifikationsentwurf
**Stand:** 2026-05-12 (AnhangA **grob** App **0.8.104**; ZeitPfad **`COMBINATION_TIMING_PROFILE_PLAN.md`**) · **Coaching/Archetypen:** §10.2.1, §10.410.5, **§5.4/§6.3** Methoden/Archetypen/Zeitschicht · **Anhang A**
**Zweck:** Produkt- und Fachspezifikation für Trainingsmodule, Kombinationsübungen, Trainingsmethodenbezug, Planungsintegration und Coaching-Modus in Shinkan.
**Wichtige Leitlinie dieser Version:**
Diese Spezifikation beschreibt bewusst **keine verbindlichen Tabellen, API-Pfade, Spaltennamen oder konkrete Implementierungsdetails**. Die technische Umsetzung soll durch den Coding Agent auf Basis der bestehenden Codebasis geplant werden. Ziel dieses Dokuments ist es, die fachliche Zielarchitektur, Nutzerlogik, Datenbedeutung und Produktentscheidungen so klar zu beschreiben, dass spätere große Refactorings vermieden werden, ohne die bestehende Anwendung durch zu frühe technische Festlegungen zu destabilisieren.
---
## 1. Ausgangslage
Shinkan ist eine trainerzentrierte App für Übungsverwaltung, Trainingsplanung, Rahmenprogramme und Durchführung. Die bestehende Planung arbeitet fachlich mit Trainingseinheiten, Trainingsabschnitten und Einträgen wie Übungen oder Notizen.
Für die nächste Ausbaustufe werden zwei zusätzliche fachliche Bausteine benötigt:
1. **Kombinationsübungen**
Strukturierte Übungsformen, bei denen mehrere Einzelübungen, Stationen, Rollen oder Schritte methodisch zusammenwirken.
2. **Trainingsmodule**
Wiederverwendbare Planungsbausteine, also gespeicherte Übungsfolgen oder Trainingsblöcke, die in konkrete Trainings oder Rahmenprogramme übernommen werden können.
Zusätzlich muss geklärt werden, wie **Trainingsmethoden**, **Methoden-Archetypen** und **konkrete Ablaufprofile** fachlich voneinander getrennt werden.
---
## 2. Fachliche Grundentscheidungen
### 2.1 Trainingsabschnitte bleiben Makrostruktur
Trainingsabschnitte beschreiben die grobe Struktur einer Trainingseinheit, z. B.:
* Aufwärmen,
* Hauptteil,
* Kumite,
* Kata,
* Selbstschutz,
* Abschluss.
Ein Abschnitt ist damit ein Gliederungselement der gesamten Trainingseinheit.
### 2.2 Kombinationsübungen sind nicht an genau einen Abschnitt gebunden
Eine Kombinationsübung darf nicht fachlich oder technisch auf genau einen Trainingsabschnitt reduziert werden.
Sie kann:
* innerhalb eines Abschnitts verwendet werden,
* einen Abschnitt faktisch ausfüllen,
* zwischen zwei Abschnitten stehen,
* als zentraler Block der Einheit auf Trainingsebene liegen,
* Bestandteil eines Trainingsmoduls sein,
* Bestandteil eines Rahmenprogramms oder Rahmen-Slots sein.
Der Abschnitt kann ein sinnvoller Anzeige- oder Planungskontext sein, ist aber nicht die fachliche Heimat der Kombinationsübung.
### 2.3 Kombinationsübungen gehören fachlich zum Übungsbereich
Eine Kombinationsübung ist eine Sonderform einer Übung. Sie besitzt daher die typischen Eigenschaften einer Übung:
* Titel,
* Ziel,
* Durchführung,
* Trainerhinweise,
* Vorbereitung,
* Hilfsmittel,
* Dauer,
* Zielgruppe,
* Fähigkeiten,
* Methodenbezug,
* Medien,
* Sichtbarkeit,
* Freigabestatus.
Zusätzlich besitzt sie eine interne Struktur:
* Slots,
* Stationen,
* Rollen,
* Schritte,
* Übungspools,
* Methoden-Archetyp,
* Ablaufprofil für Planung und Coaching.
### 2.4 Trainingsmodule gehören fachlich zur Planung
Trainingsmodule sind keine Übungen, sondern wiederverwendbare Planungsbausteine.
Ein Trainingsmodul kann enthalten:
* einzelne Übungen,
* Kombinationsübungen,
* Notizen,
* methodische Hinweise,
* kurze wiederverwendbare Übungsfolgen,
* größere Blöcke innerhalb einer Einheit.
Trainingsmodule sollten deshalb fachlich unter **Planung / Bibliothek / Module** verortet werden.
### 2.5 Einfügen bedeutet Kopie mit Herkunft, nicht Live-Verknüpfung
Wenn ein Trainingsmodul oder eine Kombinationsübung in eine konkrete Trainingseinheit übernommen wird, entsteht eine bearbeitbare Planungsinstanz.
Grundsatz:
> Bibliothek = Vorlage.
> Planung = lokal bearbeitbare Übernahme.
> Durchführung = tatsächliche Nutzung im Training.
Spätere Änderungen an der Vorlage dürfen bereits geplante oder historische Einheiten nicht ungefragt verändern.
---
## 3. Zentrale Begriffe
| Begriff | Fachliche Bedeutung |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------ |
| **Trainingseinheit** | Konkretes geplantes oder durchgeführtes Training. |
| **Trainingsabschnitt** | Makrostruktur der Einheit, z. B. Aufwärmen oder Hauptteil. |
| **Planungsblock** | Zusammenhängender Inhalt innerhalb einer Einheit, z. B. Modul, Kombinationsübung oder manuell gruppierter Block. |
| **Kombinationsübung** | Sonderform einer Übung mit interner Struktur aus Slots, Stationen, Rollen oder Schritten. |
| **Trainingsmodul** | Wiederverwendbarer Planungsbaustein aus Übungen, Kombinationsübungen und Notizen. |
| **Trainingsmethode** | Fachlicher Katalogeintrag, der beschreibt, wie eine Trainingsform didaktisch oder sportmethodisch einzuordnen ist. |
| **Methoden-Archetyp** | Ablaufmuster für Planung und Coaching, z. B. rotierender Zirkel oder lineare Sequenz. |
| **Ablaufprofil** | Konkrete Ausprägung eines Archetyps, z. B. Arbeitszeit, Wechselzeit, Runden oder Erklärphase. |
| **Slot** | Platzhalter innerhalb einer Kombinationsübung, z. B. Station 1, Rolle A oder Schritt 2. |
| **Slot-Pool** | Menge möglicher Übungen für einen Slot, aus denen bei der Planung eine konkrete Auswahl getroffen werden kann. |
---
## 4. Trainingsmethoden: Ablage und Beschreibung
### 4.1 Rolle des Methodenkatalogs
Trainingsmethoden sollen als eigenständige fachliche Katalogobjekte geführt werden.
Sie beschreiben nicht eine konkrete Übung, sondern die methodische Qualität einer Trainingsform.
Beispiele:
* Zirkeltraining,
* Rollenspiel,
* strukturierte Übung,
* Koordinationstraining,
* plyometrisches Training,
* Dauermethode,
* extensive Intervallmethode,
* Partnerübung,
* freie Anwendung,
* Reflexionsformat.
Der Methodenkatalog dient:
* der Übungsbeschreibung,
* der Suche und Filterung,
* der Trainingsplanung,
* der fachlichen Standardisierung im Verein,
* der späteren KI- oder Assistenzunterstützung,
* der Qualitätssicherung bei offiziellen Inhalten.
### 4.2 Abgrenzung: Methode, Archetyp, Ablaufprofil
Die drei Begriffe müssen getrennt bleiben.
| Ebene | Frage | Beispiel |
| --------------------- | ---------------------------------------------- | --------------------------------------------------------- |
| **Trainingsmethode** | Welche fachliche Methode wird verwendet? | Zirkeltraining, Rollenspiel, Intervalltraining. |
| **Methoden-Archetyp** | Nach welchem Ablaufmuster wird gesteuert? | rotierender Zirkel, parallele Stationen, lineare Sequenz. |
| **Ablaufprofil** | Wie ist die konkrete Durchführung eingestellt? | 45 Sekunden Arbeit, 15 Sekunden Wechsel, 3 Runden. |
Wichtig:
> Der Methoden-Archetyp ersetzt nicht die Trainingsmethode. Er ergänzt sie nur dort, wo der Ablauf für Planung oder Coaching maschinenlesbar interpretiert werden muss.
### 4.3 Fachliche Beschreibung einer Trainingsmethode
Eine Trainingsmethode sollte aus Trainersicht so beschrieben werden, dass sie zuverlässig angewendet, gesucht und von anderen Methoden unterschieden werden kann.
Empfohlene fachliche Beschreibungsfelder:
| Feld | Zweck |
| ---------------------------------- | -------------------------------------------------------------------------- |
| **Name** | Eindeutige Bezeichnung der Methode. |
| **Kurzbeschreibung** | Schnelle Orientierung in Listen und Auswahlfeldern. |
| **Langbeschreibung** | Fachliche Erklärung der Methode. |
| **Ziel / Nutzen** | Wofür diese Methode besonders geeignet ist. |
| **Typische Einsatzsituationen** | Wann die Methode sinnvoll eingesetzt wird. |
| **Geeignete Zielgruppen** | Altersgruppen, Leistungsgruppen oder Trainingskontexte. |
| **Organisationsform** | Einzelarbeit, Partnerarbeit, Gruppe, Stationen, Kreis, freie Fläche usw. |
| **Belastungscharakter** | locker, technisch, koordinativ, intensiv, intervallartig, spielerisch usw. |
| **Typische Dauer** | Orientierung für Planung und Zeitmanagement. |
| **Benötigte Rahmenbedingungen** | Platz, Material, Gruppengröße, Sicherheitsabstände. |
| **Trainerhinweise** | Wichtige Hinweise für Anleitung und Steuerung. |
| **Risiken / typische Fehler** | Was bei falscher Anwendung problematisch sein kann. |
| **Geeignete Fähigkeiten** | Fähigkeiten, die mit der Methode häufig adressiert werden. |
| **Verwandte Methoden** | Ähnliche oder kombinierbare Methoden. |
| **Abgrenzung zu anderen Methoden** | Wann eine andere Methode passender wäre. |
| **Optionale Standard-Archetypen** | Falls die Methode häufig mit bestimmten Ablaufmustern genutzt wird. |
| **Status und Sichtbarkeit** | Entwurf, freigegeben, offiziell, vereinsintern usw. |
Diese Felder sind fachliche Anforderungen. Die konkrete technische Ablage soll der Coding Agent anhand der bestehenden Methodendomäne planen.
### 4.4 Haupt- und Nebenmethoden
Eine Übung sollte fachlich mindestens eine Hauptmethode haben können.
Zusätzlich können Nebenmethoden sinnvoll sein, weil eine Übung aus mehreren methodischen Perspektiven beschrieben werden kann.
Beispiel:
* Hauptmethode: Zirkeltraining
* Nebenmethode: plyometrisches Training
* weitere Nebenmethode: Koordinationstraining
Produktentscheidung:
> Übungen und Kombinationsübungen sollen eine Hauptmethode und optional weitere Nebenmethoden unterstützen.
Perspektivisch kann zusätzlich unterschieden werden zwischen:
* sportmethodischer Methode,
* didaktischer Vermittlungsmethode,
* organisatorischer Durchführungsform.
Diese Unterscheidung sollte aber im MVP nicht übermodelliert werden.
### 4.5 Trainingsmethoden in Kombinationsübungen
Bei Kombinationsübungen ist der Methodenbezug besonders wichtig.
Eine Kombinationsübung sollte daher fachlich drei Dinge besitzen:
1. **Methode**
Beispiel: Zirkeltraining.
2. **Archetyp**
Beispiel: rotierender Zeit-Zirkel.
3. **Ablaufprofil**
Beispiel: 6 Stationen, 45 Sekunden Arbeit, 15 Sekunden Wechsel, 3 Runden.
So kann dieselbe Methode unterschiedlich angewendet werden:
| Methode | Archetyp | Beispiel |
| ------------------- | ------------------- | --------------------------------------------------- |
| Zirkeltraining | rotierender Zirkel | Alle Gruppen wechseln gemeinsam weiter. |
| Zirkeltraining | parallele Stationen | Stationen laufen parallel, kein gemeinsamer Umlauf. |
| Intervalltraining | Intervallblock | Gemeinsame Zeitdomäne ohne Stationen. |
| Strukturierte Übung | lineare Sequenz | Schritt 1, Schritt 2, Schritt 3. |
### 4.6 Methoden in Trainingsmodulen
Trainingsmodule können ebenfalls einen Methodenbezug besitzen, aber anders als Übungen.
Ein Modul kann:
* eine dominante Methode haben,
* mehrere Methoden enthalten,
* methodisch neutral sein,
* nur aus einzelnen Übungen bestehen, die selbst Methoden besitzen.
Empfehlung:
> Ein Trainingsmodul darf optional eine primäre methodische Ausrichtung besitzen, sollte aber nicht zwingend eine Methode erzwingen.
Beispiel:
* Modul: „Aktivierung und Reaktion“
* Primäre methodische Ausrichtung: Koordinationstraining
* Enthaltene Übungen: Reaktionsspiel, Sprintsignal, Partneraufgabe
### 4.7 Methoden in der Suche und Planung
Der Methodenkatalog soll in der Nutzung sichtbar werden.
Benötigte Such- und Planungsfunktionen:
* Übungen nach Methode filtern,
* Kombinationsübungen nach Methode und Archetyp filtern,
* Trainingsmodule nach methodischer Ausrichtung filtern,
* in der Planung passende Methoden für ein Trainingsziel finden,
* Methoden als Qualitätsmerkmal offizieller Vereinsinhalte nutzen,
* bei der Auswahl einer Kombinationsübung passende Ablaufmuster vorschlagen.
Beispiel aus Trainersicht:
> „Ich suche eine Übung für Kumite, Jugendliche, Schwerpunkt Beinarbeit, Methode Zirkeltraining oder Koordinationstraining, Dauer maximal 15 Minuten.“
### 4.8 Governance des Methodenkatalogs
Trainingsmethoden sind fachliche Standardobjekte. Daher sollten sie stärker kontrolliert werden als private Trainingsnotizen.
Empfehlung:
* offizielle Methoden werden durch Administratoren oder Inhaltsverantwortliche gepflegt,
* Vereine können eigene Ergänzungen oder Spezialisierungen anlegen,
* Trainer können Vorschläge oder private methodische Hinweise erfassen,
* Änderungen an offiziellen Methoden sollten nicht ungeprüft globale Inhalte verändern.
---
## 5. Methoden-Archetypen für Kombinationsübungen
### 5.1 Zweck
Archetypen beschreiben wiederkehrende Ablaufmuster, die für Planung und Coaching relevant sind.
Sie beantworten nicht die Frage „Welche Methode ist das?“, sondern:
> Wie soll dieser Block im Training durchlaufen oder angezeigt werden?
### 5.2 Empfohlene Start-Archetypen
| Archetyp | Fachliche Bedeutung | Coaching-Idee |
| ------------------------ | -------------------------------------------------------------------- | -------------------------------------------------- |
| **Lineare Sequenz** | Übungen bauen nacheinander aufeinander auf. | Schrittfolge mit optionalem Timer. |
| **Rotierender Zirkel** | Mehrere Stationen, Gruppen wechseln nach Zeit weiter. | Gemeinsamer Timer, Wechselhinweis, Rundenzähler. |
| **Parallele Stationen** | Mehrere Stationen laufen gleichzeitig, aber ohne zwingende Rotation. | Vorher erklären, dann paralleler Betrieb. |
| **Parcours** | Stationen oder Aufgaben entlang eines Wegs oder Ablaufs. | Navigation, Abhaken, flexible Reihenfolge möglich. |
| **Partner-/Paarwechsel** | Rollen oder Aufgaben wechseln gekoppelt. | A/B-Logik, Rollenhinweise, Wechselimpulse. |
| **Intervallblock** | Gemeinsame Zeitdomäne mit wiederholten Belastungsphasen. | Globale Uhr, Intervallanzeige. |
| **Freier Methodenblock** | Methodischer Zusammenhang ohne harte Steuerungslogik. | Kompakte Anzeige, manuelles Abhaken. |
### 5.3 Mindestanforderung an Archetypen
Für jeden Archetyp muss fachlich beschrieben sein:
* wann er verwendet wird,
* welche Informationen der Trainer bei der Planung benötigt,
* welche Informationen im Coaching-Modus angezeigt werden,
* welche Angaben verpflichtend sind,
* welche Angaben optional sind,
* wann ein anderer Archetyp besser geeignet wäre.
Die technische Validierung und konkrete Ablage dieser Angaben soll der Coding Agent planen.
### 5.4 Einordnung: Trainingsformen wie HIIT, Dauer, plyometrisch ↔ Archetypen
**Wichtige Trennung (bleibt fachlich zwingend):**
* **Trainingsmethode im Methodenkatalog** (z.B. HIIT, extensive Intervallmethode, Dauermethode, plyometrisches Training) beschreibt primär den **didaktisch/belastungsmethodischen Kontext („was für eine Trainingsqualität ist das?“)**.
* **`method_archetype`** beschreibt **das Ablaufmuster („wie soll der Trainer den Block strukturieren und im Coach geführt werden?“)** — insbesondere **Parallelität, Rotation, Sequenz, Zeitdomänen**.
Dieselbe Methode kann in der Praxis mit **mehreren** Archetypen sinnvoll kombiniert sein; das ist **kein Widerspruch**.
| Beispiel (Methoden-/Belastungsbegriff) | Typischer Archetyp (Orientierung), nicht Pflicht |
| -------------------------------------- | ---------------------------------------------- |
| **HIIT**, Tabata-ähnlich, Kurzintervalle oft mit hoher Intention | sehr oft **`time_domain_interval`** oder innerhalb eines Zirkels **`circuit_rotate_time`** mit kurzen Arbeitsphasen; Partnerformen zusätzlich **`pair_superset`**. |
| **Klassisches Intervalltraining** (längere Arbeit, definierte Erholung, N Wiederholungen) | überwiegend **`time_domain_interval`** oder **`circuit_rotate_time`**, wenn die „Intervallschicht“ an Stationen gebunden ist. |
| **Dauermethode** (überwiegend durchgehend ohne harte Arbeit-Erholung-Takte) | eher **`free_method_block`** oder **`sequence_linear`** mit optionalen Hinweiten; **weniger** `time_domain_interval`, sofern kein geregeltes Intervallschema gemeint ist. |
| **Plyometrisch**, Explosivblöcke, Technik-Schichtung | häufig **`sequence_linear`** (Progression vor Ort) oder **`circuit_rotate_time`** / **`time_domain_interval`**, wenn klar Zeitfenster oder Wiederholungsblöcke vorgegeben sind. |
| Rein **organisatorisches** Stationslaufen ohne gemeinsamen Intervalltakt | **`circuit_rotate_time`** oder **`station_parcours`**, **`circuit_all_parallel`**, je nach ob rotiert wird oder parallel aktiv ist. |
**Shinkan-Zielrichtung bleibt trainerzentriert:** Es geht **nicht** um individuelle Pulsonomie eines Sportlers, sondern darum, dass der **Trainer Belastungs- und Erholungsphasen, Durchläufe und ggf. Umlauf-/Parallellogik** vorgibt und der **Coach** diese Vorgaben **sichtbar und später steuerbar** macht
(siehe **§6.3** zu Phasen jenseits „nur Gesamtminuten auf dem Planungsitem“).
### 5.5 Erweiterbarkeit von Archetypen (aktuell zurückgestellt)
Die Idee einer **von Superadmins zur Laufzeit editierbare Archetyp-Registry**, die den Coaching-Modus **völlig frei parametrierbar** macht, wird **zurückgestellt**. Vorerst reicht die **festgelegte, versionierte Liste** kanonischer Archetyp-IDs (**§10.2.1**); **weitere Archetypen** können später **wie bisher durch Produkt-/Release entschieden** ergänzt werden (Code oder kuratierter Import), ohne freies „Beliebig-Neuanlegen“ ohne definierten Coach-Verhaltens-Anker.
---
## 6. Kombinationsübungen
### 6.1 Fachliche Beschreibung
Eine Kombinationsübung ist eine wiederverwendbare Übungsform mit interner Struktur.
Beispiele:
* Kumite-Zirkel mit fünf Stationen,
* Koordinationsparcours,
* Selbstschutz-Parcours,
* Partnerwechselübung,
* methodische Sequenz zur Distanzkontrolle,
* Reaktions- und Explosivitätsblock,
* Aufwärmparcours für Kinder.
### 6.2 Bestandteile
Eine Kombinationsübung sollte fachlich enthalten:
* allgemeine Übungsbeschreibung,
* Ziel,
* Durchführung,
* Trainerhinweise,
* Vorbereitung,
* Hilfsmittel,
* Zielgruppe,
* Fähigkeiten,
* Hauptmethode,
* optionale Nebenmethoden,
* Archetyp,
* Slots / Stationen / Rollen / Schritte,
* mögliche Übungen je Slot,
* strukturierte **Zeitphasen und Belastungs-/Erholungsvorgaben** innerhalb der Kombination (**`method_profile`**, Überblick §6.3; Details §10.5) — **zusätzlich** zu allenfalls geplanten **Gesamtminuten am Planungseintrag**,
* optionale klassische Hinweise zu Dauer, Runden oder Wechsel aus der Übung heraus,
* Hinweise für den Coaching-Modus.
### 6.3 Zeitschicht: Phasen innerhalb der Kombination (Bibliothek) und Anpassungen in der Planung
Ein **einzelnes** Feld „Geplante Minuten für diesen Eintrag in der Einheit“ kann die **innenliegende zeitliche Logik** einer Kombinationsübung **nicht** ersetzen. Für Trainersteuerung (und später für Coaching **Stufe C**) soll die Kombination in der Bibliotheksbeschreibung vorsehen können:
**A) Kombinationseinheit („global“ über die Slots)**
* **Arbeits-** und **Erholungszeiten** (Sekunden/Minuten) und **Anzahl der Durchläufe** oder **Intervalle**,
* ggf. **gemeinsamer Takt** für alle Teilnehmenden (z.B. rotierender Zirkel oder eine gemeinsame Intervalluhr),
* **Erklär- oder Aufbauzeit** vor dem eigentlichen Start,
* dort, wo der Archetyp passt: **Runden-/Umlaufzahl** oder vergleichbare Strukturen.
Alle diese Angaben sind **Anweisungen an den Trainer** und **CoachAssistenz**, **keine** individuelle Pulssonde oder ähnliche Personenmessung.
**B) Optional pro Slot oder Schritt**
* wenn fachlich sinnvoll: **von Station zu Station variierende Arbeitsphasen** oder MiniSequenzen innerhalb eines Slots — technisch z.B. als strukturierte Liste in `method_profile` mit Bezug zum `slot_index` (Ausarbeitung Coding Agent).
**Nach Einplanung in eine konkrete Trainingseinheit** muss diese Zeitschicht (oder ihr Abgleich mit der Einheitsposition) für den Trainer **bearbeitbar** bleiben, **ohne** die Bibliotheksvorlage still zu überschreiben (kopier-/instanzbasierte Anpassungen — siehe bereits §2.5 und §8.3).
**Umsetzung in der App (Stand 0.8.103):** Pro Übungszeile in einer Trainingseinheit kann optional ein **JSON-Snapshot** des Ablaufprofils gespeichert werden (`planning_method_profile` in der DB). **`null`** bedeutet: es wirkt das Ablaufprofil aus dem **Katalog** (`method_profile` der Übung). Ist ein Snapshot gesetzt, ersetzt er den Katalog **vollständig** für diese Platzierung (kein serverseitiges Zusammenführen). Bearbeitung in der Planungs-UI: aufklappbarer Block **„Ablaufprofil für diese Planung (Kombination)“** mit denselben geführten Feldern wie im Übungsformular.
**Coach:** soll die wirksamen Werte nach **Übernahme** und **Einheitsübersteuerungen** konsistent nachvollziehen (**§10.4**).
**Geplantes kanonisches Zeitmodell:** Globale Eckwerte (z.B. Anzahl der Durchläufe / Runden, optionale Gesamt-/Einführungszeit als Ziel oder Rechenhilfe) und **pro Platz (Slot)** die Dimensionen „Belastung“, „wie viele gleiche Übung hintereinander“, „kurze Pause dazwischen“, „Übergangszeit zur nächsten Übung/arbeitstation“ — dokumentiert für die technische Angleichung in **`.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md`** (Felder **`slot_profiles_v1`**, `timing_schema`). Archetypen können **Strukturen und typische Schnellwahlen** vorgeben (z.B. Zirkel: Relation Belastungszeit=Übergangszeit oder Erholungsanteil2/3der Belastung); der Archetyp **Freier Methodenblock** bildet den **MaximalPfad** ohne stärkere stille Annahmen. **Pyramidale/abhängige Pausen** (Pause abhängig von vorheriger Belastung) sind **nicht Teil des aktuellen Umsetzungspfads**, können später als eigener Untertyp ergänzt werden.
**Fortschritt pro Slot (Stand 0.8.109):** optional **`advance_mode`** je Eintrag in **`slot_profiles_v1`**: `timed` — Standard (`load_sec` = geplante Arbeitsdauer für Timer im Coach; fehlende Angabe entspricht `timed` ohne Sekundenfeld), **`rep`** — mengenorientiert (Zielzahl über **`consecutive_reps`**; keine verbindliche Arbeitsuhr), **`manual`** — coachgeführt (Fortschritt bewusst per Schritt später im Coach, optional Richtwert über **`consecutive_reps`**). Optional **`rep_series_count`**: Standard **1** (wird im Formular/API explizit geführt); Ausnahmen nur, wenn der **MethodenArchetyp** in `ARCHETYPE_DEFAULT_REP_SERIES_COUNT` eine andere Vorgabe definiert oder der Nutzer eine andere Zahl setzt. ≥2 ermöglicht Pause **zwischen Serien** (`intra_rep_rest_sec`). Bei nur **einer** Serie: kein **`intra_rep_rest_sec`** in UI und Payload; **`transition_after_sec`** = Wechsel zur nächsten Station.
### 6.4 Slot- und Pool-Logik
Slots können fest oder variabel sein.
Beispiel fest:
* Station 1 = Seilspringen
* Station 2 = Liegestütz
* Station 3 = Beinarbeit
Beispiel variabel:
* Station 1 = eine Übung aus Pool „Beinarbeit“
* Station 2 = eine Übung aus Pool „Reaktion“
* Station 3 = eine Übung aus Pool „Konter“
Die konkrete Auswahl kann bei der Planung angepasst werden, ohne die Bibliotheksvorlage zu ändern.
---
## 7. Trainingsmodule
### 7.1 Fachliche Beschreibung
Ein Trainingsmodul ist ein wiederverwendbarer Planungsbaustein.
Beispiele:
* Standard-Aufwärmen für Kinder,
* Mobilisation und Aktivierung,
* Kumite-Beinarbeit 20 Minuten,
* SV-Einstieg Wahrnehmung und Distanz,
* Abschlussritual mit Reflexion,
* prüfungsnaher Kihon-Block.
### 7.2 Bestandteile
Ein Trainingsmodul sollte fachlich enthalten:
* Titel,
* Kurzbeschreibung,
* Ziel,
* empfohlene Dauer,
* empfohlene Zielgruppe,
* optional empfohlener Einsatzbereich,
* optionale methodische Ausrichtung,
* enthaltene Übungen,
* enthaltene Kombinationsübungen,
* Notizen oder Trainerhinweise,
* Sichtbarkeit,
* Freigabestatus.
### 7.3 Keine harte Abschnittsbindung
Ein Modul kann für einen Abschnitt empfohlen sein, z. B. „Aufwärmen“, darf aber nicht technisch darauf beschränkt werden.
Ein Modul kann:
* in einen Abschnitt eingefügt werden,
* als eigener Block auf Einheitsebene eingefügt werden,
* zwischen Abschnitten eingefügt werden,
* in ein Rahmenprogramm übernommen werden.
---
## 8. Planungslogik
### 8.1 Planungsblöcke
Für die Produktlogik braucht Shinkan den Begriff des Planungsblocks.
Ein Planungsblock ist ein zusammengehöriger Inhalt in einer Trainingseinheit.
Planungsblöcke können sein:
* eingefügtes Trainingsmodul,
* eingefügte Kombinationsübung,
* manuell gruppierter Block,
* später ggf. weitere Blocktypen.
### 8.2 Verhältnis zu Abschnitten
Ein Planungsblock kann einem Abschnitt zugeordnet sein, muss aber nicht vollständig in einem Abschnitt aufgehen.
Produktregel:
> Abschnitte gliedern die Einheit. Planungsblöcke gliedern den konkreten Trainingsinhalt.
### 8.3 Lokale Anpassbarkeit
Nach dem Einfügen muss ein Planungsblock lokal angepasst werden können:
* Dauer ändern,
* **bei Kombinationsübungen:** Ablaufprofil **optional nur für diese Platzierung** überschreiben (aktuell: Snapshot parallel zum Katalog-`method_profile`, z.B. Arbeit-, Erholungs- und Runden-/Intervallangaben über die gleichen strukturierten Felder wie im Übungskatalog) — zusätzlich zu den **Gepl.-Min.** am Eintrag; **Stations-/Slot-Austausch** am konkreten Vorkommen weiter über die bestehende Übungs-/Planungslogik, nicht gesondert als „Kombi-Programmierung“ je Zeile,
* Übung austauschen,
* Station ergänzen,
* Hinweise anpassen,
* Reihenfolge ändern,
* Block auflösen,
* Block duplizieren,
* Block als neues Modul speichern.
Diese Änderungen betreffen nur die konkrete Einheit oder den konkreten Rahmen-Slot, nicht automatisch das Bibliotheksexemplar.
---
## 9. UX-Anforderungen
### 9.1 Inhalt hinzufügen
Im Planungseditor sollte der Trainer fachlich klar wählen können:
* Übung hinzufügen,
* Kombinationsübung hinzufügen,
* Trainingsmodul hinzufügen,
* Notiz hinzufügen,
* manuellen Block erstellen.
### 9.2 Modul erstellen
Ein Modul sollte auf mehreren Wegen entstehen können:
* leer anlegen,
* aus bestehendem Abschnitt speichern,
* aus markierten Übungen speichern,
* aus einem Teil eines alten Trainings speichern.
### 9.3 Kombinationsübung erstellen
Eine Kombinationsübung sollte geführt angelegt werden:
1. Grunddaten erfassen,
2. Methode wählen,
3. Archetyp wählen,
4. Slots / Stationen / Rollen definieren,
5. Übungen oder Pools zuordnen,
6. Ablaufprofil festlegen,
7. Coaching-Vorschau prüfen,
8. speichern.
### 9.4 Methoden auswählen
Die Methodenauswahl sollte Trainer unterstützen, nicht belasten.
Empfohlene UX:
* Hauptmethode prominent,
* Nebenmethoden optional,
* passende Methoden vorschlagen,
* Methoden kurz erklären,
* bei Kombinationsübungen passende Archetypen vorschlagen,
* keine Pflicht zur Überklassifizierung bei einfachen Übungen.
---
## 10. Coaching- und Assistenzmodus
### 10.1 Ziel
Der Coaching-Modus soll die Durchführung unterstützen, ohne den Trainer zu zwingen, exakt dem Plan zu folgen.
Grundsatz:
> Der Coaching-Modus gibt Orientierung, Zeitstruktur und Ablaufhilfe, bleibt aber in der Praxis flexibel.
### 10.2 Unterschiedliche Anzeige je Archetyp
| Archetyp | Coaching-Anzeige |
| -------------------- | -------------------------------------------- |
| Lineare Sequenz | Schrittfolge mit Weiter/Zurück. |
| Rotierender Zirkel | Stationen, Arbeitszeit, Wechselzeit, Runden. |
| Parallele Stationen | Erst Erklärübersicht, dann Parallelbetrieb. |
| Parcours | Stationen oder Wegpunkte zum Abhaken. |
| Partner-/Paarwechsel | Rollen, Aufgaben und Wechselhinweise. |
| Intervallblock | Globale Zeit, Intervallzähler, Aufgaben. |
| Freier Methodenblock | Kompakte Übersicht und manuelle Steuerung. |
#### 10.2.1 Kanonische Archetyp-IDs (Abgleich Fachbegriff, UI und API)
Damit Produktbeschreibung, Formularfelder (`method_archetype`), Trainingscoach und BackendValidierung **dieselben Werte** nutzen und es keinen dokumentationsbedingten Drift gibt, gelten diese **festen Schlüssel** (MaschinenIDs):
| Archetyp (fachlicher Name in §5.2) | Schlüssel `method_archetype` (`exercises.method_archetype`) |
| ----------------------------------- | ----------------------------------------------------------- |
| Lineare Sequenz | `sequence_linear` |
| Rotierender Zirkel (Zeit) | `circuit_rotate_time` |
| Parallele Stationen | `circuit_all_parallel` |
| Parcours | `station_parcour` |
| Partner- / Paarwechsel | `pair_superset` |
| Intervallblock (Zeitdomäne) | `time_domain_interval` |
| Freier Methodenblock | `free_method_block` |
Änderungen an dieser Zuordnung nur **gemeinsam** (Produkt, BackendEnum und UIKonstanten); siehe Implementierungsanhang weiter unten.
### 10.3 Durchführungsdokumentation
Perspektivisch sollte dokumentierbar sein:
* was durchgeführt wurde,
* was übersprungen wurde,
* was verändert wurde,
* tatsächliche Dauer,
* Trainerhinweise,
* Reflexion,
* Vorschläge zur Verbesserung einer Übung oder eines Moduls.
Die konkrete technische Umsetzung wird nicht in dieser Spezifikation festgelegt.
### 10.4 Coaching-Reifegrade (Normierung ohne technisches Pflichtenheft)
Archetyp-spezifisches Coaching soll **nicht** als ein einziges UX-„Monolith“ gebaut werden, sondern in **nachvollziehbaren Stufen**, damit frühere Umsetzungen nicht überschrieben wirken und der Fortschritt in Doku/Umsetzungsplan nachverfolgt werden kann:
| Stufe | Bezeichnung (Arbeitstitel) | Inhalt aus Trainersicht | Abgrenzung |
| ----- | ---------------------------- | ------------------------ | ----------- |
| **A** | **Informations-/Struktursicht** | Pro Kombinationsübung: KopfKontext aus Katalog **plus** strukturierte Darstellung der **Slots** und der **einzelnen Kandidatenübungen** (Titel, Kurztext, Detail aufklappbar); **ein zeitlicher Schritt im Coach** entspricht weiter **einem** Planungseintrag (ein Item in der Einheit). | Kein eigener Rundenzähler, kein eigener StationsTimerState pro Archetyp. |
| **B** | **Archetyp-Steuerung in der bestehenden Zeitleiste** | Optionale Aufspaltung: z.B. bei **`sequence_linear`** pro Slot **ein CoachSchritt** (Weiter/Zurück pro Station), ohne die Datenbank-Semantik der Einheit zu zerstückeln (Virtuelle Schritte oder materialisierte HilfsEinträge technische Variante dokumentieren). | Bewusste Produkt-/Architekturentscheidung nötig, damit ISTZeiten und AbschlussPUT konsistent bleiben. |
| **C** | **Interaktive Assistenz je Archetyp** | Gemeinschafts-/StationsTimer, Wechselimpulse (**`circuit_rotate_time`**), Vorab„Erklärphase“Flag (**`circuit_all_parallel`**), Abhaken (**`station_parcour`**), gekoppelte A/BAnsicht (**`pair_superset`**), globale Intervalluhr (**`time_domain_interval`**) — jeweils an Parameter aus **`method_profile`** angebunden, wo diese in Stufe A/B bereits sichtbar gepflegt werden. | Keine verpflichtende KISteuerung; Trainer kann überspringen (Grundsatz §10.1). |
**Aktuelle Zielrichtung:** Stufe **A** soll für **alle** in §10.2.1 genannten Archetypen **inhaltsgleich** die Slot und Kandidateninformation liefern; **unterschiedliche Kopf-/Hilfstexte und UI-Mikrolayouts** nach Archetyp sind Teil von A und sollten gemeinsam mit Stufe B/C wachsen (kein „still“ abweichendes Verhalten ohne DokuUpdate).
### 10.5 Fachliche Mindestinfos im **Ablaufprofil** (`method_profile`) pro Archetyp
`method_profile` ist das **konkretisierende** JSON (o. ä.) zum gewählten Archetyp: Zeiten, Runden, Schalter. Technische Pflichtfelder und Validierung regelt die technische Umsetzung — **fachlich** gilt folgende Minimal-Erwartung, damit Stufe B/C sinnvoll nutzbar ist:
| Archetyp-Schlüssel | Mindest-Parameter (fachlich sinnvoll; Benennung in der Umsetzung kanonisch festlegen). Typische Zuordnung methodischer Überbegriffe: **§5.4** |
| ------------------ | ------------------------------------------------------------------------------------- |
| `sequence_linear` | Orientierungs-Arbeits-/Pausenhinweise je Schritt oder global; Reihenfolge = Slotreihenfolge — u.a. für **Skillschichtungen**, Aufwärmserien ohne festen Rotationstakt oder **Ausdauer-/Technikketten ohne Intervalltakt**. |
| `circuit_rotate_time` | **Arbeit** je Station oder Umlauf, **optional Wechsel/Transition**, **optional Erholung zwischen Runden**, **optional Rundenanzahl**; Kern für rotierenden Zirkel inkl. vieler HIIT-/Zirkelmischformen über Stationen hinweg (**§5.4**). |
| `circuit_all_parallel` | „Erst gemeinsame Erklärung, dann gleichzeitiger Betrieb aller Stationen“; Zeitfenster VorabErklärung optional — z.B. wenn keine Rotation, aber gemeinsamer Startzeitpunkt gewünscht ist. |
| `station_parcour` | Fokus Stationsbeschreibung; optional freie Besuchsreihenfolge (Profil/Archetyp); weniger zentral **feste Arbeit/Erholung-Takte**, mehr Navigation/Abhaken (später Stufe C). |
| `pair_superset` | Arbeit und Wechsel bei **gekoppelten** Rollen; typisch wenn zwei Linien oder Partnerblöcke im Takt gewechselt werden. |
| `time_domain_interval` | Klare Zeitdomäne: **Belastungs-, Erholungsblöcke** und **Anzahl Wiederholungen** der Domäne bzw. **Gesamtblockbegrenzung** — zentrale Schicht für viele Formen aus **„Intervall/HIIT/Zeitschachtelungs“**Methodenkatalog ohne individuelle Messung (**§5.4**). |
| `free_method_block` | Keine zusätzlichen Pflichtparameter; **unterstützt** etwa **reibungsarmere Dauer- oder Spielformen**, wo der Trainer keine starke Taktuhr braucht, aber Stationsideen strukturiert bündeln will. |
#### 10.5.1 Mehrschichtiges Planen (Überblick)
| Ebene | Inhalt zeitlicher Art |
| ----- | --------------------- |
| **Einheit / Planungsitem** | z.B. geplante **Gesamtminuten** dieser Platzierung („der Block soll heute etwa 25Min einnehmen“). |
| **Kombinationsübung (Bibliothek)** | strukturierte **Phasen in `method_profile`** (arbeiten, pausieren, Runden…) — §6.3. |
| **Einheitliche Planungsinstanz** | optionale Abweiche vom Bibliotheksprofil **nur für dieses Training**8.3). |
| **Coach** | liest wirksamen Stand (Bibliothek + Overrides) zur **Orientierung**, später automatisierte Taktassistenz (**§10.4**). |
Solange diese Mindestinfos in der Datenpflege noch **nicht** validiert oder nicht geführt erfasst werden, bleibt Coaching bei **Informations-Schicht und manuellen Timern des bestehenden Coach-Dialogs** die fachlich ehrliche Darstellung (siehe Anhang A).
---
## 11. Rahmenprogramm-Integration
Trainingsmodule und Kombinationsübungen müssen auch in Rahmenprogrammen nutzbar sein.
Regel:
> Was in einer konkreten Trainingseinheit geplant werden kann, sollte grundsätzlich auch in einem Rahmenprogramm oder Rahmen-Slot vorbereitet werden können, sofern es keine echte Durchführung voraussetzt.
Das betrifft insbesondere:
* Modul einfügen,
* Kombinationsübung einfügen,
* methodische Ausrichtung übernehmen,
* Slot-Pools vorbelegen,
* Dauer anpassen,
* später konkrete Einheit daraus ableiten.
Nicht in den Rahmen gehört:
* echte Durchführung,
* tatsächliche Dauer,
* spontane Trainingsnotizen,
* Nachbereitungsreflexion.
---
## 12. Governance
Für Methoden, Übungen, Kombinationsübungen und Module gelten abgestufte Sichtbarkeiten und Verantwortlichkeiten.
Empfohlene fachliche Ebenen:
* privat,
* Verein,
* offiziell,
* archiviert,
* Entwurf,
* freigegeben.
Normale Trainer sollen Inhalte nutzen und lokal anpassen können. Offizielle oder vereinsweite Vorlagen sollen nicht ungeprüft überschrieben werden.
Für Methoden ist eine besondere Qualitätskontrolle sinnvoll, weil sie als fachlicher Katalog für viele Übungen und Planungen wirken.
---
## 13. MVP-Empfehlung
### 13.1 Muss enthalten sein
* Trainingsmodule anlegen und wiederverwenden,
* Kombinationsübungen als fachliche Sonderform von Übungen,
* Methodenbezug mit Hauptmethode und optionalen Nebenmethoden,
* klare Trennung zwischen Methode, Archetyp und Ablaufprofil,
* mindestens folgende Archetypen:
* lineare Sequenz,
* rotierender Zirkel,
* freier Methodenblock,
* Planungsblöcke als fachliches Konzept,
* lokale Anpassbarkeit nach Einfügen,
* Coaching: mindestens **Stufe A** nach §10.4 für alle Archetypen aus §10.2.1 (strukturierte Slot-/Kombi-Darstellung; Archetyp-Hilfstexte); **zeitliche/mechanische Archetyp-Steuerung (Stufen B/C)** ausdrücklich als Ausbauschritte.
### 13.2 Sollte vorbereitet werden
* parallele Stationen,
* Parcours,
* Partner-/Paarwechsel,
* Intervallblock,
* Durchführungsdokumentation,
* Rückfluss von Erfahrungswissen,
* Offline-/PWA-Nutzung,
* stärkere Suche nach Methoden und Archetypen.
### 13.3 Nicht im MVP
* vollständige technische Event-Historie jeder Planänderung,
* automatische Synchronisation alter Einheiten bei Vorlagenänderung,
* komplexe Verschachtelung von Modulen in Modulen,
* individuelles Athleten-Tracking,
* KI-generierte Trainingsplanung,
* verbindliche technische Tabellen- oder API-Architektur.
---
## 14. Arbeitsauftrag an den Coding Agent — fachliche Leitplanken
Der Coding Agent soll die bestehende Codebasis prüfen und auf dieser Grundlage eine technische Umsetzungsplanung erstellen.
Dabei soll er ausdrücklich:
1. bestehende Strukturen wiederverwenden, soweit sinnvoll,
2. keine unnötigen Refactorings auslösen,
3. bestehende Trainingsplanung nicht destabilisieren,
4. Migrationen schrittweise und rückwärtskompatibel planen,
5. vorhandene Methodendomäne berücksichtigen,
6. die Trennung zwischen Trainingsmethode, Archetyp und Ablaufprofil fachlich erhalten,
7. technische Alternativen mit Vor- und Nachteilen darstellen,
8. erst danach konkrete Tabellen, APIs und UI-Komponenten vorschlagen.
Die Spezifikation ist daher kein technisches Pflichtenheft, sondern ein fachlicher Rahmen.
---
## 15. Zusammenfassung der verbindlichen Produktlogik
1. Trainingsabschnitte sind die Makrostruktur der Einheit.
2. Kombinationsübungen sind keine Abschnitte.
3. Kombinationsübungen sind Sonderformen von Übungen.
4. Trainingsmodule sind Planungsbausteine.
5. Trainingsmethoden sind eigenständige fachliche Katalogobjekte.
6. Eine Übung hat eine Hauptmethode und optional Nebenmethoden.
7. Methoden-Archetypen beschreiben Ablaufmuster, nicht die Methode selbst.
8. Ablaufprofile konkretisieren den Archetyp für Planung und Coaching (siehe §10.5).
9. Einfügen aus Bibliotheken erzeugt lokal bearbeitbare Planungsinhalte.
10. Vorlagenänderungen verändern historische oder konkrete Planungen nicht automatisch.
11. Rahmenprogramme sollen dieselbe Planungslogik nutzen wie konkrete Einheiten.
12. Der Coding Agent entscheidet die technische Umsetzung anhand der bestehenden Codebasis.
13. Archetyp-IDs und Coaching-Stufen (§10.2.1, §10.4) sind die **Referenz gegen Code-Drift**; Änderungen nur mit Anhang A und technischer Doku.
14. **Zeitliche Phasen** einer Kombination liegen vorrangig in **`method_profile`** und **Gesamtzeit am Planungseintrag**; **Übersteuerungen nur in der Planungsinstanz**, nicht still in der Bibliothek (§6.3, §8.3, §10.5.1).
---
## Anhang A — Implementierungsabgleich (Stand Code: App **0.8.104**, grob)
Zweck: dieselbe Tabelle für **Produkt / Architekt / Agent** beim nächsten Schritt; verhindert „wir haben X gebaut, die Spec sagt aber Y“ ohne dass es dokumentiert wird.
| Thema (fachliche Headline aus dieser Spez) | Kurz beschrieben | Stand Code / UX (Referenz nur) | Lücke / nächste sinnvolle Schritte |
|--------------------------------------------|-----------------|---------------------------------|-------------------------------------|
| **Trainingsmodule (Bibliothek)** | Wiederverwendbare Blöcke, Kopier-Einfügen in Einheit | Bibliothek, API, Übernahme-Modal, Lineage-Spalte | **Phase 3** des Umsetzungsplans: erweiterter Übernahmemodus |
| **Kombinationsübung im Katalog** | `exercise_kind=combination`, Slots, Pools (Kandidaten) | Migration 056, CRUD Übung mit `combination_slots`, GET liefert Slots + Kandidatentitel | Fachbezug Haupt-/Nebenmethoden aus §4/§6 dort umsetzen, wo die Domäne es noch nicht abdeckt |
| **Archetyp + Ablaufprofil am Katalogobjekt** | `method_archetype`, JSON `method_profile` (+ Pilot **`slot_profiles_v1`** je Station in derselben JSONStruktur) | Persistenz; Übungsformular: **geführte globale Felder** + **pro Slot** vier Zeitreihen ohne NutzerJSONPflicht; Schnellwahl typische Arbeit/PauseRelationen (**Zirkel**, **Intervall**); Reihenfolge UX: Stationen vor Ablaufprofil | JSON„Experte“ weiter abschaltbar; SchemaPflichtfelder nach Archetyp; Konvergenz flache Schlüssel ↔ `timing_schema` (siehe Arbeitsplan) |
| **Einplanbarkeit (normale Planung)** | Kombi in Sektionen; **ZeitprofilOverrides** nach §8.3 / §10.5.1 | Picker, `exercise_kind` in Form/PUT, keine Variante bei Kombi; **Override:** DB **`planning_method_profile`** je Sektions-Item (Migration **057**), Planungseditor: Details „Ablaufprofil für diese Planung“, **„Planung wie Katalog“** / **„Aus Katalog kopieren“** | Planungsblöcke als Produktkonzept · Phase 3; serverseitige Validierung Snapshot↔Archetyp optional |
| **Zeitphasen (global / pro Slot)** | §6.3 | Über `method_profile` / PlanungsSnapshot (**gleiche JSON-Struktur** wie Katalogprofil): globale Schlüssel im Übungs- und Planungseditor; weiterhin **keine** eigenständigen slotgebundenen Zeitlisten im UI | `slot_timing[]` oder äquivalent definieren und editieren |
| **Coaching Stufe A** | Slots + Kandidaten sichtbar, ArchetypHinweis, Profil lesbar | `CombinationCoachSlots`: wirksames Profil = **PlanungsSnapshot wenn gesetzt, sonst Katalog**; Anzeige **Key/Value** | Profilwerte **lesend** benutzerfreundlicher labeln (statt nur Schlüsselnamen) |
| **Coaching Stufe B** | Zeitleiste archetypnah (z.B. Schritt pro Station) | **Nein** — ein CoachSchritt = ein Planungsitem | Designentscheid: virtuelle Substeps vs. DBMaterialisierung; Auswirkung auf IstZeit pro Item |
| **Coaching Stufe C** | Timer/Wechsel/Abhaken nach Archetyp | Nur **generischer** CoachTimer pro Planungsitem | Pro Archetyp UIState + Anbindung an `method_profile` |
| **Rahmenprogramm** | Gleiche Inhalte wie Einheit | SlotBlueprint, `from-framework-slot` | Modul-/KombiUX in Rahmen wie in Einheit konsolidieren (Phase 5) |
| **Coaching-Vorschau im Editor** | §9.3 Schritt 7 | **Nein** / nicht als eigener Modus | Optional: dieselbe `CombinationCoachSlots`Ansicht readonly im Übungseditor |
**Pflege:** Bei jeder relevanten Codeänderung diese Tabelle **in demselben PR / derselben Session** anpassen (kein stiller Drift).

View File

@ -1,7 +1,7 @@
# Gelieferte Features & technische Basis (Q2 2026)
**Stand:** 2026-05-08
**Referenz:** `backend/version.py`**APP_VERSION 0.8.64**, **DB_SCHEMA_VERSION** siehe dort
**Stand:** 2026-05-12
**Referenz:** `backend/version.py`aktuelle **APP_VERSION** / **DB_SCHEMA_VERSION** (Stand Code u. a. **0.8.96**)
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**. **Medien-Archiv & Bibliothek:** Abschnitt **12** unten + **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**. Detail-Spezifikationen bleiben in den verlinkten Pfaden unter `.claude/docs/technical/` und `.claude/docs/functional/`.
@ -170,4 +170,5 @@ Einzelnorm: **`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**. Kurzüberblick gel
| Datenbank Überblick | `technical/DATABASE_SCHEMA.md` |
| Medien Upload (Limits, MIME) | `technical/MEDIA_UPLOAD_SPEC.md` |
| Medien-Archiv & Lifecycle | `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
| Fachlicher Nutzerüberblick | `docs/FACHLICHE_NUTZERFUNKTIONEN.md` (Repo-Root) |
| Projektstatus-Kachel | `../PROJECT_STATUS.md` |

View File

@ -0,0 +1,203 @@
# Trainingsmodule und Kombinationsübungen — Spezifikation (Entwurf)
**Status:** Entwurf zur fachlichen und technischen Abstimmung · **Stand:** 2026-05-12
**Zweck:** Rahmen für Umsetzung, Integration in Planung/Rahmenprogramm und Durchführung im assistierten Training (Coaching-Modus). Dieses Dokument ist **nicht** implementierungsbindend, bis die markierten **offenen Entscheidungen** geschlossen und der Status angehoben wurde.
**Abgleich mit Code (Stand ~0.8.101, Drift vermeiden):**
- **Kanonische Archetyp-IDs:** fest in `backend/routers/exercises.py` (`COMBINATION_ARCHETYPE_IDS`); fachliche Tabelle und UI-Labels in `frontend/src/constants/combinationArchetypes.js` — die **fachliche Master-Zuordnung** Name↔ID steht in `functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` §10.2.1.
- **Coaching:** Stufe **A** (informations-/strukturierte Slot- und Kandidatenansicht + Archetyp-Hilfstext) umgesetzt im Trainings-Coach (`ExerciseFullContent` / `CombinationCoachSlots`); Stufen **B/C** bewusst offen — siehe Fachspez §10.4 und **Anhang A** dort.
- **Umsetzungsplan:** `working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phasen 2/4 mit „teilweise“).
**Verwandte Dokumente:**
| Dokument | Bezug |
|----------|--------|
| `TRAINING_FRAMEWORK_SPEC.md` | Rahmen-Bibliothek, Slot-Blueprint, Kopiersemantik (`from-framework-slot`) |
| `DATABASE_SCHEMA.md` | Aktueller Stand `training_units`, Sektionen, Items |
| `functional/DOMAIN_MODEL.md` | Domänenbegriffe (bei Bedarf zu erweitern) |
| `EXERCISES_*` (Katalog) | Einzelübungen, Varianten |
| `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` | Sichtbarkeit, Mandant, Rollen bei neuen Bibliotheks-Entitäten |
---
## 1. Zielbild und Abgrenzung
### 1.1 Problem
Die Trainingsplanung unterstützt Einheiten mit Sektionen und einzelnen Übungen (inkl. Notizen) sowie Rahmenprogramme mit Blueprint-Einheiten pro Slot. Es fehlen:
- **Wiederverwendbare Übungsfolgen** („Trainingsmodule“), die sich wie Bausteine in eine Einheit einfügen lassen (ganze Sektion oder Block innerhalb einer Sektion), inkl. kopierbasierter Integration analog zum Rahmen.
- **Strukturierte Kombinationsformen** (z. B. Zirkel mit Stationstausch, Parcour), bei denen **mehrere Einzelübungen** Slots oder Rollen einnehmen und die **Trainingsmethode** den Ablauf (Rotation, parallele Stationen, Zeitmodell) bestimmt.
- Ein durchgängiges Konzept für den **Coaching- bzw. Assistenzmodus**, in dem derselbe Plan je nach Archetyp **unterschiedlich gesteuert** wird (Beispiel Zirkel: Erklärphase vs. parallele Nutzung aller Stationen).
### 1.2 Nicht-Ziele (für erste Ausbaustufe)
- **Individuelles Athleten-Tracking** oder Leistungsmessung pro Person (außerhalb Shinkan-MVP, siehe Produktabgrenzung).
- Automatische **Synchronisation** zwischen Bibliotheksexemplar und bereits geplanten historischen Einheiten (bewusst: **Kopie** statt Live-Spiegel, konsistent mit Rahmen-Konzept).
### 1.3 Zwei Bausteine (fachliche Trennung)
| Baustein | Kurzname | Einordnung | Kurzbeschreibung |
|----------|-----------|------------|------------------|
| **Typ 1** | **Kombinationsübung** | Übungskatalog (Sonderform einer **Übung**) | Eine logische Übung mit **1n Slots**; Slots können einzelne Übungen oder **Pools** auswählbarer Übungen tragen; **Methodenprofil / Archetyp** steuert später den Durchlauf. |
| **Typ 2** | **Trainingsmodul** | Planung / Bibliothek | Gespeicherte, wiederverwendbare **Sequenz** von Elementen (Einzelübungen, optional Kombinationsübungen, Notizen); Einbindung per **Kopie** in konkrete `training_units` oder in Rahmen-Slot-Blueprints. |
**Abgrenzung Rahmenprogramm:** Das Rahmenprogramm strukturiert **mehrere Einheiten** (Slots) auf Programm-Ebene. Ein Trainingsmodul strukturiert typischerweise **Inhalt einer Einheit** oder eines Teils davon, nicht den Wochen-/Periodenrahmen.
---
## 2. Begriffe
| Begriff | Definition |
|---------|------------|
| **Bibliotheksexemplar** | Gespeicherte Vorlage (Modul oder Kombinationsübung-Definition) mit Governance (z. B. global, Verein, privat). |
| **Instanz in der Planung** | In `training_unit_section_items` (und ggf. ergänzende Tabellen) materialisierter Ablauf für einen **konkreten Termin** bzw. eine **geplante Einheit**. |
| **Slot (Typ 1)** | Position innerhalb einer Kombinationsübung; kann genau eine gewählte Übung oder einen **Pool** (mehrere Kandidaten) referenzieren. |
| **Methodenprofil / Archetyp** | Maschinenlesbare Semantik **wie** trainiert wird (Zeit, Rotation, Parallelität), ergänzend zum bestehenden Katalog `training_methods` (Beschreibung **was** für eine Didaktik/Kondition gilt). |
| **Coaching-Modus** | UI- und Zustandslogik zur Durchführung einer geplanten Einheit (Timer, Phasen, Stationen). |
---
## 3. Trainingsmethoden und Archetypen (Typ 1)
### 3.1 Bestehende Basis
Der Katalog `training_methods` (Migration 003) enthält u. a. **Zirkeltraining** (`category` u. a. `zirkel`, `kondition`). Er beschreibt die Methode **inhaltlich**, nicht aber Parameter wie Wechselintervalle oder parallele vs. rotierende Nutzung.
### 3.2 Erweiterung: Archetyp
Jede Kombinationsübung (und optional der Methodendatensatz als Default) erhält ein Feld **`method_archetype`** (Enum/Wertliste). Der Archetyp legt fest, welche **Parameter** am Methodenprofil relevant sind und wie der **Coaching-Modus** den Ablauf interpretiert.
**Vorschlagsliste (erweiterbar, zu verbindlich machen):**
| Archetyp-ID (Vorschlag) | Beschreibung Planungslogik | Coaching (Intent) |
|-------------------------|----------------------------|---------------------|
| `circuit_rotate_time` | n Stationen; Wechsel nach Ablaufzeit, optional globale Rundenanzahl | Rotierender oder gemeinsamer Takt; Umlauf zur nächsten Station |
| `circuit_all_parallel` | n Stationen; **kein** Umlauf als fachlicher Kern, alle Stationen gleichzeitig aktiv | Erklärphase (alle Inhalte vorher), dann **parallel** alle Stationen |
| `sequence_linear` | feste Reihenfolge; Aufbau, keine Kreisrotation | Schrittliste / Timer optional pro Schritt |
| `station_parcour` | Stationsbezogener Pfad, Reihenfolge kann variieren | Navigation / Abhaken eher als ein globaler Umlauf-Takt |
| `pair_superset` | zwei (oder wenige) Blöcke im Wechsel | Partnerlogik, gekoppelte Timer |
| `time_domain_interval` | AMRAP/EMOM-ähnliche Zeitdomäne | Globale Uhr, Runden-/Intervallzähler |
### 3.3 Parameter des Methodenprofils
Zu präzisieren (JSON-Dokument vs. normalisierte Spalten):
- Zeit: `work_seconds`, `rest_seconds`, `transition_seconds`, `rounds`
- Organisation: `station_count`, `rotation_direction`, Flags `explain_all_before_start`, `stations_operate_simultaneously`
- ggf. `intensity_profile` (skalar oder Enum), nur wenn für MVP nötig
**Offen:** Welche Parameter sind **Pflicht pro Archetyp** (Validierung).
---
## 4. Datenmodell (Zielarchitektur, Entwurf)
### 4.1 Typ 2 — Trainingsmodule
**Entwurfstabellen (Namen können bei Implementierung angeglichen werden):**
- `training_modules` — Kopf: Titel, Beschreibung, Metadaten, `visibility`, `club_id`, `created_by`, Timestamps
- optional `training_module_sections` — falls ein Modul mehrere semantische Blöcke abbilden soll
- `training_module_items` — Reihenfolge, Verweis auf:
- Einzelübung (`exercise_id`, `exercise_variant_id`)
- Kombinationsübung (`combination_exercise_id` / `exercise_id` mit `kind=combination`)
- Freitext-Notiz (analog `note` bei Einheiten)
Semantik: **Bibliotheksbaum**, keine Bindung an Kalender oder Gruppe.
### 4.2 Typ 1 — Kombinationsübungen
**Option A (Embedding in `exercises`):** Spalte `exercise_kind` = `simple` | `combination` und Kindtabellen für Slots/Pools.
**Option B (Separate Kopf-Tabelle):** 1:1-Beziehung zwischen `exercises` und `combination_exercises`.
**Slot-Pools:** mindestens M:N **Pool-Kandidat** pro Slot; die **konkret geplante Auswahl** gehört zur **Instanz** (geplante Einheit), nicht zwingend zum Bibliotheksexemplar.
### 4.3 Integration in geplante Einheiten
Heute: `training_unit_section_items` mit `item_type` in (`exercise`, `note`).
**Erweiterungsoptionen (Entscheidung offen):**
1. **Expansion beim Einfügen:** Modul wird in Items „aufgeklappt“; optional `source_module_id` an Items für Herkunft (Lineage-Light).
2. **Block-Item:** neuer `item_type` `module_reference` oder `combination` mit ID und eingebetteter Bearbeitungssemantik (komplexer, aber „Modul als Einheit“ editierbar).
Empfehlung zur Abstimmung: MVP oft mit **Expansion** + optionaler Markierung; später Block-Knoten.
**Rahmenprogramm:** Blueprint-`training_units` pro Slot nutzen dieselbe Sektions-/Item-Struktur — Module müssen **dort** ebenfalls einfügbar sein, wenn Rahmen und konkrete Planung konsistent bleiben sollen.
---
## 5. API (Skizze)
Verbindliche Pfade und Payloads folgen nach Freigabe dieses Dokuments.
| Richtung | Beispielpfad / Funktion | Zweck |
|----------|-------------------------|--------|
| CRUD | `GET/POST/PUT/DELETE …/training-modules` | Bibliothek Trainingsmodule |
| Anwendung | `POST …/training-units/{id}/apply-module` | Modulinhalt in Sektion kopieren (tiefe Kopie) |
| Übungen | Erweiterung `GET/POST/PUT …/exercises` oder Unterressource `…/exercises/{id}/combination` | Kombinationsübung inkl. Slots |
| optional | `POST …/training-units/from-module` | Neue Einheit aus Modul (falls produktrelevant) |
**AuthZ:** analog andere Bibliotheks- und Planungsobjekte; Abgleich mit `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` und Endpoint-Audit.
---
## 6. Frontend
- **Bibliothek:** Verwaltung Trainingsmodule (Liste, Editor, Sortierung, Vorschau).
- **Übungsbereich:** Editor für Kombinationsübungen (Slots, Pools, Methodenprofil/Archetyp).
- **Planungs-UI:** Aktion „Modul einfügen“, Ziel-Sektion und Position; Hinweis **Kopie** und Editierbarkeit pro Termin.
---
## 7. Coaching- / Assistenzmodus (Durchlauf)
### 7.1 Phasenmodell (konzeptionell)
- **Briefing / Erklärung:** insbesondere für `circuit_all_parallel` und Varianten mit `explain_all_before_start`
- **Arbeitsphase(n):** timer- und stationsgetrieben
- **Übergänge:** Pausen, Wechsel, Rundenzähler
### 7.2 Persistenz während Durchführung
**Offen:** Ob ein **`training_session_run`** (Snapshot der aufgelösten Einheit zum Startzeitpunkt) für Nachvollziehbarkeit und Offline-Fähigkeit nötig ist.
### 7.3 Ausbaustufen
1. Read-only **Durchführungsansicht** (Archetyp + Zeiten, keine komplexe State Machine)
2. **Aktiver Modus** mit State Machine und Archetyp-spezifischer UI
3. Optional: Offline/PWA-Verhalten
---
## 8. Umsetzungsphasen (Vorschlag)
| Phase | Inhalt |
|-------|--------|
| **A** | Dieses Dokument verbindlich machen; Archetypen und Parameter final; Governance-Regeln |
| **B** | Typ 2: `training_modules` + API + „Modul in Einheit einfügen“ (Expansion) |
| **C** | Typ 1: Kombinationsübung im Katalog + Slots/Pools + Methodenprofil |
| **D** | Einbindung in Rahmen-Slot-Blueprints (Editor-Flow) |
| **E** | Coaching-Modus gemäß Archetyp |
---
## 9. Offene Entscheidungen (Checkliste)
- [ ] Modul-Einfügung: nur **Expansion** vs. **Block-Knoten** vs. beides
- [ ] Normalisierung vs. JSON für **Methodenprofil-Parameter**
- [ ] Globale vs. vereinsbezogene vs. private **Trainingsmodule** (Governance-Matrix)
- [ ] Pflichtbinding: muss jede Kombinationsübung einen **Default-Archetyp** aus `training_methods` erben dürfen?
- [ ] Coaching: Mindestumfang MVP (nur Ansicht vs. interaktive Timer)
- [ ] Verweise in `DOMAIN_MODEL.md` und `DATABASE_SCHEMA.md` nach Implementierung pflegen
---
## 10. Changelog
| Datum | Änderung |
|-------|----------|
| 2026-05-12 | Erstversion (Entwurf) angelegt |

View File

@ -15,6 +15,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
| exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | PUT Einzelübung: bei Sichtbarkeit `official` Medien-§4.2 (422: Lifecycle/Promotion/Copyright) |
| exercise_progression_graphs | `/api/exercise-progression-graphs*` | ja | `get_tenant_context` | Liste wie Bibliothek; Schreiben Ersteller/Plattform-Admin | Kanten: Lesen wenn Graph lesbar |
| training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage Default club_id |
| training_modules | `/api/training-modules*` | ja | `get_tenant_context` | ja | Bibliotheks-Module wie Vorlagen/Rahmen; POST Default `club_id` bei `visibility=club` |
| training_framework_programs | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Liste + POST Default club_id |
| admin_users | `GET /api/admin/users` | Plattform | `require_auth` | Admin-Rolle | EXEMPT `check_access_layer_hints.py` |
| platform_media_storage | `GET/PUT /api/admin/platform-media-storage` | Plattform | `require_auth` | GET: `is_platform_admin`; PUT: nur `superadmin` | Relativer Pfad unter `MEDIA_ROOT`; keine Secrets; EXEMPT wie admin_users |
@ -36,12 +37,13 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
**Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen.
Letzte Änderung: 2026-05-07 — Upload-Dedupe Papierkorb 409 + `reactivate`; DELETE …/media nur Verknüpfung.
Letzte Änderung: 2026-05-12 — Trainingsmodule (`/api/training-modules*`); Governance wie Planungsbibliothek.
---
### Changelog (Fortführung)
- **2026-05-12:** `training_modules` Router dokumentiert.
- **2026-05-07:** Legacy `GET/PUT /api/profile` auf Session-Profil gehärtet; OpenAPI/Health-Ready Produktionsdefaults; Security-Release-Tests + CI-Schritt `security_release_checks.py` — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.
- **2026-05-07 (Phase 3):** CSP SPA (nginx); API `nosniff`-Middleware — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.

View File

@ -0,0 +1,101 @@
# KombinationsAblaufprofil — Zeitmodell, ArchetypVorgaben, Umsetzung
**Zweck:** Fach-/Technik-Brücke zwischen Wunschbild („kein NutzerJSON“, globale und slotbezogene Eckwerte, ArchetypStrukturen) und bestehendem Speicher **`method_profile` (JSON)** + **`planning_method_profile`** auf Planungszeilen.
**Bezüge:** `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md`6.3 / §8.3); Frontend `CombinationMethodProfileEditor`, `combinationMethodProfileUi.js`; ArchetypIDs siehe Backend `COMBINATION_ARCHETYPE_IDS` / Frontend `COMBINATION_ARCHETYPE_OPTIONS`.
---
## 1. Grundprinzipien
| Prinzip | Beschreibung |
|--------|--------------|
| **Kein PflichtJSON für Trainer** | Alle trainertypischen Pflegepfade nur über geführte Felder + ArchetypVorschlagsknöpfe. |
| **JSON bleibt Transport** | Persistenz geschieht weiter in `method_profile` / Kopie in `planning_method_profile`; **kanonische Schlüssel** werden hier und in Codekommentaren festgehalten. |
| **Archetyp = Struktur + Defaults** | Wechsel des Archetyps soll (optional/togglebar) Grundwerte oder typische Relationen vorbelegen können — keine stillen Überschreibungen ohne Hinweis. |
| **`free_method_block` = Maximale Freiheit** | Entspricht „maximaler Konfiguration“: alle relevanten TimingDimensionen über UI, insbesondere **pro Slot**; keine impliziten stationären Constraints. |
---
## 2. Kanonisches ZeitSchema (`timing_schema`)
**Empfohlene Versionierung im Objekt:**
- **`timing_schema: 1`** — sobald neue globale/strukturierte Felder aktiv genutzt werden (Pilot; UI kann ohne Migration starten durch parallele Schlüssel).
### 2.1 Globalebene (`method_profile`)
| Feld (Pilot) | Semantik |
|----------------|----------|
| `timing_schema` | `1` wenn Block unten aktiv |
| `intro_sec` oder bestehend `block_intro_sec` | einmalige Einführung/Demo am Block |
| `rounds` (bzw. bei Intervallen `interval_rounds` — Angleich später) | komplette Durchläufe des Musters |
| *Planned totals* nur **berechnete Anzeige** in UI, optional persistiert z.B. `planned_total_duration_min_hint` später |
Relationen **Zwischen Arbeit und Pause** können als Schnellwahl gesetzt werden (kein eigener PersistErzwingTyp nötig), indem konkrete Sekunden geschrieben werden.
### 2.2 Slots (`slot_profiles_v1`)
Array synchron zu `slot_index`; fehlende Einträge = „nicht gefüllt / aus globalen Eckdaten ableiten wo sinnvoll“.
ObjektShape (Sekunden, ganze Zahlen ≥ 0):
```json
{
"slot_index": 0,
"load_sec": 40,
"consecutive_reps": 1,
"intra_rep_rest_sec": 10,
"transition_after_sec": 15
}
```
| Feld | Bedeutung |
|------|------------|
| `load_sec` | Belastungsdauer „an der Station“. |
| `consecutive_reps` | Wiederholungen pro „Serie“ bzw. ohne Wechsel zu **neuem** Stationsinhalt (oft 1). |
| `rep_series_count` | Anzahl Serien à `consecutive_reps` bei rep/manual; Standard **1**, ArchetypVorgabe möglich (**`ARCHETYPE_DEFAULT_REP_SERIES_COUNT`**). Persistiert für rep/manual ab 1. |
| `intra_rep_rest_sec` | Pause zwischen den FolgeWiederholungen bzw. **zwischen Serien** (nur sinnvoll, wenn `rep_series_count` ≥ 2 im Modus `rep`/`manual`; sonst Wechselzeit `transition_after_sec` nutzen). |
| `transition_after_sec` | Pause / Wechsel **zur nächsten** Station oder zum nächsten logischen Block. |
**Hinweis:** Bestehende Archetyp„flachen“ Schlüssel (`work_seconds`, `transition_seconds`, …) werden schrittweise **nicht zerstört**, sondern Slots ergänzen; Konvergenz (eine Darstellung zu v1) kann Phase 4 sein.
---
## 3. Archetyp → typische Schnellwahl (ÜberblicksMatrix)
| Archetyp | Globale Schnellwahl (Beispiele) | Slots |
|----------|---------------------------------|-------|
| `circuit_rotate_time` | Arbeit; Rotation „≈ Arbeit“ oder „Pause 2/3 Arbeit“ bezogen auf RundPausen/Rotation wo im UI dokumentiert | sinnvoll ab **timing_schema** geführt |
| `time_domain_interval` | Pause = Arbeit; Pause = 2/3 Arbeit (auf `rest_seconds`↔`work_seconds`) | optional |
| `sequence_linear` | Einführung + grobe Sek./Station | **slot_profiles_v1** priorisiert |
| `circuit_all_parallel` | Erklärzeit, gemeinsamer Start | Slots optional |
| `pair_superset` | Wechsel A↔B, Arbeit je Seite (+ später erweiterbar) | 2SlotFokus |
| `free_method_block` | alle globalen Slots optional | **Pfad für maximale Flex** |
| `station_parcour` | Reihenfolge freiFlag bestehend | pro Station Verweilen sinnvoll |
**Pyramidal (später):** neue ArchetypID **`pyramid_interval`** o. ä. oder Flag `pyramid_recovery_rule` mit Regelwerk „Pause abhängig von letzter Belastung“ — **explizit out of scope** bis Regeln feststehen.
---
## 4. UXNormen
- **Trainingsplanung** (`plannerMode`): **keine** RohJSONOberfläche.
- **Übungsformular**: RohJSON nur wenn `allowExpertJson === true` (Default false; später z.B. Superadmin/Dev).
- **CoachingAnsicht**: nur **wirksame** Zahlen aus Snapshot/Katalog darstellen, mittelfristig Labels statt Schlüsseln.
---
## 5. Phasen (Implementierung)
| Phase | Inhalt |
|-------|--------|
| **1 (jetzt)** | SlotZeilenUI über `slot_profiles_v1`; SchnellwahlRatios für `circuit_rotate_time` + `time_domain_interval`; `plannerMode` ohne JSON; `allowExpertJson` default false |
| **2** | Beim Archetypwechsel **optionales** Modal „ArchetypVorlage anwenden?“ mit nichtdestruktivem Merge |
| **3** | Geplante **Gesamtzeit** konsistent rechnerisch (Summe Slots × Runden + Global) mit Transparenz in UI |
| **4** | Konsolidierung flacher Schlüssel → **`timing_schema`** v1only im Editor |
| **5** | Pyramide / adaptive Recovery |
---
**Pflege:** Änderungen an Schlüsseln oder Phasen hier und in Anhang A der Fachspez mitziehen.

View File

@ -0,0 +1,40 @@
# Umsetzungsplan: Trainingsmodule & Kombinationsübungen
**Bezug:** `functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (Kopf „V3“, inkl. **§10.2.1**, **§10.4 Coaching-Stufen**, **Anhang A** Implementierungsabgleich — Drift-Schutz)
**Technische Entwurfsspezifikation:** `technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md`
**Stand dieses Dokuments:** 2026-05-12 (Abgleich mit Code ~App **0.8.102**)
## Ziele
Umsetzung der MVP-Punkte aus der Fachspezifikation ohne die bestehende Planung zu destabilisieren: schrittweise Migrationen, bestehende Sektions-/Item-Struktur (`training_unit_sections`, `training_unit_section_items`) beibehalten, Kopiersemantik bei Übernahmen.
## Phasenüberblick
| Phase | Inhalt | Status |
|-------|--------|--------|
| **1** | **Trainingsmodule (Bibliothek):** Tabellen `training_modules`, `training_module_items`; REST CRUD mit Governance wie andere Bibliotheken; Übernahme in eine bestehende Einheit per `POST /api/training-units/{id}/apply-training-module` (Anfügen ans Ende eines Abschnitts via `section_order_index`); optionale Lineage-Spalte `source_training_module_id` auf Planungsitems; UI: Liste/Editor unter `/planning/training-modules`, Link von der Planung, Modal „Modul übernehmen“ | **umgesetzt (MVP Schritt 1)** |
| **2** | Kombinationsübungen: `exercise_kind`/`combination_*`, Slots, Pools, `method_archetype`, `method_profile` (JSON) | **teilweise** — Migration 056, CRUD/API, Picker/Liste; Übungsformular: geführtes **`method_profile` nach Archetyp** (`CombinationMethodProfileEditor`, `combinationMethodProfileUi.js`) plus RohJSON; **Backend:** keine strenge Validierung Profil ↔ Archetyp | Haupt-/Nebenmethoden an Kombi wo Spec es verlangt; serverseitige Validierung für ProfilSchlüssel optional |
| **3** | Planungsblöcke: Gruppierung, Auflösen, „als Modul speichern“, erweiterter Übernahmemodus (Zwischenposition) | geplant |
| **4** | Coaching: Archetyp-Support | **teilweise:** **Stufe A** nach Fachspez §10.4 (Slotliste, Kandidaten aus Katalog geladen, Archetyp-Hilfstexte in `CombinationCoachSlots`/`combinationArchetypes.js`); **Stufe B/C** (Zeitleisten-Splitting, Stations-/Intervall-Timing) — **offen**, siehe Anhang A der Fachspez |
| **5** | Rahmenprogramm: Modulübernahme UX in Slot-Blueprint-Editor konsolidieren | geplant |
## Coaching — verbindliche Arbeitspakete (gegen Spec-Drift)
| Paket | Spec-Referenz | Kurzinhalt |
|-------|----------------|-----------|
| **4a (Ist/Ziel)** | §10.2.1 | Archetyp-Schlüssel bleiben identisch zu `backend/routers/exercises.py` (`COMBINATION_ARCHETYPE_IDS`) und `frontend/src/constants/combinationArchetypes.js`. |
| **4b** | §10.4 Stufe A | Slots + Kandidaten; Archetyp-Hilfstext; `method_profile` **lesend** unter der Kopf-Zeile (Key/WertListe wenn gepflegt); Feintuning Labels optional. |
| **4c** | §10.4 Stufe B | Entscheidung: virtuelle Substeps vs. persistierte Items; Konsistenz `sectionsToPutPayload`/Ist-Zeit. |
| **4d** | §10.4 Stufe C | Archetyp-spezifische Timer/Wechsel/Abhaken an `method_profile` — nach 4b/4c. |
## Phase 1 (technische Notizen)
- **Governance:** `visibility`/`club_id`/`created_by` analog `training_plan_templates`; Listenfilter `library_content_visibility_sql`.
- **Übernahme:** Keine Live-Verknüpfung; Items werden kopiert; `source_training_module_id` dokumentiert Herkunft.
- **Schnittstelle Übernahme:** `section_order_index` entspricht der Reihenfolge der Abschnitte in der gespeicherten Einheit (0-basiert), konsistent zur Planungs-API.
## Pflege nach Merge
- `DATABASE_SCHEMA.md` bei größeren Schema-Erweiterungen ergänzen.
- `ACCESS_LAYER_ENDPOINT_AUDIT.md` bei neuen mandantenbezogenen Endpunkten fortpflegen.
- **Nach jeder Kombi-/Coach-Änderung:** `functional/… Spezifikation V2.md` **Anhang A** und diese Tabelle Phasen 2/4 abstimmen.

View File

@ -13,6 +13,7 @@
> | Anforderungen | `.claude/docs/functional/SHINKAN_REQUIREMENTS.md` |
> | Medien-Archiv, Lifecycle, Inline (Plan §11) | `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
> | Handover / nächste Session | **`docs/HANDOVER.md`** |
> | Fachlicher Nutzerüberblick (Design/Product) | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** |
## Projekt-Übersicht
@ -83,7 +84,7 @@ frontend/src/
**Siehe:** `backend/version.py` (`APP_VERSION`, `DB_SCHEMA_VERSION`, `MODULE_VERSIONS`) und `.claude/docs/PROJECT_STATUS.md`.
Kurz (Stand 2026-05-08): App **0.8.64**, DBSchemaVersion siehe **`backend/version.py`**; Kern: Übungen, Varianten, **Medien-Archiv & Bibliothek (`/media`)**, **Inline-Medien im Rich-Text** (Modal-Picker, Größenwahl, Drag&Drop + Auto-Scroll), Mandanten-Sync aktiver Verein, Planung mit Sektionen, **Trainingsrahmen Bibliothek + SlotBlueprint** (036037), Progressionsgraph, Reifegrad/MatrixStack — Details `PROJECT_STATUS.md`, `docs/HANDOVER.md`, `MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`11 umgesetzt).
Kurz (Stand 2026-05-12): App **0.8.96**, DBSchemaVersion siehe **`backend/version.py`**; Kern: Übungen, Varianten, **Medien-Archiv & Bibliothek (`/media`)**, **Inline-Medien im Rich-Text**, **Inhaltsmeldungen (P-13)** im Posteingang, Mandanten-Sync aktiver Verein, Planung mit Sektionen, **Trainingsrahmen Bibliothek + SlotBlueprint** (036037), Progressionsgraph, Reifegrad/MatrixStack — Details `PROJECT_STATUS.md`, `docs/HANDOVER.md`, Nutzerüberblick **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`**, `MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` (Abschnitt 11 umgesetzt).
### Log (Auszug)

View File

@ -193,7 +193,7 @@ def read_root():
return out
# Register routers
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, training_planning, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports
app.include_router(auth.router)
app.include_router(profiles.router)
@ -209,6 +209,7 @@ app.include_router(media_assets.admin_rights_router)
app.include_router(media_assets.admin_legal_hold_router)
app.include_router(skills.router)
app.include_router(training_planning.router)
app.include_router(training_modules.router)
app.include_router(training_framework_programs.router)
app.include_router(catalogs.router)
app.include_router(maturity_models.router)

View File

@ -0,0 +1,60 @@
-- Migration 054: Trainingsmodule (Bibliothek / Planung) — Phase 1 MVP
-- Fachgrundlage: functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md
CREATE TABLE IF NOT EXISTS training_modules (
id SERIAL PRIMARY KEY,
club_id INT REFERENCES clubs(id) ON DELETE SET NULL,
created_by INT REFERENCES profiles(id) ON DELETE SET NULL,
title VARCHAR(200) NOT NULL,
summary TEXT,
goal TEXT,
recommended_duration_min INT,
target_group_notes TEXT,
deployment_context_notes TEXT,
primary_method_id INT REFERENCES training_methods(id) ON DELETE SET NULL,
visibility VARCHAR(50) NOT NULL DEFAULT 'club'
CHECK (visibility IN ('private', 'club', 'official')),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_training_modules_club ON training_modules(club_id);
CREATE INDEX IF NOT EXISTS idx_training_modules_creator ON training_modules(created_by);
CREATE INDEX IF NOT EXISTS idx_training_modules_visibility ON training_modules(visibility);
CREATE INDEX IF NOT EXISTS idx_training_modules_method ON training_modules(primary_method_id)
WHERE primary_method_id IS NOT NULL;
DROP TRIGGER IF EXISTS training_modules_update ON training_modules;
CREATE TRIGGER training_modules_update
BEFORE UPDATE ON training_modules
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
CREATE TABLE IF NOT EXISTS training_module_items (
id SERIAL PRIMARY KEY,
module_id INT NOT NULL REFERENCES training_modules(id) ON DELETE CASCADE,
order_index INT NOT NULL,
item_type VARCHAR(20) NOT NULL CHECK (item_type IN ('exercise', 'note')),
exercise_id INT REFERENCES exercises(id) ON DELETE SET NULL,
exercise_variant_id INT REFERENCES exercise_variants(id) ON DELETE SET NULL,
planned_duration_min INT,
notes TEXT,
note_body TEXT,
UNIQUE (module_id, order_index),
CHECK (
(item_type = 'exercise' AND exercise_id IS NOT NULL AND note_body IS NULL)
OR
(item_type = 'note' AND exercise_id IS NULL)
)
);
CREATE INDEX IF NOT EXISTS idx_training_module_items_module ON training_module_items(module_id);
CREATE INDEX IF NOT EXISTS idx_training_module_items_exercise ON training_module_items(exercise_id)
WHERE exercise_id IS NOT NULL;
-- Herkunft bei Übernahme aus Modul-Bibliothek (Kopie, keine Live-Verknüpfung)
ALTER TABLE training_unit_section_items
ADD COLUMN IF NOT EXISTS source_training_module_id INT REFERENCES training_modules(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_training_unit_section_items_source_module
ON training_unit_section_items(source_training_module_id)
WHERE source_training_module_id IS NOT NULL;

View File

@ -0,0 +1,3 @@
-- Persönliche Planungs-UI-Präferenzen (JSONB, selbst vom Nutzer setzbar)
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS training_planning_prefs JSONB NOT NULL DEFAULT '{}'::jsonb;

View File

@ -0,0 +1,33 @@
-- Migration 056: Kombinationsübungen (Phase 2 MVP) — Slots + Pool-Kandidaten
-- Fachgrundlage: functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md §6
ALTER TABLE exercises
ADD COLUMN IF NOT EXISTS exercise_kind VARCHAR(20) NOT NULL DEFAULT 'simple'
CHECK (exercise_kind IN ('simple', 'combination')),
ADD COLUMN IF NOT EXISTS method_archetype VARCHAR(80),
ADD COLUMN IF NOT EXISTS method_profile JSONB NOT NULL DEFAULT '{}'::jsonb;
CREATE INDEX IF NOT EXISTS idx_exercises_exercise_kind ON exercises(exercise_kind);
CREATE INDEX IF NOT EXISTS idx_exercises_method_archetype ON exercises(method_archetype)
WHERE method_archetype IS NOT NULL;
CREATE TABLE IF NOT EXISTS combination_exercise_slots (
id SERIAL PRIMARY KEY,
exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
slot_index INT NOT NULL,
title VARCHAR(200),
UNIQUE (exercise_id, slot_index)
);
CREATE INDEX IF NOT EXISTS idx_combination_exercise_slots_exercise ON combination_exercise_slots(exercise_id);
CREATE TABLE IF NOT EXISTS combination_slot_candidates (
id SERIAL PRIMARY KEY,
slot_id INT NOT NULL REFERENCES combination_exercise_slots(id) ON DELETE CASCADE,
candidate_exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
sort_order INT NOT NULL DEFAULT 0,
UNIQUE (slot_id, candidate_exercise_id)
);
CREATE INDEX IF NOT EXISTS idx_combination_slot_candidates_slot ON combination_slot_candidates(slot_id);
CREATE INDEX IF NOT EXISTS idx_combination_slot_candidates_exercise ON combination_slot_candidates(candidate_exercise_id);

View File

@ -0,0 +1,8 @@
-- 057: Terminspezifisches Ablaufprofil fuer Kombinationsuebungen in der Planung
-- NULL = method_profile vom Katalog (exercises) verwenden; sonst dieser JSONB-Stand gilt fuer diese Platzierung.
ALTER TABLE training_unit_section_items
ADD COLUMN IF NOT EXISTS planning_method_profile JSONB NULL;
COMMENT ON COLUMN training_unit_section_items.planning_method_profile IS
'Snapshots des Ablaufprofils fuer diese Einheit/Zeile; NULL = exercises.method_profile.';

View File

@ -47,6 +47,10 @@ class ProfileUpdate(BaseModel):
default=None,
description="JSON: gespeicherte Standardfilter für die Übungsliste",
)
training_planning_prefs: Optional[Dict[str, Any]] = Field(
default=None,
description="JSON: UI-Optionen Trainingsplanung (z.B. Darstellung kopierter Module)",
)
class ProfileResponse(BaseModel):
id: int

View File

@ -10,12 +10,13 @@ import logging
import os
import re
from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional, Tuple
from typing import Any, Dict, Iterator, List, Literal, Optional, Tuple
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Depends, Query, Request, UploadFile, File, Form
from fastapi.responses import FileResponse, Response, StreamingResponse
from pydantic import BaseModel, Field, model_validator
from psycopg2.extras import Json
from db import get_db, get_cursor, r2d
from club_tenancy import (
@ -198,6 +199,26 @@ def _upload_limit_bytes(tenant: TenantContext) -> int:
# Pydantic Models
# ============================================================================
# Archetyp-IDs (Maschinenlesbare Ablaufmuster) — konsistent zur technischen Entwurfsspezifikation
COMBINATION_ARCHETYPE_IDS = frozenset(
{
"circuit_rotate_time",
"circuit_all_parallel",
"sequence_linear",
"station_parcour",
"pair_superset",
"time_domain_interval",
"free_method_block",
}
)
class CombinationSlotIn(BaseModel):
slot_index: int = Field(ge=0, le=99)
title: Optional[str] = Field(None, max_length=200)
candidate_exercise_ids: list[int] = Field(default_factory=list)
class ExerciseCreate(BaseModel):
# Basis-Felder (goal/execution: DB-Constraint mind. eines; Wiki oft nur eines)
title: str = Field(..., min_length=3, max_length=300)
@ -231,6 +252,12 @@ class ExerciseCreate(BaseModel):
status: str = "draft"
club_id: Optional[int] = None
# Kombinationsübung (Phase 2)
exercise_kind: Literal["simple", "combination"] = "simple"
method_archetype: Optional[str] = Field(None, max_length=80)
method_profile: Dict[str, Any] = Field(default_factory=dict)
combination_slots: list[CombinationSlotIn] = Field(default_factory=list)
@model_validator(mode="after")
def normalize_goal_execution(self):
g = (self.goal or "").strip() or None
@ -270,6 +297,11 @@ class ExerciseUpdate(BaseModel):
# Vereins-Übung: fehlende Copyrights an Datei-Assets nach Prompt-Text setzen (PUT-Retry)
default_club_media_copyright: Optional[str] = Field(default=None, max_length=2000)
exercise_kind: Optional[Literal["simple", "combination"]] = None
method_archetype: Optional[str] = Field(None, max_length=80)
method_profile: Optional[Dict[str, Any]] = None
combination_slots: Optional[list[CombinationSlotIn]] = None
@model_validator(mode="after")
def normalize_goal_execution(self):
if self.goal is not None:
@ -811,6 +843,174 @@ def _resolve_local_media_file(
return path_under_media_root(media_root, asset_storage_key)
return _abs_media_path(file_path_db or "", media_root) if file_path_db else None
def _normalize_method_profile_store(raw: Any) -> Dict[str, Any]:
if raw is None:
return {}
if isinstance(raw, dict):
return raw
raise HTTPException(status_code=400, detail="method_profile muss ein JSON-Objekt sein")
def _validate_archetype_for_kind(kind: str, archetype: Optional[str]) -> None:
if kind != "combination":
return
if archetype is None or not str(archetype).strip():
return
a = str(archetype).strip()
if a not in COMBINATION_ARCHETYPE_IDS:
raise HTTPException(
status_code=400,
detail=(
"Unbekannter method_archetype. Erlaubt: "
+ ", ".join(sorted(COMBINATION_ARCHETYPE_IDS))
),
)
def _assert_candidate_exercises_for_combination(cur, tenant: TenantContext, ids: List[int]) -> None:
if not ids:
return
seen: set[int] = set()
for cid_raw in ids:
cid = int(cid_raw)
if cid in seen:
continue
seen.add(cid)
cur.execute(
"""SELECT id, exercise_kind, visibility, club_id, created_by
FROM exercises WHERE id = %s""",
(cid,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=400, detail=f"Slot-Verweis: Übung #{cid} nicht gefunden")
rd = r2d(row)
k = str(rd.get("exercise_kind") or "simple").strip().lower()
if k != "simple":
raise HTTPException(
status_code=400,
detail=f"Slot-Verweis: Übung #{cid} ist eine Kombinationsübung — nur Einzelübungen erlaubt",
)
if not library_content_visible_to_profile(
cur,
tenant.profile_id,
rd.get("visibility"),
rd.get("club_id"),
rd.get("created_by"),
tenant.global_role,
):
raise HTTPException(status_code=403, detail=f"Slot-Verweis: keine Leserechte für Übung #{cid}")
def _validate_and_normalize_combination_slots_payload(
cur,
tenant: TenantContext,
slots: Optional[List[CombinationSlotIn]],
) -> List[Tuple[int, Optional[str], List[int]]]:
if slots is None:
return []
normalized: Dict[int, Tuple[Optional[str], List[int]]] = {}
for s in sorted(slots, key=lambda x: x.slot_index):
cid_list_raw = list(s.candidate_exercise_ids or [])
cid_list: List[int] = []
for x in cid_list_raw:
cid_list.append(int(x))
title = ((s.title or "").strip()) or None
normalized[int(s.slot_index)] = (title, cid_list)
out: List[Tuple[int, Optional[str], List[int]]] = []
for idx in sorted(normalized.keys()):
title, cands = normalized[idx]
if not cands:
raise HTTPException(
status_code=400,
detail=f"Station (Index {idx}): mindestens eine Einzelübung (Pool) ist erforderlich",
)
out.append((idx, title, cands))
return out
def replace_combination_slots(
cur,
tenant: TenantContext,
exercise_id: int,
slots_norm: List[Tuple[int, Optional[str], List[int]]],
) -> None:
flat_ids = [cid for _, __, xs in slots_norm for cid in xs]
_assert_candidate_exercises_for_combination(cur, tenant, flat_ids)
cur.execute("DELETE FROM combination_exercise_slots WHERE exercise_id = %s", (exercise_id,))
for slot_index, title, cand_ids in slots_norm:
cur.execute(
"""INSERT INTO combination_exercise_slots (exercise_id, slot_index, title)
VALUES (%s, %s, %s) RETURNING id""",
(exercise_id, slot_index, title),
)
row = cur.fetchone()
sid = row["id"] if isinstance(row, dict) else row[0]
for so, cid in enumerate(cand_ids):
cur.execute(
"""INSERT INTO combination_slot_candidates (slot_id, candidate_exercise_id, sort_order)
VALUES (%s, %s, %s)""",
(sid, int(cid), int(so)),
)
def wipe_combination_structure(cur, exercise_id: int) -> None:
cur.execute("DELETE FROM combination_exercise_slots WHERE exercise_id = %s", (exercise_id,))
def assert_exercise_not_combination(cur, exercise_id: int) -> None:
cur.execute(
"SELECT COALESCE(exercise_kind, 'simple') AS exercise_kind FROM exercises WHERE id = %s",
(exercise_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
if str(r2d(row).get("exercise_kind") or "simple").strip().lower() == "combination":
raise HTTPException(
status_code=400,
detail="Kombinationsübungen unterstützen keine Varianten.",
)
def load_combination_slots_for_exercise(cur, exercise_id: int) -> List[dict]:
"""Stationsliste einer Kombinationsübung (gleiches Format wie GET /api/exercises/:id)."""
cur.execute(
"""SELECT id, slot_index, title FROM combination_exercise_slots
WHERE exercise_id = %s ORDER BY slot_index ASC, id ASC""",
(exercise_id,),
)
slot_rows = [r2d(r) for r in cur.fetchall()]
slots_out: List[dict] = []
for sr in slot_rows:
slot_pk = sr["id"]
cur.execute(
"""SELECT candidate_exercise_id FROM combination_slot_candidates
WHERE slot_id = %s ORDER BY sort_order ASC, id ASC""",
(slot_pk,),
)
crows = cur.fetchall()
cids = [int(r2d(c)["candidate_exercise_id"]) for c in crows]
cand_meta: Dict[int, Optional[str]] = {}
if cids:
ph = ",".join(["%s"] * len(cids))
cur.execute(
f"SELECT id, title FROM exercises WHERE id IN ({ph})",
tuple(cids),
)
cand_meta = {int(r2d(x)["id"]): r2d(x).get("title") for x in cur.fetchall()}
slots_out.append(
{
"slot_index": sr["slot_index"],
"title": sr.get("title"),
"candidate_exercise_ids": cids,
"candidates": [{"exercise_id": cid, "title": cand_meta.get(cid)} for cid in cids],
}
)
return slots_out
def enrich_exercise_detail(exercise_id: int, cur) -> dict:
"""
Lädt alle M:N Relations für eine Übung und gibt ein vollständiges
@ -933,6 +1133,14 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict:
)
exercise["media"] = [r2d(r) for r in cur.fetchall()]
mp_raw = exercise.get("method_profile")
exercise["method_profile"] = mp_raw if isinstance(mp_raw, dict) else {}
exercise["exercise_kind"] = str(exercise.get("exercise_kind") or "simple").strip().lower()
exercise["combination_slots"] = []
if exercise["exercise_kind"] == "combination":
exercise["combination_slots"] = load_combination_slots_for_exercise(cur, exercise_id)
return exercise
@ -1528,6 +1736,10 @@ def list_exercises(
default=False,
description="Nur Übungen, die vom aktuellen Profil angelegt wurden (created_by = Profil)",
),
exercise_kind_any: list[str] = Query(
default=[],
description="ODER: mind. einer dieser Übungsarten — simple oder combination",
),
tenant: TenantContext = Depends(get_tenant_context),
):
"""
@ -1558,6 +1770,21 @@ def list_exercises(
where.append("e.created_by = %s")
params.append(profile_id)
ek_filtered: List[str] = []
if exercise_kind_any:
for raw in exercise_kind_any:
s = str(raw or "").strip().lower()
if not s:
continue
if s not in ("simple", "combination"):
raise HTTPException(status_code=400, detail="exercise_kind_any: nur simple oder combination")
if s not in ek_filtered:
ek_filtered.append(s)
if ek_filtered:
ph = ",".join(["%s"] * len(ek_filtered))
where.append(f"(LOWER(TRIM(COALESCE(e.exercise_kind::text,''))) IN ({ph}))")
params.extend(ek_filtered)
vis_list = _merge_str_any(visibility_any, visibility)
if vis_list:
ph = ",".join(["%s"] * len(vis_list))
@ -1776,6 +2003,7 @@ def list_exercises(
# Query (primary_focus_name für Listen-Ansicht gemäß Spec-Beispiel „focus_area“-Label)
query = f"""
SELECT e.id, e.title, e.summary, e.visibility, e.status,
e.exercise_kind, e.method_archetype,
e.created_by, p.name as creator_name,
e.club_id, c.name as club_name,
e.created_at, e.updated_at,
@ -1829,6 +2057,7 @@ def list_exercises(
out = []
for r in rows:
d = r2d(r)
d["exercise_kind"] = str(d.get("exercise_kind") or "simple").strip().lower()
pfn = d.get("primary_focus_name")
d["focus_area"] = pfn
d["focus_area_names"] = _coerce_json_str_list(d.get("focus_area_names"))
@ -1913,16 +2142,39 @@ def create_exercise(
cur, profile_id, tenant.global_role, body.visibility, club_id
)
kind_clean = str(body.exercise_kind or "simple").strip().lower()
if kind_clean not in ("simple", "combination"):
raise HTTPException(status_code=400, detail="exercise_kind: simple oder combination")
prof_dict = _normalize_method_profile_store(body.method_profile)
arch_raw = body.method_archetype
arch_val = (arch_raw.strip() if isinstance(arch_raw, str) and arch_raw.strip() else None)
_validate_archetype_for_kind(kind_clean, arch_val)
slots_norm: List[Tuple[int, Optional[str], List[int]]] = []
if kind_clean == "combination":
slots_norm = _validate_and_normalize_combination_slots_payload(
cur, tenant, body.combination_slots or []
)
if not slots_norm:
raise HTTPException(
status_code=400,
detail="Kombinationsübung: mindestens eine Station mit Übungen nötig",
)
mp_json = Json(prof_dict if kind_clean == "combination" else {})
arch_db = arch_val if kind_clean == "combination" else None
# Equipment als JSONB
equipment_json = json.dumps(body.equipment) if body.equipment else None
# INSERT
cur.execute(
"""INSERT INTO exercises
(title, summary, goal, execution, preparation, trainer_notes,
duration_min, duration_max, group_size_min, group_size_max,
equipment, visibility, status, created_by, club_id)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
equipment, visibility, status, created_by, club_id,
exercise_kind, method_archetype, method_profile)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id""",
(
body.title, body.summary, body.goal, body.execution,
@ -1931,11 +2183,15 @@ def create_exercise(
body.group_size_min, body.group_size_max,
equipment_json,
body.visibility, body.status, profile_id, club_id,
)
kind_clean, arch_db, mp_json,
),
)
row = cur.fetchone()
exercise_id = row['id'] if isinstance(row, dict) else row[0]
if kind_clean == "combination":
replace_combination_slots(cur, tenant, exercise_id, slots_norm)
data = body.dict()
assign_exercise_relations(cur, conn, exercise_id, data, do_commit=False)
if (body.visibility or "").strip().lower() == "club":
@ -1963,7 +2219,7 @@ def update_exercise(
cur = get_cursor(conn)
cur.execute(
f"""SELECT created_by, visibility, club_id,
f"""SELECT created_by, visibility, club_id, exercise_kind, method_archetype, method_profile,
{", ".join(sorted(RICH_HTML_EXERCISE_FIELDS))}
FROM exercises WHERE id = %s""",
(exercise_id,),
@ -1993,6 +2249,84 @@ def update_exercise(
default_official_copy = data.pop("default_official_media_copyright", None)
default_club_copy = data.pop("default_club_media_copyright", None)
combo_slots_provided = "combination_slots" in data
combo_slots_payload = data.pop("combination_slots", None)
ec_kind_was = str(rd_full.get("exercise_kind") or "simple").strip().lower()
ek_provided = "exercise_kind" in data
next_kind = ec_kind_was
if ek_provided:
next_kind = str(data.pop("exercise_kind") or "simple").strip().lower()
arche_provided = "method_archetype" in data
meth_prof_provided = "method_profile" in data
next_ma_db = rd_full.get("method_archetype")
if isinstance(next_ma_db, str):
next_ma_db = next_ma_db.strip() or None
else:
next_ma_db = None
mp_row = rd_full.get("method_profile")
next_mp_db = mp_row if isinstance(mp_row, dict) else {}
if ek_provided and next_kind not in ("simple", "combination"):
raise HTTPException(status_code=400, detail="exercise_kind: simple oder combination")
if arche_provided:
va = data.pop("method_archetype")
if va is None or (isinstance(va, str) and not va.strip()):
next_ma_db = None
elif isinstance(va, str):
next_ma_db = va.strip() or None
else:
next_ma_db = None
if meth_prof_provided:
next_mp_db = _normalize_method_profile_store(data.pop("method_profile"))
if next_kind == "simple":
next_ma_db = None
next_mp_db = {}
_validate_archetype_for_kind(next_kind, next_ma_db)
if ec_kind_was == "simple" and next_kind == "combination":
if not combo_slots_provided:
raise HTTPException(
status_code=400,
detail='Umschalten auf Kombinationsübung: Feld "combination_slots" ist erforderlich',
)
combo_slots_normalized: Optional[List[Tuple[int, Optional[str], List[int]]]] = None
if combo_slots_provided:
if next_kind != "combination":
raise HTTPException(
status_code=400,
detail="combination_slots nur bei exercise_kind=combination erlaubt",
)
slots_in_raw = combo_slots_payload if combo_slots_payload is not None else []
slots_in: List[CombinationSlotIn] = []
for s in slots_in_raw:
if isinstance(s, CombinationSlotIn):
slots_in.append(s)
elif isinstance(s, dict):
slots_in.append(CombinationSlotIn(**s))
else:
raise HTTPException(status_code=400, detail="Ungültige combination_slots Payload-Struktur")
combo_slots_normalized = _validate_and_normalize_combination_slots_payload(
cur, tenant, slots_in
)
if not combo_slots_normalized:
raise HTTPException(status_code=400, detail="Kombinationsübung: mindestens eine Station mit Übungen")
update_combo_cols = (
ek_provided
or arche_provided
or meth_prof_provided
or (next_kind == "simple" and ec_kind_was != "simple")
)
merged_rich = {fld: rich_row.get(fld) for fld in RICH_HTML_EXERCISE_FIELDS}
for fld in RICH_HTML_EXERCISE_FIELDS:
if fld not in data:
@ -2064,11 +2398,27 @@ def update_exercise(
fields.append("equipment = %s")
params.append(json.dumps(data["equipment"]) if data["equipment"] else None)
if update_combo_cols:
if ek_provided:
fields.append("exercise_kind = %s")
params.append(next_kind)
fields.append("method_archetype = %s")
params.append(next_ma_db)
fields.append("method_profile = %s")
params.append(Json(next_mp_db))
if fields:
fields.append("updated_at = NOW()")
params.append(exercise_id)
query = f"UPDATE exercises SET {', '.join(fields)} WHERE id = %s"
cur.execute(query, params)
elif combo_slots_normalized is not None:
cur.execute("UPDATE exercises SET updated_at = NOW() WHERE id = %s", (exercise_id,))
if combo_slots_normalized is not None:
replace_combination_slots(cur, tenant, exercise_id, combo_slots_normalized)
elif ec_kind_was == "combination" and next_kind == "simple":
wipe_combination_structure(cur, exercise_id)
assign_exercise_relations(cur, conn, exercise_id, data, do_commit=False)
try:
@ -2144,6 +2494,7 @@ def reorder_exercise_variants(
with get_db() as conn:
cur = get_cursor(conn)
_assert_can_edit_exercise(cur, exercise_id, tenant)
assert_exercise_not_combination(cur, exercise_id)
cur.execute(
"SELECT id FROM exercise_variants WHERE exercise_id = %s",
@ -2178,6 +2529,7 @@ def create_exercise_variant(
with get_db() as conn:
cur = get_cursor(conn)
_assert_can_edit_exercise(cur, exercise_id, tenant)
assert_exercise_not_combination(cur, exercise_id)
_validate_variant_prerequisite(cur, exercise_id, body.prerequisite_variant_id)
eq_json = _variant_equipment_json(body.equipment_changes)
@ -2242,6 +2594,7 @@ def update_exercise_variant(
with get_db() as conn:
cur = get_cursor(conn)
_assert_can_edit_exercise(cur, exercise_id, tenant)
assert_exercise_not_combination(cur, exercise_id)
old = _fetch_variant_row(cur, exercise_id, variant_id)
if "variant_name" in data and data["variant_name"] is not None:
@ -2323,6 +2676,7 @@ def delete_exercise_variant(
with get_db() as conn:
cur = get_cursor(conn)
_assert_can_edit_exercise(cur, exercise_id, tenant)
assert_exercise_not_combination(cur, exercise_id)
_fetch_variant_row(cur, exercise_id, variant_id)
cur.execute(

View File

@ -384,6 +384,15 @@ def _run_profile_update(pid: str, p: ProfileUpdate, tenant: TenantContext) -> di
else:
raise HTTPException(400, "exercise_list_prefs muss ein JSON-Objekt sein")
if "training_planning_prefs" in patch:
tp = patch.pop("training_planning_prefs")
if tp is None:
data["training_planning_prefs"] = Json({})
elif isinstance(tp, dict):
data["training_planning_prefs"] = Json(tp)
else:
raise HTTPException(400, "training_planning_prefs muss ein JSON-Objekt sein")
nullable_keys = {"goal_weight", "goal_bf_pct", "dob"}
for k, v in patch.items():
if k == "email":

View File

@ -0,0 +1,381 @@
"""
Trainingsmodule wiederverwendbare Planungsbausteine (Bibliothek).
Governance wie TrainingsMikrovorlagen (`training_plan_templates`):
Liste/Detail über `library_content_visibility_sql`; Schreiben: Ersteller oder PlattformAdmin.
Siehe `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md`.
"""
from typing import Any, Dict, List, Optional, Tuple
from fastapi import APIRouter, Depends, HTTPException
from db import get_db, get_cursor, r2d
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
from club_tenancy import (
assert_valid_governance_visibility,
is_platform_admin,
library_content_visible_to_profile,
)
router = APIRouter(prefix="/api", tags=["training_modules"])
def _has_planning_role(role: Optional[str]) -> bool:
return role in ("admin", "superadmin", "trainer", "user")
def _fetch_training_module_row(cur, mid: int) -> Dict[str, Any]:
cur.execute("SELECT * FROM training_modules WHERE id = %s", (mid,))
r = cur.fetchone()
if not r:
raise HTTPException(status_code=404, detail="Trainingsmodul nicht gefunden")
return r2d(r)
def _module_assert_readable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None:
if is_platform_admin(role):
return
if not library_content_visible_to_profile(
cur,
profile_id,
row.get("visibility") or "club",
row.get("club_id"),
row.get("created_by"),
role,
):
raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Modul")
def _module_assert_writable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None:
if is_platform_admin(role):
return
if row.get("created_by") != profile_id:
raise HTTPException(status_code=403, detail="Nur der Ersteller darf dieses Modul ändern")
def _module_access(cur, mid: int, profile_id: int, role: str) -> Dict[str, Any]:
row = _fetch_training_module_row(cur, mid)
_module_assert_readable(cur, row, profile_id, role)
return row
def _validate_variant_for_exercise(cur, exercise_id: Optional[int], variant_id: Optional[int]):
if not variant_id:
return
if not exercise_id:
raise HTTPException(
status_code=400, detail="exercise_variant_id nur zusammen mit exercise_id erlaubt"
)
cur.execute(
"SELECT 1 FROM exercise_variants WHERE id = %s AND exercise_id = %s",
(variant_id, exercise_id),
)
if not cur.fetchone():
raise HTTPException(status_code=400, detail="Variante passt nicht zur gewählten Übung")
def _optional_positive_int(val, field_name: str) -> Optional[int]:
if val is None or val == "":
return None
try:
i = int(val)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail=f"Ungültige {field_name}")
if i < 1:
raise HTTPException(status_code=400, detail=f"Ungültige {field_name}")
return i
def _replace_module_items(cur, module_id: int, items_in: Optional[List[Any]]) -> None:
cur.execute("DELETE FROM training_module_items WHERE module_id = %s", (module_id,))
items_in = items_in or []
for i, raw in enumerate(items_in):
itype = raw.get("item_type")
if not itype:
itype = "exercise" if raw.get("exercise_id") else "note"
order_ix = raw.get("order_index")
if order_ix is None:
order_ix = i
order_ix = int(order_ix)
if itype == "note":
body = raw.get("note_body")
if body is None:
body = ""
cur.execute(
"""
INSERT INTO training_module_items (
module_id, order_index, item_type,
exercise_id, exercise_variant_id,
planned_duration_min, notes, note_body
) VALUES (%s, %s, 'note',
NULL, NULL, NULL, NULL, %s)
""",
(module_id, order_ix, body),
)
continue
eid = raw.get("exercise_id")
if not eid:
continue
eid = int(eid)
vid = _optional_positive_int(raw.get("exercise_variant_id"), "exercise_variant_id")
_validate_variant_for_exercise(cur, eid, vid)
cur.execute(
"""
INSERT INTO training_module_items (
module_id, order_index, item_type,
exercise_id, exercise_variant_id,
planned_duration_min, notes, note_body
) VALUES (%s, %s, 'exercise',
%s, %s, %s, %s, NULL)
""",
(
module_id,
order_ix,
eid,
vid,
raw.get("planned_duration_min"),
raw.get("notes"),
),
)
def load_training_module_for_apply(
cur, module_id: int, profile_id: int, role: Optional[str]
) -> Tuple[List[Dict[str, Any]], int]:
"""
Liest Modul inkl. Items für Übernahme in eine Trainingseinheit.
Returns (items_ordered, module_id).
Raises HTTPException bei 403/404.
"""
row = _fetch_training_module_row(cur, module_id)
_module_assert_readable(cur, row, profile_id, role or "")
cur.execute(
"""
SELECT item_type, exercise_id, exercise_variant_id,
planned_duration_min, notes, note_body
FROM training_module_items
WHERE module_id = %s
ORDER BY order_index ASC
""",
(module_id,),
)
raw_items = [r2d(x) for x in cur.fetchall()]
items: List[Dict[str, Any]] = []
for r in raw_items:
items.append(dict(r))
return items, int(module_id)
@router.get("/training-modules")
def list_training_modules(tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
vis_clause, vis_params = library_content_visibility_sql(
alias="m",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
cur.execute(
f"""
SELECT m.*,
(SELECT COUNT(*) FROM training_module_items i WHERE i.module_id = m.id)
AS items_count
FROM training_modules m
WHERE ({vis_clause})
ORDER BY m.updated_at DESC NULLS LAST, m.title
""",
vis_params,
)
return [r2d(r) for r in cur.fetchall()]
@router.get("/training-modules/{module_id}")
def get_training_module(module_id: int, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
row = _module_access(cur, module_id, profile_id, role)
cur.execute(
"""
SELECT *
FROM training_module_items
WHERE module_id = %s
ORDER BY order_index ASC
""",
(module_id,),
)
row["items"] = [r2d(r) for r in cur.fetchall()]
return row
@router.post("/training-modules")
def create_training_module(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
role = tenant.global_role
if not _has_planning_role(role):
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Trainingsmodule anlegen")
title = (data.get("title") or "").strip()
if not title:
raise HTTPException(status_code=400, detail="title ist Pflicht")
vis_raw = data.get("visibility")
visibility = (vis_raw if isinstance(vis_raw, str) else "club").strip() or "club"
club_id = data.get("club_id")
if club_id in ("", []):
club_id = None
if visibility == "club" and club_id is None:
club_id = tenant.effective_club_id
primary_method_id = data.get("primary_method_id")
if primary_method_id in ("", []):
primary_method_id = None
if primary_method_id is not None:
primary_method_id = int(primary_method_id)
items_in = data.get("items") or []
with get_db() as conn:
cur = get_cursor(conn)
assert_valid_governance_visibility(cur, profile_id, role, visibility, club_id)
if primary_method_id is not None:
cur.execute("SELECT 1 FROM training_methods WHERE id = %s", (primary_method_id,))
if not cur.fetchone():
raise HTTPException(status_code=400, detail="Trainingsmethode nicht gefunden")
cur.execute(
"""
INSERT INTO training_modules (
club_id, created_by, title, summary, goal,
recommended_duration_min, target_group_notes, deployment_context_notes,
primary_method_id, visibility
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
club_id,
profile_id,
title,
(data.get("summary") or "").strip() or None,
data.get("goal"),
data.get("recommended_duration_min"),
data.get("target_group_notes"),
data.get("deployment_context_notes"),
primary_method_id,
visibility,
),
)
mid = cur.fetchone()["id"]
_replace_module_items(cur, mid, items_in if isinstance(items_in, list) else [])
conn.commit()
return get_training_module(mid, tenant)
@router.put("/training-modules/{module_id}")
def update_training_module(
module_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)
):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
row_prev = _fetch_training_module_row(cur, module_id)
_module_assert_writable(cur, row_prev, profile_id, role)
merged_vis = row_prev.get("visibility") or "club"
merged_club = row_prev.get("club_id")
if "visibility" in data:
v_in = data.get("visibility")
if not isinstance(v_in, str) or v_in not in ("private", "club", "official"):
raise HTTPException(status_code=400, detail="visibility ungültig")
merged_vis = v_in
if "club_id" in data:
merged_club = data.get("club_id")
if merged_club in ("", []):
merged_club = None
if "visibility" in data or "club_id" in data:
assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club)
fields: List[str] = []
params: List[Any] = []
if "title" in data:
t = data.get("title")
t = t.strip() if isinstance(t, str) else ""
if not t:
raise HTTPException(status_code=400, detail="title ist Pflicht")
fields.append("title = %s")
params.append(t)
for col in ("summary", "goal", "target_group_notes", "deployment_context_notes"):
if col in data:
fields.append(f"{col} = %s")
v = data.get(col)
if col == "summary" and isinstance(v, str):
v = v.strip() or None
params.append(v)
if "recommended_duration_min" in data:
fields.append("recommended_duration_min = %s")
params.append(data.get("recommended_duration_min"))
if "primary_method_id" in data:
pm = data.get("primary_method_id")
if pm in ("", [], None):
fields.append("primary_method_id = %s")
params.append(None)
else:
pm = int(pm)
cur.execute("SELECT 1 FROM training_methods WHERE id = %s", (pm,))
if not cur.fetchone():
raise HTTPException(status_code=400, detail="Trainingsmethode nicht gefunden")
fields.append("primary_method_id = %s")
params.append(pm)
if "club_id" in data:
fields.append("club_id = %s")
params.append(merged_club)
if "visibility" in data:
fields.append("visibility = %s")
params.append(merged_vis)
if fields:
fields.append("updated_at = NOW()")
params.append(module_id)
cur.execute(
f"UPDATE training_modules SET {', '.join(fields)} WHERE id = %s",
tuple(params),
)
if "items" in data:
items_in = data["items"]
_replace_module_items(cur, module_id, items_in if isinstance(items_in, list) else [])
conn.commit()
return get_training_module(module_id, tenant)
@router.delete("/training-modules/{module_id}")
def delete_training_module(module_id: int, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
row_del = _fetch_training_module_row(cur, module_id)
_module_assert_writable(cur, row_del, profile_id, role)
cur.execute("DELETE FROM training_modules WHERE id = %s", (module_id,))
conn.commit()
return {"ok": True}

View File

@ -8,6 +8,7 @@ from datetime import date, timedelta
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from psycopg2.extras import Json as PsycopgJson
from db import get_db, get_cursor, r2d
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
@ -17,6 +18,9 @@ from club_tenancy import (
is_platform_admin,
library_content_visible_to_profile,
)
from routers.training_modules import load_training_module_for_apply
from routers.exercises import load_combination_slots_for_exercise
router = APIRouter(prefix="/api", tags=["training_planning"])
@ -39,12 +43,28 @@ def _optional_positive_int(val, field_name: str) -> Optional[int]:
def _validate_variant_for_exercise(cur, exercise_id: Optional[int], variant_id: Optional[int]):
if not exercise_id:
if variant_id:
raise HTTPException(
status_code=400, detail="exercise_variant_id nur zusammen mit exercise_id erlaubt"
)
return
cur.execute(
"SELECT COALESCE(exercise_kind, 'simple') AS exercise_kind FROM exercises WHERE id = %s",
(int(exercise_id),),
)
ek_row = cur.fetchone()
if not ek_row:
raise HTTPException(status_code=400, detail="Übung nicht gefunden")
if str(r2d(ek_row).get("exercise_kind") or "simple").strip().lower() == "combination":
if variant_id:
raise HTTPException(
status_code=400,
detail="Kombinationsübungen haben keine Varianten — bitte exercise_variant_id weglassen",
)
return
if not variant_id:
return
if not exercise_id:
raise HTTPException(
status_code=400, detail="exercise_variant_id nur zusammen mit exercise_id erlaubt"
)
cur.execute(
"SELECT 1 FROM exercise_variants WHERE id = %s AND exercise_id = %s",
(variant_id, exercise_id),
@ -390,6 +410,18 @@ def _normalize_assistant_trainer_profile_ids(
)
return uniq
def _normalize_planning_method_profile_payload(raw) -> Optional[Dict[str, Any]]:
"""None = Katalog wirksam; Dict = Snapshot fuer diese Platzierung."""
if raw is None:
return None
if isinstance(raw, dict):
return dict(raw)
raise HTTPException(
status_code=400,
detail="planning_method_profile muss ein JSON-Objekt oder null 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
@ -403,6 +435,19 @@ _ORIGIN_LINEAGE_FIELDS = """
"""
def _optional_source_training_module_id_payload(raw_val) -> Optional[int]:
"""Erlaubt None; sonst positives int (FK-Verletzung bei ungültigem Modul möglich)."""
if raw_val is None or raw_val == "":
return None
try:
i = int(raw_val)
except (TypeError, ValueError):
return None
if i < 1:
return None
return i
def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
cur.execute(
"""
@ -420,7 +465,10 @@ def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
"""
SELECT tusi.*,
e.title AS exercise_title,
e.exercise_kind AS exercise_kind,
e.summary AS exercise_summary,
e.method_archetype AS catalog_method_archetype,
e.method_profile AS catalog_method_profile,
(
SELECT fa.name FROM exercise_focus_areas efa
JOIN focus_areas fa ON fa.id = efa.focus_area_id
@ -428,16 +476,34 @@ def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
LIMIT 1
) AS exercise_focus_area,
ev.variant_name AS exercise_variant_name
ev.variant_name AS exercise_variant_name,
tm.title AS source_module_title
FROM training_unit_section_items tusi
LEFT JOIN exercises e ON tusi.exercise_id = e.id
LEFT JOIN exercise_variants ev ON tusi.exercise_variant_id = ev.id
LEFT JOIN training_modules tm ON tm.id = tusi.source_training_module_id
WHERE tusi.section_id = %s
ORDER BY tusi.order_index
""",
(sec["id"],),
)
sec["items"] = [r2d(r) for r in cur.fetchall()]
for it in sec["items"]:
if it.get("item_type") != "exercise":
continue
cmp_raw = it.get("catalog_method_profile")
if not isinstance(cmp_raw, dict):
it["catalog_method_profile"] = {}
else:
it["catalog_method_profile"] = dict(cmp_raw)
ek = str(it.get("exercise_kind") or "simple").strip().lower()
if ek == "combination" and it.get("exercise_id"):
try:
it["combination_slots"] = load_combination_slots_for_exercise(cur, int(it["exercise_id"]))
except (TypeError, ValueError):
it["combination_slots"] = []
else:
it["combination_slots"] = []
secs.append(sec)
return secs
@ -452,28 +518,33 @@ def _sections_clone_payload(cur, unit_id: int) -> List[Dict[str, Any]]:
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 "",
}
)
note_item = {
"item_type": "note",
"order_index": oix,
"note_body": it.get("note_body") or "",
}
sm = _optional_source_training_module_id_payload(it.get("source_training_module_id"))
if sm is not None:
note_item["source_training_module_id"] = sm
items_clean.append(note_item)
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"),
}
)
ex_item = {
"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"),
"planning_method_profile": it.get("planning_method_profile"),
}
sm = _optional_source_training_module_id_payload(it.get("source_training_module_id"))
if sm is not None:
ex_item["source_training_module_id"] = sm
items_clean.append(ex_item)
out.append(
{
"title": sec.get("title"),
@ -568,6 +639,94 @@ def _hydrate_training_unit_payload(cur, unit: Dict[str, Any]) -> Dict[str, Any]:
return unit
def _resolve_training_unit_section_id(cur, unit_id: int, section_order_index: int) -> int:
cur.execute(
"""
SELECT id FROM training_unit_sections
WHERE training_unit_id = %s AND order_index = %s
""",
(unit_id, section_order_index),
)
r = cur.fetchone()
if not r:
raise HTTPException(
status_code=400, detail="Abschnitt für diese Reihenfolge nicht gefunden"
)
return int(r["id"])
def _append_copied_module_items_to_section(
cur,
section_id: int,
module_items: List[Dict[str, Any]],
source_training_module_id: int,
) -> None:
"""Hängt kopierte ModulItems ans Ende eines Abschnitts (section_order_index in API)."""
cur.execute(
"""
SELECT COALESCE(MAX(order_index), -1) AS mo
FROM training_unit_section_items
WHERE section_id = %s
""",
(section_id,),
)
row = cur.fetchone()
start = int(row["mo"]) + 1 if row and row["mo"] is not None else 0
for i, mi in enumerate(module_items):
oi = start + i
itype = mi.get("item_type")
if itype == "note":
body = mi.get("note_body")
if body is None:
body = ""
cur.execute(
"""
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, source_training_module_id
) VALUES (%s, %s, 'note',
NULL, NULL, NULL, NULL, NULL, NULL, %s, %s)
""",
(section_id, oi, body, source_training_module_id),
)
continue
eid = mi.get("exercise_id")
if not eid:
continue
eid = int(eid)
vid = mi.get("exercise_variant_id")
if vid is not None:
vid = int(vid)
else:
vid = None
_validate_variant_for_exercise(cur, eid, vid)
cur.execute(
"""
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,
source_training_module_id, planning_method_profile
) VALUES (%s, %s, 'exercise',
%s, %s, %s, NULL, %s, NULL, NULL, %s, NULL)
""",
(
section_id,
oi,
eid,
vid,
mi.get("planned_duration_min"),
mi.get("notes"),
source_training_module_id,
),
)
def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], start_order: int = 0):
if items_in is None:
items_in = []
@ -582,18 +741,19 @@ def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], s
body = raw.get("note_body")
if body is None:
body = ""
src_mod = _optional_source_training_module_id_payload(raw.get("source_training_module_id"))
cur.execute(
"""
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
notes, modifications, note_body, source_training_module_id
) VALUES (%s, %s, 'note',
NULL, NULL, NULL, NULL, NULL, NULL, %s
NULL, NULL, NULL, NULL, NULL, NULL, %s, %s
)
""",
(section_id, order_ix, body),
(section_id, order_ix, body, src_mod),
)
continue
@ -603,16 +763,27 @@ def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], s
eid = int(eid)
vid = _optional_positive_int(raw.get("exercise_variant_id"), "exercise_variant_id")
_validate_variant_for_exercise(cur, eid, vid)
cur.execute(
"""SELECT COALESCE(exercise_kind, 'simple') AS k FROM exercises WHERE id = %s""",
(eid,),
)
er = cur.fetchone()
ek = str(er["k"] if er and er.get("k") is not None else "simple").strip().lower()
planning_mp = _normalize_planning_method_profile_payload(raw.get("planning_method_profile"))
if ek != "combination":
planning_mp = None
planning_sql_val = PsycopgJson(planning_mp) if planning_mp is not None else None
src_mod = _optional_source_training_module_id_payload(raw.get("source_training_module_id"))
cur.execute(
"""
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
notes, modifications, note_body,
source_training_module_id, planning_method_profile
) VALUES (%s, %s, 'exercise',
%s, %s, %s, %s, %s, %s, NULL
)
%s, %s, %s, %s, %s, %s, NULL, %s, %s)
""",
(
section_id,
@ -623,6 +794,8 @@ def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], s
raw.get("actual_duration_min"),
raw.get("notes"),
raw.get("modifications"),
src_mod,
planning_sql_val,
),
)
@ -1443,6 +1616,46 @@ def get_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_c
return unit
@router.post("/training-units/{unit_id}/apply-training-module")
def apply_training_module_to_training_unit(
unit_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)
):
"""Kopiert die Positionen eines Trainingsmoduls ans Ende eines Abschnitts (lokal bearbeitbar)."""
profile_id = tenant.profile_id
role = tenant.global_role
if not _has_planning_role(role):
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Module übernehmen")
module_id_raw = data.get("module_id")
if module_id_raw is None or module_id_raw == "":
raise HTTPException(status_code=400, detail="module_id ist Pflicht")
try:
module_id = int(module_id_raw)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="module_id ungültig")
soy = data.get("section_order_index")
try:
section_order_index = int(soy)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="section_order_index ist Pflicht (Ganzzahl)")
if section_order_index < 0:
raise HTTPException(status_code=400, detail="section_order_index ungültig")
with get_db() as conn:
cur = get_cursor(conn)
unit_row = _training_unit_guard_row(cur, unit_id)
_assert_training_unit_permission(cur, unit_row, profile_id, role)
section_id = _resolve_training_unit_section_id(cur, unit_id, section_order_index)
mod_items, src_mid = load_training_module_for_apply(cur, module_id, profile_id, role)
_append_copied_module_items_to_section(cur, section_id, mod_items, src_mid)
_promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role)
conn.commit()
return get_training_unit(unit_id, tenant)
@router.post("/training-units")
def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id

View File

@ -1,13 +1,13 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.94"
BUILD_DATE = "2026-05-11"
DB_SCHEMA_VERSION = "20260511053"
APP_VERSION = "0.8.110"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260512057"
MODULE_VERSIONS = {
"legal_documents": "1.2.0", # jsPDF-Download auf LegalPage (oeffentlich) + Admin; Abschnitts-Sortierung/-Einfuegen
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
"auth": "1.2.3", # P-05b: reset-password min_length=8 via Pydantic PasswordResetConfirm
"profiles": "1.7.0", # exercise_list_prefs JSONB (Standard Übungsfilter); Patch via ProfileUpdate + Json()
"profiles": "1.8.0", # training_planning_prefs JSONB (Planungs-UI); Patch via ProfileUpdate + Json(), Migration 055
"tenant_context": "1.0.5", # Plattform-Admin: effective_club ohne Header aus Profil active_club_id wenn Verein existiert
"clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext
"club_memberships": "1.0.1", # Depends(get_tenant_context)
@ -21,10 +21,11 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
"exercises": "2.23.0", # P-11: enrich_exercise_detail + download_file blocken Legal-Hold-Assets (451)
"exercises": "2.27.3", # load_combination_slots_for_exercise (gemeinsam mit GET Übung); Hydrate für Planung
"training_units": "0.2.0",
"training_programs": "0.1.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
"planning": "0.9.3", # GET training-units/:id Sektions-Items: combination_slots + Kandidaten-Titel für Druck/Run
"training_modules": "1.0.0",
"import_wiki": "1.0.0",
"admin": "1.0.0",
"membership": "1.0.0",
@ -34,6 +35,121 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.110",
"date": "2026-05-12",
"changes": [
"GET /api/training-units/:id: Bei Kombinationsübungen werden `combination_slots` inkl. Kandidaten-Titel mitgeliefert (für Plan & Ablauf / Druck).",
"Hilfsfunktion `load_combination_slots_for_exercise` im exercises-Router; GET Übung nutzt dieselbe Ladelogik.",
],
},
{
"version": "0.8.109",
"date": "2026-05-12",
"changes": [
"Kombination: „Serien“ standardmäßig 1 (Formular/API); Archetyp kann via `ARCHETYPE_DEFAULT_REP_SERIES_COUNT` andere Vorgaben setzen; ProfilEditor zeigt Fallback.",
],
},
{
"version": "0.8.108",
"date": "2026-05-12",
"changes": [
"Kombination rep/manual: Feld „Pause zw. Serien“ nur ab 2 Serien sichtbar und speicherbar; Hinweis unterscheidet Wechsel zur nächsten Station; API verwirft intra_rep_rest_sec bei nur einer Serie.",
],
},
{
"version": "0.8.107",
"date": "2026-05-12",
"changes": [
"Kombination Wiederholungsziel: `rep_series_count` in `slot_profiles_v1` (mehrere Serien à ZielWdh.); Formular und ProfilEditorFelder; Pause als „zwischen Serien“ beschriftet; CoachZusammenfassung angepasst.",
],
},
{
"version": "0.8.106",
"date": "2026-05-12",
"changes": [
"Kombinationsübung: Stationssteuerung `advance_mode` in `slot_profiles_v1` (zeitlich / ZielWiederholungen / Coach ohne Arbeitsuhr); Übungsformular + PlanungsProfilEditor; APIPayload verwirft ArbeitSekunden außer bei Zeitmodus; Coach zeigt verkürzte Planzeile je Station.",
],
},
{
"version": "0.8.105",
"date": "2026-05-12",
"changes": [
"Übungsbearbeitung Kombi: Stationen mit Pool per Modal (nur Einzelübungen), Zeiten pro Station in derselben Karte, Drag&Drop + Pfeile statt Index; API schreibt slot_index aus Reihenfolge; Gesamtdurchläufe bei Zirkel/Sequenz/Parcours/Parallel klar beschriftet.",
],
},
{
"version": "0.8.104",
"date": "2026-05-12",
"changes": [
"KombinationsAblaufprofil UX: Stationszeilen (slot_profiles_v1); Schnellwahlen Arbeit↔Pause (Zirkel + Intervall); PlanungsOverride ohne JSON; Übungsformular: Reihenfolge Stationen dann Ablaufprofil.",
"Arbeitspapier: `.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md`.",
],
},
{
"version": "0.8.103",
"date": "2026-05-12",
"changes": [
"Trainingsplanung: bei Kombinationszeilen optionales `planning_method_profile` (Migration 057); Planungs-Editor mit Ablaufprofil-Details, „wie Katalog“ / „aus Katalog kopieren“; Payload/Coach-PUT übernehmen Snapshot.",
],
},
{
"version": "0.8.102",
"date": "2026-05-12",
"changes": [
"Kombinationsübung beim Anlegen/Bearbeiten: archetypbezogenes Ablaufprofil (geführt) + eingeklappt Roh-JSON (`CombinationMethodProfileEditor`); Schlüsselmanifest `combinationMethodProfileUi.js`. Coach: angelegtes method_profile unter Stationenliste lesbar.",
],
},
{
"version": "0.8.101",
"date": "2026-05-12",
"changes": [
"Training-Coach bei Kombinationsübungen: Stationen/Kandidaten mit geladenem Katalog (Kurzbeschreibung, aufklappbar Ablauf/Trainerhinweise); Archetyp-spezifischer Coach-Hilfstext; Archetyp-Labels aus `combinationArchetypes.js`.",
],
},
{
"version": "0.8.100",
"date": "2026-05-12",
"changes": [
"Planungs-API/UI: Kombinationsübungen in Trainingsseinheiten (exercise_kind in Sektions-Responses; PATCH verbietet exercise_variant_id für combination); ExercisePicker ohne simple-only Filter, Badge Kombination.",
],
},
{
"version": "0.8.99",
"date": "2026-05-12",
"changes": [
"exercises Phase 2 (Kombinationsübungen): Migration 056 (`exercise_kind`, `method_archetype`, `method_profile`; Tabellen Slots/Kandidaten); CRUD über POST/PUT/GET Übung mit `combination_slots`; Liste-Filter `exercise_kind_any`; Varianten-Endpoints verbieten `exercise_kind=combination`.",
],
},
{
"version": "0.8.98",
"date": "2026-05-12",
"changes": [
"profiles: `training_planning_prefs` JSONB (Migration 055), Patch via PUT Profil — z.B. Darstellung kopierter Trainingsmodule in der Planungs-UI (nutzerspezifisch).",
],
},
{
"version": "0.8.97",
"date": "2026-05-12",
"changes": [
"Trainingsmodule (Phase 1): Bibliothek `training_modules` + `training_module_items` (Migration 054); REST `/api/training-modules`; Übernahme in Einheiten per `POST /api/training-units/{id}/apply-training-module`; Herkunft `source_training_module_id` auf kopierten Sektions-Items; UI unter /planning/training-modules und Übernahme-Dialog in der Trainingsplanung.",
"Umsetzungsplan: `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md`.",
],
},
{
"version": "0.8.96",
"date": "2026-05-12",
"changes": [
"P-01 Admin Rechtstexte: Live-Vorschau je Abschnitt (Markdown) neben dem Editor; modale „Vollständige Vorschau“ aus dem Formular; Augen-Symbol in der Dokumentenliste für die gerenderte Ansicht (API-Laden).",
],
},
{
"version": "0.8.95",
"date": "2026-05-12",
"changes": [
"P-01 Rechtstexte: Abschnitte in der Ausgabe mit fortlaufender §1, §2, … (nur Darstellung/PDF, nicht in der DB); Fließtext mit Markdown (react-markdown) inkl. PDF-Rendering (fett/kursiv, Listen, Links, Codeblöcke).",
],
},
{
"version": "0.8.94",
"date": "2026-05-11",

View File

@ -0,0 +1,130 @@
# Shinkan Jinkendo Fachliche Nutzerfunktionen (Ist-Stand)
**Zweck:** Überblick über die **wesentlichen, produktiv nutzbaren Funktionen** aus Nutzer- und Fachperspektive zur Weitergabe an Design, Product Discovery oder externe Fachplanung.
**Technischer Detailstand:** App-Version und Schema siehe `backend/version.py` (Stand Code: **0.8.101**, **DB_SCHEMA_VERSION** siehe dort).
**Vertiefung:** Domänenmodell `.claude/docs/functional/DOMAIN_MODEL.md`, Lieferdetal `.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md`, Projektstatus `.claude/docs/PROJECT_STATUS.md`, Entwickler-Handover `docs/HANDOVER.md`.
---
## 1. Produktauftrag und Zielgruppe
**Shinkan Jinkendo** ist eine **Web-Applikation für Trainer, Vereinsadmins und Inhaltsverantwortliche** in der Kampfsport- und Trainingsplanung: zentrale Übungs- und Methodenverwaltung, strukturierte **Trainingsplanung für Gruppen**, wiederverwendbare **Rahmenprogramme**, sowie **Governance** von Inhalten (Sichtbarkeit, Vereinszuordnung, Plattform-Inhalte).
**Explizit keine persönliche Sportler-App:** Es geht nicht um individuelles Leistungstracking von Endnutzern oder um ein Athleten-Tagebuch; der Fokus liegt auf **vereinlicher/trainersicher Organisation von Wissen und Ablaufplänen**.
---
## 2. Rollen (vereinfachte Nutzerbilder)
Die sichtbaren Funktionen hängen von **Rolle** und **Kontext** ab (eingeloggter Nutzer, aktiver Verein, Plattform- vs. Vereins-Admin).
| Rollenprofil (fachlich) | Typische Aufgaben in der App |
|-------------------------|------------------------------|
| **Trainer / Redakteur** | Übungen anlegen und pflegen, medienreich beschreiben, filtern/suchen; Trainingseinheiten für Gruppen planen; Rahmenprogramme nutzen oder mitgestalten (je nach Berechtigung); Medienbibliothek nutzen. |
| **Vereinsadmin** | Vereinsdaten, Mitgliedschaften, ggf. vereinsgebundene Inhalte und Medien; kann je nach Implementierung **Inhaltsmeldungen zu Vereinsmedien** bearbeiten und **Legal Hold** für Vereinsmedien auslösen. |
| **Plattform-Admin** | Globale Kataloge, Hierarchien, Importe, Nutzerverwaltung (soweit freigeschaltet); **Posteingang** inkl. organisationsbezogener Meldungen; Reifegradmodelle / Matrix-Stack. |
| **Superadmin** | Stärkste technische Rolle: u. a. **offizielle Plattform-Inhalte** (`official`), tiefe Medien-Lifecycle-Operationen, ausgewählte Hochrisiko-Aktionen (z. B. bestimmte Legal-Hold-Fälle). |
**Aktiver Verein:** Nutzer mit Vereinsbezug arbeiten oft im Kontext eines **gewählten aktiven Vereins** (Profil, API-Header); das beeinflusst Sichtbarkeit von Inhalten und Mandantenlogik.
---
## 3. Hauptnavigation (Nutzerpfade)
Über die **Hauptnavigation** (mobil und Desktop) sind u. a. erreichbar:
- **Übersicht** Einstieg / Dashboard.
- **Posteingang** für berechtigte Nutzer: **Änderungs- und Organisationsanfragen** sowie **Inhaltsmeldungen** (Workflow, Status, Archiv).
- **Übungen** Katalogarbeit, Suche, Filter, Detail, Bearbeitung; **Progressionsgraphen** zwischen Übungen; **Fähigkeiten** (Skills) als verknüpfte Dimension.
- **Planung** Kalender-/Listenlogik für **Trainingseinheiten** (Sektionen, Übungen, optional **Übungsvarianten**); **Trainingsrahmen (Bibliothek)** mit Zielen und Slots; **Durchführungsansicht** und **Coaching-Modus** pro Einheit (je Route).
- **Medien** zentrale **Medienbibliothek** (Filter, Suche, Tags, Lifecycle, Copyright-Hinweise; rollenabhängige Bearbeitung).
- **Vereine** Organisation: Vereine, Struktur, Gruppen (soweit für den Nutzer freigeschaltet).
- **Einstellungen** Profil, Systeminfos, ggf. Rechtstexte; **Trainer-Kontexte** separat (Route `trainer-contexts`).
- **Admin** (nur Admin-Rolle) Plattformbereich: Nutzer, Hierarchie/Kataloge, Reifegradmodelle, MediaWiki-Import, **Rechtstexte/P-01** u. a.
Öffentlich bzw. ohne volle App: **Impressum, Datenschutz, Nutzungsbedingungen, Medienrichtlinie**; Login/Registrierung/Verifizierung.
---
## 4. Funktionsblöcke im Detail (fachlich)
### 4.1 Übungen (Kernobjekt)
- **Anlegen, Bearbeiten, Archivieren/Löschen** je nach Rolle und Sichtbarkeit.
- **Mehrdimensionale Einordnung:** Fokusbereiche, Stilrichtungen, Trainingsstile, Zielgruppen, **Fähigkeiten mit Stufen**; Suche und Filter über diese Dimensionen.
- **Übungsvarianten:** mehrere Ausprägungen einer Übung (z. B. Aufbau, Schwierigkeit, Material), mit Reihenfolge und optionaler **Voraussetzungsvariante**.
- **Progressionsgraph:** gerichtete Beziehungen **von Übung zu Übung** (und Variantenbezug), Pflege in der Übungswelt; unterstützt didaktische „weiter“-Ketten.
- **Medien an der Übung:** Upload, Einbettung, Verknüpfung aus dem **Archiv**; Darstellung in Detail- und Bearbeitungsansicht.
- **Rich-Text-Felder** (Ablauf, Ziele, Hinweise): **Inline-Verweise auf verknüpfte Medien** über eine einheitliche Platzhalter-/Renderlogik (konsistent mit Archiv-Governance).
- **Exercise Blocks** („Bausteine“) und gespeicherte Suchpräferenzen, wo implementiert.
- **Kombinationsübungen** („combination“, Migration 056): Sonderform im Übungskatalog mit **Stationen/Slots**, **Trainingsmethode-/Archetyp** (`method_archetype`), optionalem strukturierten **Ablaufprofil** (`method_profile`). In der Planung wie eine Übung ohne Variante einsetzbar; im **Coach** werden Stations-Kandidaten und Archetyp-Hilfstexte angezeigt (Ausbauschritte B/C nach Fachspez §10.4 dokumentiert unter `.claude/docs/functional/… Kombinationsübungen Spezifikation V2.md` Anhang A).
### 4.2 Fähigkeiten, Methoden, Kataloge
- **Globaler Fähigkeitskatalog** mit hierarchischer Struktur (Kategorien, Stufen); Zuordnung zu Übungen.
- **Trainingsmethoden-Katalog** (bestehende Domäne).
- **Admin/Katalog-Pflege** für Fokusbereiche, Stile, Zielgruppen und Zusammenhänge (Plattform-Admin-Bereich).
### 4.3 Reifegradmodelle (Fähigkeitsmatrix)
- **Matrixbasierte Modelle** mit Stufen und Zelltexten; **kontextsensitive Auflösung** (Fokus, optional Stilrichtung, Trainingsart) über Bindings.
- **Export/Import** einzelner Modelle und **Komplett-Stack** (Admin-Werkzeuge) für Übertrag zwischen Umgebungen oder Backup.
### 4.4 Trainingsplanung
- **Trainingseinheiten** als planbare Objekte mit **Sektionen** und **Einträgen** (Übungen, ggf. mit **Variante** und Metadaten wie Dauer).
- **Trainingsvorlagen / Mikrovorlagen** (wo eingerichtet): Struktur wiederverwenden.
- **Trainingsrahmenprogramm (Bibliothek):** übergeordnete Programme mit **Zielen** und **Slots**; Slot-Inhalt technisch als **Blueprint-Trainingsunit** abgebildet.
- **Materialisierung:** aus einem Rahmen-Slot kann eine **konkrete Kalender-Einheit** für eine Gruppe erzeugt werden (API vorhanden; UI-Anbindung kann erweitert werden).
- **Durchführung:** Ansicht zum Abarbeiten einer Einheit; **Coaching-Modus** als separater Erlebnispfad (generischer Zeit-Block pro Platzierung); bei **Kombinationsübungen** zusätzliche **Stations-/Kandidaten-Schicht und Archetyp-Hinweise** siehe Kombination-Fachspez **Anhang A** (implementierter Umfang vs. nächste Stufen).
### 4.5 Medienbibliothek und Archiv
- **Zentrale Medienverwaltung:** Suche, Filter (u. a. Lifecycle, Medientyp, Verein für Admins), Tags, Copyright-Felder.
- **Lifecycle:** aktive Nutzung, Papierkorb-Stufen, Wiederherstellung; endgültiges Entfernen stark eingeschränkt (Superadmin-Kontext).
- **Governance:** Sichtbarkeit (z. B. privat, vereinsbezogen, **official**); **official** ist fachlich „Plattform offiziell“ und an **Superadmin** gebunden.
- **Rechtliche Sofortmaßnahme:** **Legal Hold** kann Medien vor automatisiertem Lifecycle schützen (Fälle aus Meldungen oder Admin-Prozessen).
### 4.6 Organisation und Mitgliedschaft
- **Vereine (Clubs)** mit Struktur (Sparten/Divisions, Trainingsgruppen) je nach Ausprägung.
- **Beitritts- und Mitgliedschaftslogik** (Requests, Rollen) für mandantenfähige Zusammenarbeit.
### 4.7 Governance von Übungsinhalten
- **Änderungsanfragen** (Content Change Requests) für vorgeschlagene Änderungen an Inhalten Einreichung und Bearbeitung über Posteingang/Admin-Prozesse (Detailtiefe siehe Fachdoku).
- **Sichtbarkeits- und Statusmodelle** für Übungen (Entwurf, veröffentlicht, archiviert konkrete Werte siehe Datenmodell).
### 4.8 Inhaltsmeldungen (P-13, vertrauens- und compliance-orientiert)
- **Melden** von problematischen Inhalten (auch aus Medien- und Übungskontexten; **official**-Medien teils ohne Login meldbar).
- **Posteingang für Admins:** Eingang neuer Meldungen, **Statusworkflow** (z. B. eingereicht, in Prüfung, erledigt/abgelehnt), Notizen, **Wiedereröffnen**; getrennte Darstellung abgeschlossener Fälle (Archiv).
- **Priorisierung** bei sensiblen Kategorien (Minderjährige, illegaler Inhalt, Jugendschutz fachlich automatisch höher gewichtet).
- Anbindung an **Legal Hold** und Audit-Spuren im Medien-Journal wo vorgesehen.
### 4.9 Import und Plattform-Werkzeuge
- **MediaWiki-basierter Import** von Übungsinhalten mit Tracking und Duplikat-Referenzen (Admin).
- **Plattformnutzerverwaltung** und **Rechtstexte** mit editorseitiger und listenbasierter Vorschau (Markdown, strukturierte Ausgabe inkl. PDF-Darstellung siehe technische Versionsnotizen).
---
## 5. Bekannte Lücken und Planungshinweise (kurz)
Nicht als „broken“ gemeint, sondern als **typische nächste Ausbaustellen** für Product/Design:
- Kalender-UX: **„Aus Rahmen übernehmen“** flächendeckend und ggf. bulkfähig anbinden.
- **Policies** für geteilte Rahmen (Wer darf Bibliotheks-Rahmen sehen/kopieren?).
- **Skill-Kategorie-Admin-UI**, **Dark Mode/Responsive/PWA-Ausbau**, **KI-Suche** über Volltext hinaus je nach Backlog.
- **Coach / Kombination:** nächste Stufen **Zeitleisten-Splitting** und **Archetyp-Timer** (Fachspez §10.4 Stufe B/C; Umsetzungsplan Phase 4bd); **geführtes Erfassen** von `method_profile` im Übungseditor.
---
## 6. Änderungshistorie dieser Zusammenfassung
| Datum | Änderung |
|-------|----------|
| 2026-05-12 | Erstfassung für Übergabe an fachliches Design; Abgleich mit Code-Navigation, `version.py`, `HANDOVER.md`, `FEATURES_DELIVERED`, `DOMAIN_MODEL`. |
| 2026-05-12 | Kombinationsübungen + Coaching Stufe A; Verweise auf Fachspezifikation (`…Kombinationsuebungen…` V3 Anhang A) und `TRAINING_MODULES_IMPLEMENTATION_PLAN.md`. |

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-11
**App-Version / DB-Schema:** App **0.8.94**, DB-Schema siehe `backend/version.py` (`DB_SCHEMA_VERSION`)
**Stand:** 2026-05-12
**App-Version / DB-Schema:** App **0.8.101**, DB-Schema siehe `backend/version.py` (`DB_SCHEMA_VERSION`)
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
@ -29,9 +29,12 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
| Zugriffsschicht, Mandant, Governance | `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` |
| Tenant-Endpoints (Audit) | `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md` |
| Rahmenprogramm · Planung | `.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md` |
| **Trainingsmodule & Kombinationsübungen (Fachspez, Drift-Schutz)** | `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md`10.2.1 Archetyp-IDs, §10.4 Coaching-Stufen, **Anhang A** Code-Abgleich) |
| **Umsetzungsplan** (Module/Kombination/Coach) | `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` |
| Überblick DB | `.claude/docs/technical/DATABASE_SCHEMA.md` |
| Domäne | `.claude/docs/functional/DOMAIN_MODEL.md` |
| **Lieferliste inkl. Medien** | `.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md` §12 |
| **Fachlicher Nutzerüberblick (Design/Product)** | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** |
---
@ -71,6 +74,12 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
- **036 / 037:** Bibliotheks-Rahmen, Slot-Inhalt als **`training_units`** mit **`framework_slot_id`**; **`POST /api/training-units/from-framework-slot`**.
- **Code:** `training_framework_programs.py`, `training_planning.py`; Frontend **`TrainingFrameworkProgramEditPage.jsx`**, **`createTrainingUnitFromFrameworkSlot`** in `api.js`.
### Trainingsmodule, Kombinationsübungen und Coach (Stand ~0.8.101)
- **Fachspez & Drift-Schutz:** `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (**§10.2.1** IDs, **§10.4** Coaching-Stufen, **Anhang A** Abgleich).
- **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4ad**).
- **Ist kurz:** Trainingsmodule-Bibliothek (Phase **1**) umgesetzt; Kombi-Katalog (**056**) + Einplanung + Coach **Stufe A** (`CombinationCoachSlots`, `combinationArchetypes.js`). Coach **Stufe B/C** und geführtes **`method_profile`** offen — siehe Fachspez Anhang A.
---
## 4. Stand: Medien-Management (Ist, 2026-05-07)
@ -105,21 +114,15 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
---
## 5. Geplant: Inline-Medienverlinkung (nicht umgesetzt)
## 5. Inline-Medien im Fließtext (Spec Abschnitt 11 — umgesetzt)
**Ziel:** Mediendarstellung **innerhalb** von Fließtext-Feldern (Ablauf, Ziele, Trainerhinweise), konsistent mit derselben **`exercise_media`** bzw. Asset-Governance wie die Medienliste.
**Ist:** Platzhalter-Syntax, zentraler Render-Pfad, Modal-Picker, Größenwahl, Drag-and-Drop in den Übungstextfeldern — siehe **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`** (Abschnitt 11), **`FEATURES_DELIVERED_2026-Q2.md`** Abschnitt 12.3 und **`PROJECT_STATUS.md`**.
**Norm:** **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §11** — u.a.:
- Verweis auf **`exercise_media.id`** (oder kanonisch übersetzte Markup-Syntax), **keine** zweite Sichtbarkeitslogik.
- **Ein** zentraler Render-/Sanitize-Pfad für Übungstexte; keine verstreuten „roh `dangerouslySetInnerHTML`“-Pfade.
- XSS/CSP: nur Allowlist-HTML und kontrollierte Player-Komponenten.
**Reihenfolge:** Archiv & aktuelle Governance gelten als Basis; Inline ist die **nächste** inhaltliche Ausbaustufe für Medien (siehe **`PROJECT_STATUS.md`** Nächste Schritte).
**Weiteres:** UX-Politik, ggf. strategische Vereinheitlichung der Referenzmodellierung (reine Asset-Referenz vs. `exercise_media`) — siehe Nächste Schritte in **`PROJECT_STATUS.md`**.
---
## 5b. P-13: Content-Meldeverfahren (vollständig implementiert, 2026-05-11)
## 6. P-13: Content-Meldeverfahren (vollständig implementiert, 2026-05-11)
**DSA-konformes Meldeverfahren (KRIT-03) — App 0.8.870.8.94.**
@ -148,22 +151,25 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
---
## 6. Nächste Session — sinnvolle Arbeitspakete
## 7. Nächste Session — sinnvolle Arbeitspakete
1. **P-13 Frontend-Verifikation:** Melde-Flow in Medienbibliothek, Inbox-Workflow (Status, Archiv, Wiedereröffnen), Club-Admin-Ansicht manuell auf Dev-System durchspielen. E-Mail-Benachrichtigungen verifizieren (SMTP-Log).
2. **Inline §11:** Syntax festlegen (`{{exerciseMedia:id}}` → kanonisches HTML), Server normalisieren bei Speichern, einen `renderExerciseRichText()`-Pfad im Frontend.
2. **Inline (Spec Abschnitt 11):** Basis umgesetzt — verbleibend: gezielte UX-Politik; optional Server-Normalisierung/Absicherung prüfen, falls Produkt es verlangt.
3. **Tests:** pytest für `media_assets`-Router (Leserechte, Lifecycle, `from-asset`); ggf. Snapshot der Pfad-Umzug-Logik.
4. **Retention:** Job-Dokumentation + Betrieb (ENV, Intervall); Dry-Run beschreiben.
5. **S3/Adapter:** Speicher-Abstraktion §7 — wenn Produkt es verlangt.
5. **S3/Adapter:** Speicher-Abstraktion (Spec Abschnitt 7) — wenn Produkt es verlangt.
6. **Rahmen/UI:** Kalender „aus Rahmen übernehmen” weiter anbinden (parallel, unabhängig von Medien).
7. **Fachlicher Nutzerüberblick:** bei größeren UX-Änderungen **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** mitpflegen.
8. **Kombinations-Coach (Archetyp B/C):** Fachspez §10.4; nach Implementierung **Anhang A** + `TRAINING_MODULES_IMPLEMENTATION_PLAN.md` aktualisieren (kein Doc-Drift).
---
## 7. Technische Referenz (kurz)
## 8. Technische Referenz (kurz)
| Bereich | Einstieg |
|---------|----------|
| Backend API | `backend/main.py`; u.a. **`media_assets.py`**, **`exercises.py`**, **`profiles.py`**, **`training_framework_programs.py`**, `tenant_context.py` |
| Backend API | `backend/main.py`; u.a. **`media_assets.py`**, **`exercises.py`** (`COMBINATION_ARCHETYPE_IDS`, `enrich_exercise_detail`), **`profiles.py`**, **`training_framework_programs.py`**, `tenant_context.py` |
| Coach-Kombination (Frontend) | `TrainingCoachPage.jsx`, `ExerciseFullContent.jsx`, `CombinationCoachSlots.jsx`, `constants/combinationArchetypes.js` |
| Migrationen | `backend/migrations/` (040+ Mitgliedschaft/Governance; **045+** Medien-Stack) |
| Frontend API | `frontend/src/utils/api.js` |
| Aktiver Verein (UI) | `frontend/src/utils/activeClub.js`, `AuthContext.jsx` |
@ -171,7 +177,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
---
## 8. Veraltete Hinweise
## 9. Veraltete Hinweise
`.claude/docs/working/HANDOVER_NEXT_SESSION.md` verweist auf **dieses** Dokument (`docs/HANDOVER.md`) als aktuelle Basis.

View File

@ -10,9 +10,12 @@
"dependencies": {
"jspdf": "^4.2.1",
"lucide-react": "^0.344.0",
"marked": "^18.0.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.22.0"
"react-markdown": "^10.1.0",
"react-router-dom": "^6.22.0",
"remark-breaks": "^4.0.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.1",

View File

@ -26,6 +26,8 @@ import SkillsPage from './pages/SkillsPage'
import TrainingPlanningPage from './pages/TrainingPlanningPage'
import TrainingFrameworkProgramsListPage from './pages/TrainingFrameworkProgramsListPage'
import TrainingFrameworkProgramEditPage from './pages/TrainingFrameworkProgramEditPage'
import TrainingModulesListPage from './pages/TrainingModulesListPage'
import TrainingModuleEditPage from './pages/TrainingModuleEditPage'
import TrainingUnitRunPage from './pages/TrainingUnitRunPage'
import TrainingCoachPage from './pages/TrainingCoachPage'
import AdminCatalogsPage from './pages/AdminCatalogsPage'
@ -199,6 +201,9 @@ function AppRoutes() {
<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/training-modules/new" element={<TrainingModuleEditPage />} />
<Route path="planning/training-modules/:id" element={<TrainingModuleEditPage />} />
<Route path="planning/training-modules" element={<TrainingModulesListPage />} />
<Route path="planning/run/:unitId/coach" element={<TrainingCoachPage />} />
<Route path="planning/run/:unitId" element={<TrainingUnitRunPage />} />
<Route path="planning" element={<TrainingPlanningPage />} />

View File

@ -291,6 +291,159 @@ ul > li.card + li.card,
margin-top: 2px;
}
/* Rechtstexte (P-01): Markdown im Fließtext */
.legal-doc-body {
font-size: 0.95rem;
line-height: 1.55;
color: var(--text1);
}
.legal-doc-body--muted {
color: var(--text3);
font-style: italic;
}
.legal-doc-body p {
margin: 0 0 0.65em;
}
.legal-doc-body p:last-child {
margin-bottom: 0;
}
.legal-doc-body ul,
.legal-doc-body ol {
margin: 0.4em 0 0.65em 1.25rem;
padding: 0;
}
.legal-doc-body li {
margin: 0.2em 0;
}
.legal-doc-body strong {
font-weight: 700;
}
.legal-doc-body em {
font-style: italic;
}
.legal-doc-body code {
font-family: ui-monospace, monospace;
font-size: 0.88em;
background: var(--surface2);
padding: 0.1em 0.35em;
border-radius: 4px;
}
.legal-doc-body pre {
background: var(--surface2);
padding: 10px 12px;
border-radius: 8px;
overflow-x: auto;
margin: 0.65em 0;
font-size: 0.85em;
}
.legal-doc-body pre code {
background: none;
padding: 0;
}
.legal-doc-body blockquote {
margin: 0.5em 0;
padding-left: 12px;
border-left: 3px solid var(--border2);
color: var(--text2);
}
.legal-doc-body a {
color: var(--accent);
}
.legal-doc-body h1,
.legal-doc-body h2,
.legal-doc-body h3,
.legal-doc-body h4 {
font-size: 1em;
font-weight: 700;
margin: 0.75em 0 0.35em;
}
.legal-doc-body h1:first-child,
.legal-doc-body h2:first-child,
.legal-doc-body h3:first-child {
margin-top: 0;
}
.legal-doc-body hr {
border: none;
border-top: 1px solid var(--border);
margin: 0.85em 0;
}
/* Admin: Rechtstext Editor — Live-Vorschau neben Textarea */
.legal-section-split {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 4px;
}
@media (min-width: 720px) {
.legal-section-split {
flex-direction: row;
align-items: flex-start;
}
.legal-section-split__editor {
flex: 1;
min-width: 0;
}
.legal-section-split__preview {
flex: 1;
min-width: 0;
}
}
.legal-section-preview-box {
padding: 12px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
min-height: 100px;
}
.legal-section-preview-box h4 {
font-size: 0.95rem;
font-weight: 700;
margin: 0 0 0.45rem;
color: var(--text1);
}
/* Modales Vorschau-Overlay (Rechtstexte Admin) */
.legal-preview-modal-backdrop {
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: flex-start;
justify-content: center;
padding: max(16px, env(safe-area-inset-top, 0px)) 16px 24px;
overflow-y: auto;
}
.legal-preview-modal {
width: 100%;
max-width: 760px;
margin-top: 4vh;
margin-bottom: 4vh;
background: var(--surface);
border-radius: 12px;
border: 1px solid var(--border);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
max-height: min(92vh, 960px);
display: flex;
flex-direction: column;
}
.legal-preview-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.legal-preview-modal__body {
padding: 16px 18px 20px;
overflow-y: auto;
min-height: 0;
flex: 1;
}
.form-input {
width: 100%;
min-width: 0;
@ -4962,6 +5115,408 @@ a.analysis-split__nav-item {
max-width: 100%;
}
.tu-planning-module-band {
/* Legacy — Ersetzt durch .tu-module-bundle-head; wird aus Kompatibilität vorerst beibehalten. */
margin-top: 0.85rem;
margin-bottom: 0.05rem;
padding: 0.35rem 0.65rem;
border-radius: 8px;
border: 1px solid var(--border2);
background: linear-gradient(to right, var(--accent-light), transparent 140%);
font-size: 0.78rem;
font-weight: 700;
color: var(--accent-dark);
letter-spacing: 0.01em;
}
.tu-module-bundle-head {
display: flex;
align-items: stretch;
gap: 0;
margin: 0.65rem 0 0.15rem;
border-radius: 14px;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--border2));
background: linear-gradient(
134deg,
color-mix(in srgb, var(--accent-light) 55%, var(--surface)) 0%,
var(--surface) 68%
);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.tu-module-bundle-head__stripe {
width: 5px;
flex-shrink: 0;
background: linear-gradient(180deg, var(--accent) 0%, color-mix(in srgb, var(--accent-dark) 92%, black) 100%);
}
.tu-module-bundle-head__main {
flex: 1;
min-width: 0;
padding: 0.6rem 0.75rem 0.72rem;
}
.tu-module-bundle-head__kicker {
display: block;
font-size: 0.65rem;
font-weight: 800;
letter-spacing: 0.07em;
text-transform: uppercase;
color: color-mix(in srgb, var(--accent-dark) 88%, var(--text3));
margin-bottom: 0.12rem;
}
.tu-module-bundle-head__title {
display: block;
font-size: 1.02rem;
font-weight: 700;
color: var(--text1);
line-height: 1.25;
margin-bottom: 0.5rem;
}
.tu-module-bundle-head__list {
margin: 0;
padding-left: 1.15rem;
font-size: 0.84rem;
line-height: 1.48;
color: var(--text2);
}
.tu-module-bundle-head__list li {
margin-bottom: 0.12rem;
}
.tu-module-bundle-head__list li::marker {
color: var(--accent-dark);
}
.tu-module-bundle-head__more,
.tu-module-bundle-head__meta,
.tu-module-bundle-head__empty {
margin: 0.25rem 0 0;
font-size: 0.79rem;
line-height: 1.42;
color: var(--text3);
}
.tu-module-bundle-head__empty {
margin-top: 0.15rem;
}
.tu-modulepick-search {
width: 100%;
margin-bottom: 0.55rem;
}
.tu-modulepick-list {
max-height: 240px;
overflow-y: auto;
padding: 2px;
margin: 0 -2px;
border-radius: 10px;
border: 1px solid var(--border2);
background: color-mix(in srgb, var(--surface2) 22%, var(--surface));
display: flex;
flex-direction: column;
gap: 4px;
}
.tu-modulepick-item {
text-align: left;
appearance: none;
margin: 0;
cursor: pointer;
width: 100%;
padding: 0.45rem 0.62rem;
border-radius: 8px;
border: 1px solid transparent;
background: var(--surface);
color: var(--text1);
transition:
border-color 0.12s ease,
box-shadow 0.12s ease;
}
.tu-modulepick-item:hover {
border-color: color-mix(in srgb, var(--accent) 28%, transparent);
}
.tu-modulepick-item--active {
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
box-shadow:
0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent),
0 2px 10px rgba(29, 158, 117, 0.08);
}
.tu-modulepick-item__title {
display: block;
font-size: 0.9rem;
font-weight: 700;
}
.tu-modulepick-item__meta {
display: block;
margin-top: 0.08rem;
font-size: 0.74rem;
color: var(--text3);
line-height: 1.38;
}
.tu-modulepick-preview {
margin-top: 0.85rem;
padding: 0.55rem 0.72rem;
border-radius: 10px;
border: 1px dashed var(--border2);
background: color-mix(in srgb, var(--surface2) 38%, transparent);
}
.tu-modulepick-preview__title {
margin: 0 0 0.45rem;
font-size: 0.74rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text3);
}
.tu-modulepick-preview__list {
margin: 0;
padding-left: 1.1rem;
font-size: 0.82rem;
color: var(--text2);
line-height: 1.42;
}
.tu-modulepick-preview__more {
margin: 0.35rem 0 0;
font-size: 0.78rem;
color: var(--text3);
}
.tu-module-apply-placement-details summary {
cursor: pointer;
font-weight: 600;
font-size: 0.82rem;
color: var(--accent-dark);
margin: 0.25rem 0 0;
}
/* Einfügen zwischen Ablaufzeilen (Übung / Modul / Anmerkung) */
.tu-insert-slot {
display: flex;
align-items: center;
justify-content: center;
min-height: 11px;
margin: -1px 0 3px;
padding: 0 4px;
}
.tu-insert-slot__btn {
appearance: none;
margin: 0;
cursor: pointer;
border: 1px dashed var(--border2);
background: color-mix(in srgb, var(--surface2) 90%, transparent);
color: var(--accent-dark);
font-size: 0.9rem;
font-weight: 700;
line-height: 1;
padding: 2px 9px;
border-radius: 999px;
opacity: 0.78;
}
.tu-insert-slot__btn:hover,
.tu-insert-slot__btn:focus-visible {
opacity: 1;
outline: none;
border-color: var(--accent);
background: color-mix(in srgb, var(--accent-light) 40%, var(--surface2));
}
.tu-insert-chooser-actions {
display: flex;
flex-direction: column;
gap: 10px;
}
.tu-insert-chooser-actions__full {
width: 100%;
justify-content: center;
}
.tu-item-row--separator-note {
padding-top: 0.35rem;
padding-bottom: 0.35rem;
}
.tu-item-row__separator-line {
width: 100%;
margin: 0.2rem 0 0;
min-height: 1px;
border: none;
border-top: 2px solid var(--border);
opacity: 0.92;
}
.training-unit-sections-editor .tu-item-row {
border-top: none;
margin-top: 0;
}
.training-unit-sections-editor .tu-item-row--exercise {
margin-top: 0.58rem;
padding: 0.55rem 0.72rem 0.55rem;
border-radius: 14px;
border: 1px solid color-mix(in srgb, var(--border2) 85%, var(--accent) 15%);
background: linear-gradient(160deg, var(--surface) 0%, color-mix(in srgb, var(--surface2) 35%, var(--surface)) 100%);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.56) inset,
0 2px 10px rgba(15, 23, 42, 0.05);
}
.training-unit-sections-editor .tu-item-row--note:not(.tu-item-row--separator-note) {
margin-top: 0.52rem;
padding: 0.48rem 0.72rem;
border-radius: 11px;
border: 1px solid color-mix(in srgb, var(--border2) 70%, transparent);
background: color-mix(in srgb, var(--surface2) 28%, var(--surface));
}
.training-unit-sections-editor .tu-item-row--separator-note {
margin-top: 0.42rem;
border-radius: 10px;
border: 1px dashed color-mix(in srgb, var(--border2) 75%, transparent);
background: color-mix(in srgb, var(--surface2) 15%, transparent);
}
.training-unit-sections-editor .tu-item-row--from-module.tu-item-row--exercise,
.training-unit-sections-editor .tu-item-row--from-module.tu-item-row--note:not(.tu-item-row--separator-note) {
border-left: 4px solid var(--accent);
padding-left: 0.6rem;
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.52) inset,
0 2px 12px rgba(29, 158, 117, 0.07),
inset 10px 0 0 color-mix(in srgb, var(--accent) 14%, transparent);
}
/* Kompakt: Modulfarbe über CSS-Variable --tu-mod-border (pro Zeile gesetzt). */
.training-unit-sections-editor .tu-item-row--from-module-soft.tu-item-row--exercise,
.training-unit-sections-editor .tu-item-row--from-module-soft.tu-item-row--note:not(.tu-item-row--separator-note) {
border-left: 4px solid var(--tu-mod-border, var(--accent));
padding-left: 0.62rem;
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.52) inset,
0 2px 12px rgba(15, 23, 42, 0.05);
}
/* KombinationsStrip: volle Breite unter der Zeile, begrenzte Textbreite — Hauptzeile (Name/Min.) nicht verdrängen */
.training-unit-sections-editor .tu-combo-planning-strip {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.training-unit-sections-editor .tu-combo-planning-strip__meta {
width: 100%;
max-width: min(100%, 42rem);
min-width: 0;
}
.training-unit-sections-editor .tu-combo-planning-strip > .btn {
align-self: flex-start;
}
.tu-planning-mod-tag {
display: inline-flex;
align-items: center;
gap: 5px;
flex: 0 1 auto;
max-width: 100%;
margin: 0;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--border2) 70%, transparent);
font-size: 0.72rem;
font-weight: 700;
line-height: 1.35;
color: var(--text1);
}
.tu-planning-mod-tag__dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.tu-planning-mod-tag__text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tu-section-mod-legend {
margin-top: 0.75rem;
padding: 0.5rem 0.62rem;
border-radius: 11px;
border: 1px solid color-mix(in srgb, var(--border2) 80%, transparent);
background: color-mix(in srgb, var(--surface) 92%, transparent);
}
.tu-section-mod-legend__caption {
font-size: 0.65rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text3);
margin-bottom: 0.42rem;
}
.tu-section-mod-legend__list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 0.42rem 0.72rem;
}
.tu-section-mod-legend__item {
display: inline-flex;
align-items: flex-start;
gap: 8px;
flex: 1 1 200px;
min-width: 0;
}
.tu-section-mod-legend__swatch {
width: 11px;
height: 11px;
border-radius: 3px;
flex-shrink: 0;
margin-top: 0.26rem;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.06);
}
.tu-section-mod-legend__title {
display: block;
font-size: 0.82rem;
font-weight: 700;
line-height: 1.35;
color: var(--text1);
}
.tu-section-mod-legend__meta {
display: block;
font-size: 0.72rem;
line-height: 1.42;
color: var(--text3);
}
.tu-item-row {
display: flex;
flex-wrap: wrap;
@ -5673,6 +6228,221 @@ a.analysis-split__nav-item {
line-height: 1.48;
}
/* Kombinationsplan — Klammer (Vorschau, Plan & Ablauf, Druck) */
.combo-plan-bracket {
display: flex;
gap: 0;
align-items: stretch;
margin: 0.35rem 0 0;
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--border);
background: var(--surface);
}
.combo-plan-bracket__accent {
width: 6px;
flex-shrink: 0;
background: linear-gradient(180deg, var(--accent) 0%, var(--accent-dark) 100%);
}
.combo-plan-bracket__body {
flex: 1;
min-width: 0;
padding: 10px 12px 12px;
}
.combo-plan-bracket__head {
margin-bottom: 10px;
}
.combo-plan-bracket__head-main {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px 12px;
}
.combo-plan-bracket__kicker {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text3);
}
.combo-plan-bracket__archetype {
font-size: 0.95rem;
font-weight: 700;
color: var(--text1);
}
.combo-plan-bracket__archetype-id {
font-weight: 500;
font-size: 0.78rem;
color: var(--text3);
}
.combo-plan-bracket__badge {
font-size: 0.72rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
background: var(--accent-soft, hsla(160, 42%, 90%, 1));
color: var(--accent-dark);
}
.combo-plan-bracket__hint {
margin: 6px 0 0;
font-size: 0.78rem;
line-height: 1.42;
color: var(--text2);
}
.combo-plan-bracket__globals {
margin-bottom: 12px;
}
.combo-plan-bracket__globals-title {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text3);
margin-bottom: 8px;
}
.combo-plan-bracket__globals-empty {
margin: 0 0 12px;
font-size: 0.78rem;
color: var(--text3);
line-height: 1.4;
}
.combo-plan-bracket__chip-row {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.combo-plan-bracket__chip {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 6.5rem;
max-width: 14rem;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface2);
}
.combo-plan-bracket__chip-cap {
font-size: 0.68rem;
color: var(--text3);
line-height: 1.25;
}
.combo-plan-bracket__chip-val {
font-size: 0.88rem;
font-weight: 700;
color: var(--text1);
}
.combo-plan-bracket__stations {
list-style: decimal;
list-style-position: outside;
padding: 0 0 0 1.35rem;
margin: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.combo-plan-bracket__station {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 10px 10px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface2);
}
.combo-plan-bracket__station-load {
flex-shrink: 0;
min-width: 3rem;
max-width: 5rem;
padding: 7px 8px;
border-radius: 8px;
font-size: 0.78rem;
font-weight: 700;
line-height: 1.2;
text-align: center;
align-self: flex-start;
background: var(--surface);
border: 1px solid var(--border);
color: var(--accent-dark);
}
.combo-plan-bracket__station-main {
flex: 1;
min-width: 0;
}
.combo-plan-bracket__station-title {
font-weight: 700;
font-size: 0.9rem;
color: var(--text1);
margin-bottom: 4px;
}
.combo-plan-bracket__station-exercises {
font-size: 0.84rem;
color: var(--text2);
line-height: 1.38;
margin-bottom: 6px;
}
.combo-plan-bracket__station-timing {
font-size: 0.78rem;
line-height: 1.42;
color: var(--text1);
}
.combo-plan-bracket__station-timing--muted {
color: var(--text3);
font-style: italic;
}
.combo-plan-bracket__timing-label {
font-weight: 700;
color: var(--text3);
margin-right: 6px;
}
.training-run-combo-embed {
margin-top: 0.65rem;
}
/* Kombi-Planung bearbeiten (Planungseditor): gleiches Modal-Chrome wie Übungs-Vorschau */
.combo-planning-edit-backdrop.admin-modal-backdrop {
z-index: 10060;
}
.combo-planning-edit-sheet.admin-modal-sheet {
max-width: min(880px, calc(100vw - 24px));
}
@media (min-width: 640px) {
.combo-planning-edit-sheet.admin-modal-sheet {
max-height: min(88vh, 860px);
}
}
.combo-planning-edit-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.combo-planning-edit-hint {
margin: 0 0 14px;
font-size: 0.8rem;
color: var(--text2);
line-height: 1.45;
}
.combo-planning-edit-card {
margin-top: 4px;
padding: 14px 16px 16px;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--surface2);
border-left: 4px solid var(--accent);
}
.combo-planning-edit-card__title {
margin: 0 0 12px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text3);
}
@media print {
.desktop-sidebar,
.bottom-nav,
@ -5699,6 +6469,25 @@ a.analysis-split__nav-item {
break-inside: avoid;
page-break-inside: avoid;
}
.combo-plan-bracket {
border-color: #222 !important;
background: #fff !important;
break-inside: avoid;
page-break-inside: avoid;
}
.combo-plan-bracket__accent {
background: #085041 !important;
}
.combo-plan-bracket__chip,
.combo-plan-bracket__station {
border-color: #444 !important;
background: #f4f6f8 !important;
}
.combo-plan-bracket__station-load {
border-color: #444 !important;
background: #fff !important;
color: #06352a !important;
}
}
/* Coach — volle Übung, Nur-Mittelbereich scrollt; Steuerung oben/unten sichtbar */

View File

@ -0,0 +1,346 @@
/**
* Kombinationsübung im Coach: Archetyp-Hinweis + Katalog-Inhalt je Slot/Kandidat.
*/
import React, { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
import {
archetypeCoachHint,
combinationArchetypeLabel,
sortCombinationSlotsForDisplay,
} from '../constants/combinationArchetypes'
import {
describeGlobalComboProfile,
effectiveStationTimingSummary,
METHOD_PROFILE_GUI_FIELDS,
readSlotProfilesV1,
} from '../utils/combinationMethodProfileUi'
function formatInlineProfileValue(val) {
if (val === null || val === undefined) return '—'
if (typeof val === 'boolean') return val ? 'ja' : 'nein'
if (typeof val === 'number' && Number.isFinite(val)) return String(val)
if (typeof val === 'string') return val.trim() === '' ? '—' : val
try {
return JSON.stringify(val)
} catch {
return String(val)
}
}
export default function CombinationCoachSlots({
combinationSlots,
methodArchetype,
methodProfile,
compactPlanningView = false,
omitGlobalKeyValueBlock = false,
}) {
const slots = useMemo(() => sortCombinationSlotsForDisplay(combinationSlots), [combinationSlots])
const candidateIds = useMemo(() => {
const set = new Set()
for (const s of slots) {
for (const id of s.candidate_exercise_ids || []) {
const n = typeof id === 'number' ? id : parseInt(String(id), 10)
if (Number.isFinite(n)) set.add(n)
}
}
return [...set]
}, [slots])
const [byId, setById] = useState({})
const [errById, setErrById] = useState({})
const [loadingIds, setLoadingIds] = useState(false)
const sig = candidateIds.slice().sort((a, b) => a - b).join(',')
useEffect(() => {
let cancelled = false
setById({})
setErrById({})
if (candidateIds.length === 0) {
setLoadingIds(false)
return () => {
cancelled = true
}
}
setLoadingIds(true)
Promise.all(
candidateIds.map((id) =>
api.getExercise(id).then(
(ex) => ({ id, ok: true, ex }),
(e) => ({
id,
ok: false,
err: e?.message || String(e),
}),
),
),
).then((results) => {
if (cancelled) return
const map = {}
const emap = {}
for (const r of results) {
if (r.ok) map[r.id] = r.ex
else emap[r.id] = r.err
}
setById(map)
setErrById(emap)
setLoadingIds(false)
})
return () => {
cancelled = true
}
}, [sig])
const archeKey = methodArchetype != null ? String(methodArchetype).trim() : ''
const archDisplay = archeKey ? combinationArchetypeLabel(archeKey) : null
const slotTimingByIx = useMemo(() => {
if (!methodProfile || typeof methodProfile !== 'object' || Array.isArray(methodProfile)) return new Map()
const rows = readSlotProfilesV1(methodProfile)
const m = new Map()
for (const r of rows) {
m.set(Number(r.slot_index), r)
}
return m
}, [methodProfile])
const globalComboRows = useMemo(
() => describeGlobalComboProfile(archeKey, methodProfile || {}),
[archeKey, methodProfile],
)
const profileExtraEntries = useMemo(() => {
if (!methodProfile || typeof methodProfile !== 'object' || Array.isArray(methodProfile)) return []
const known = new Set(['slot_profiles_v1'])
for (const f of METHOD_PROFILE_GUI_FIELDS[archeKey] || []) {
known.add(f.key)
}
const out = []
for (const [k, val] of Object.entries(methodProfile)) {
if (known.has(k)) continue
if (val === null || val === undefined || val === '') continue
out.push([k, val])
}
return out.sort((a, b) => String(a[0]).localeCompare(String(b[0]), 'de'))
}, [methodProfile, archeKey])
return (
<section
className="card"
style={{
marginBottom: '14px',
padding: '12px 14px',
borderLeft: '3px solid var(--accent-dark)',
background: 'var(--surface2)',
}}
>
<h3
style={{
fontSize: '0.72rem',
textTransform: 'uppercase',
color: 'var(--text3)',
margin: '0 0 6px',
letterSpacing: '0.04em',
}}
>
{compactPlanningView ? 'Stationen & Einzelübungen (Katalog)' : 'Kombination · Stationen & Einzelübungen'}
</h3>
{archDisplay ? (
<p style={{ margin: '0 0 6px', fontSize: '0.95rem', fontWeight: 700 }}>{archDisplay}</p>
) : null}
{compactPlanningView ? null : (
<p style={{ margin: '0 0 14px', fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.48 }}>
{archetypeCoachHint(archeKey)}
</p>
)}
{methodProfile && typeof methodProfile === 'object' && !Array.isArray(methodProfile) && Object.keys(methodProfile).length && !omitGlobalKeyValueBlock ? (
<div
style={{
margin: '0 0 14px',
padding: '8px 10px',
borderRadius: '8px',
background: 'var(--surface)',
border: '1px solid var(--border)',
}}
>
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: 'var(--text3)', textTransform: 'uppercase', marginBottom: '6px' }}>
Globale Eckdaten (wie im Editor)
</div>
{globalComboRows.length === 0 && profileExtraEntries.length === 0 ? (
<p style={{ margin: 0, fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.42 }}>
Keine globalen Zahlenfelder gesetzt Zeiten und Steuerung siehe je Station unter Plan: (oder nur im Freitext der Kombination).
</p>
) : (
<dl style={{ margin: 0, fontSize: '0.82rem', lineHeight: 1.45 }}>
{globalComboRows.map((row) => (
<div
key={row.key}
style={{
marginBottom: '6px',
display: 'grid',
gridTemplateColumns: 'minmax(88px,1fr) minmax(0,1.4fr)',
gap: '6px 10px',
}}
>
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>{row.detailLabel}</dt>
<dd style={{ margin: 0, color: 'var(--text1)', fontWeight: 600 }}>{row.value}</dd>
</div>
))}
{profileExtraEntries.map(([k, val]) => (
<div
key={k}
style={{
marginBottom: '4px',
display: 'grid',
gridTemplateColumns: 'minmax(88px,1fr) minmax(0,1.4fr)',
gap: '6px 10px',
}}
>
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)', wordBreak: 'break-all' }}>{k}</dt>
<dd style={{ margin: 0, color: 'var(--text1)' }}>{formatInlineProfileValue(val)}</dd>
</div>
))}
</dl>
)}
</div>
) : null}
{!slots.length ? (
<p style={{ margin: 0, color: 'var(--text3)', fontSize: '0.88rem' }}>Keine Stationen hinterlegt.</p>
) : (
<ol style={{ margin: 0, paddingLeft: '1.1rem', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{slots.map((slot, si) => {
const candIdsRaw = slot.candidate_exercise_ids || []
const candIds = candIdsRaw
.map((id) => (typeof id === 'number' ? id : parseInt(String(id), 10)))
.filter((n) => Number.isFinite(n))
const slotTitle =
(slot.title && String(slot.title).trim()) ||
(candIds.length <= 1 && slot.candidates?.[0]?.title) ||
`Station ${si + 1}`
const ix = slot.slot_index != null ? Number(slot.slot_index) : si
const timingSummary = effectiveStationTimingSummary(archeKey, methodProfile || {}, slotTimingByIx.get(ix))
return (
<li key={`${slot.slot_index ?? si}-${slotTitle}`} style={{ lineHeight: 1.45 }}>
<div style={{ fontWeight: 700, fontSize: '0.92rem', marginBottom: timingSummary ? '4px' : candIds.length > 1 ? '6px' : '8px' }}>
{slotTitle}
</div>
{timingSummary ? (
<p style={{ margin: '0 0 8px', fontSize: '0.78rem', color: 'var(--text2)', lineHeight: 1.42 }}>
Plan: <span style={{ color: 'var(--text1)', fontWeight: 600 }}>{timingSummary}</span>
</p>
) : null}
{candIds.length === 0 ? (
<p style={{ margin: 0, color: 'var(--text3)', fontSize: '0.86rem' }}>Keine Übung zugeordnet.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', paddingLeft: 0 }}>
{candIds.map((cid, ci) => {
const ex = byId[cid]
const err = errById[cid]
const candTitleFallback =
slot.candidates?.find((c) => Number(c.exercise_id) === cid)?.title ||
slot.candidates?.[ci]?.title
const isAlt = candIds.length > 1
return (
<div
key={`${cid}-${ci}`}
style={{
padding: '10px 11px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface)',
}}
>
{isAlt ? (
<div style={{ fontSize: '0.72rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '4px' }}>
{candIds.length > 2 ? `Alternative ${ci + 1}` : ci === 0 ? 'Alternative A' : 'Alternative B'}
</div>
) : null}
{!ex && loadingIds ? (
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', fontSize: '0.86rem', color: 'var(--text2)' }}>
<span className="spinner" style={{ transform: 'scale(0.7)' }} />
Übung #{cid} laden
</div>
) : err ? (
<p style={{ margin: 0, color: 'var(--danger)', fontSize: '0.88rem' }}>
Übung #{cid}: {err}
</p>
) : ex ? (
compactPlanningView ? (
<>
<p style={{ margin: '0 0 4px', fontSize: '0.96rem', fontWeight: 700 }}>{ex.title}</p>
<p style={{ margin: 0 }}>
<Link to={`/exercises/${cid}`} style={{ fontSize: '0.82rem', color: 'var(--accent)' }}>
Im Katalog öffnen
</Link>
</p>
</>
) : (
<>
<p style={{ margin: '0 0 6px', fontSize: '0.96rem', fontWeight: 700 }}>{ex.title}</p>
{ex.summary ? (
<div style={{ marginBottom: ex.execution ? '8px' : 0 }}>
<ExerciseRichTextBlock html={ex.summary} exerciseId={ex.id} media={ex.media} />
</div>
) : (
<p style={{ margin: '0 0 6px', fontSize: '0.86rem', color: 'var(--text3)' }}>
Keine Kurzbeschreibung im Katalog.
</p>
)}
{ex.execution ? (
<details style={{ marginTop: '4px' }}>
<summary style={{ cursor: 'pointer', fontSize: '0.82rem', color: 'var(--accent-dark)', fontWeight: 600 }}>
Ablauf (Detail)
</summary>
<div style={{ marginTop: '8px' }}>
<ExerciseRichTextBlock html={ex.execution} exerciseId={ex.id} media={ex.media} />
</div>
</details>
) : null}
{ex.trainer_notes ? (
<details style={{ marginTop: '6px' }}>
<summary style={{ cursor: 'pointer', fontSize: '0.82rem', color: 'var(--accent-dark)', fontWeight: 600 }}>
Hinweise Trainer
</summary>
<div style={{ marginTop: '8px' }}>
<ExerciseRichTextBlock html={ex.trainer_notes} exerciseId={ex.id} media={ex.media} />
</div>
</details>
) : null}
<p style={{ marginTop: '8px', marginBottom: 0 }}>
<Link to={`/exercises/${cid}`} style={{ fontSize: '0.84rem', color: 'var(--accent)' }}>
Volle Übungsseite
</Link>
</p>
</>
)
) : (
<p style={{ margin: 0, fontSize: '0.86rem', color: 'var(--text2)' }}>
{candTitleFallback || `Übung #${cid}`}
</p>
)}
</div>
)
})}
</div>
)}
</li>
)
})}
</ol>
)}
</section>
)
}

View File

@ -0,0 +1,487 @@
import React, { useMemo, useState } from 'react'
import { archetypeCoachHint, combinationArchetypeLabel, sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
import {
METHOD_PROFILE_GUI_FIELDS,
parseProfileJson,
setFullProfileRawJson,
updateProfileGuided,
patchMethodProfile,
readSlotProfilesV1,
patchSlotTimingField,
patchSlotAdvanceMode,
normalizeAdvanceMode,
parseComboRepSeriesCountUi,
applyCircuitRotateQuickRatio,
applyIntervalDomainQuickRatio,
} from '../utils/combinationMethodProfileUi'
function clampInt(n, min, max) {
if (!Number.isFinite(n)) return null
let x = n
if (typeof min === 'number' && x < min) x = min
if (typeof max === 'number' && x > max) x = max
return Math.round(x)
}
/** Archetypen mit klar bezifferbarer Stationslogik · alle mit Slot-Liste sinnvoll */
const ARCHETYPES_WITH_SLOT_TIMING = new Set([
'circuit_rotate_time',
'sequence_linear',
'station_parcour',
'time_domain_interval',
'circuit_all_parallel',
'pair_superset',
'free_method_block',
])
/**
* Kombination: geführtes method_profile (+ optional Stationszeilen, ohne JSON für Trainer).
*
* @param {boolean} [props.plannerMode] z. B. PlanungsOverride: keine RohJSONSektion.
* @param {boolean} [props.allowExpertJson] wenn true und nicht plannerMode: RohJSON (Support).
* @param {{ slot_index?: number|string, title?: string }[]} [props.comboSlotsOutline] für SlotFelder aus der Übung
*/
export default function CombinationMethodProfileEditor({
methodArchetype,
methodProfileJson,
onChangeMethodProfileJson,
plannerMode = false,
allowExpertJson = false,
comboSlotsOutline = null,
omitPerSlotTiming = false,
}) {
const arch = typeof methodArchetype === 'string' ? methodArchetype.trim() : ''
const fieldsGui = METHOD_PROFILE_GUI_FIELDS[arch]
const fields = Array.isArray(fieldsGui) ? fieldsGui : null
const parseState = useMemo(() => parseProfileJson(methodProfileJson || '{}'), [methodProfileJson])
const [rawOpenError, setRawOpenError] = useState(null)
const [rawDraft, setRawDraft] = useState(null)
const [presetHint, setPresetHint] = useState(null)
const profileObj = parseState.ok ? parseState.obj : {}
const outlineSorted = useMemo(() => {
if (!comboSlotsOutline || !Array.isArray(comboSlotsOutline) || comboSlotsOutline.length === 0) return []
return sortCombinationSlotsForDisplay(comboSlotsOutline)
}, [comboSlotsOutline])
const showSlotTiming =
!omitPerSlotTiming && ARCHETYPES_WITH_SLOT_TIMING.has(arch) && outlineSorted.length > 0
const slotRowsModel = useMemo(() => readSlotProfilesV1(profileObj), [profileObj])
const lookupSlotTiming = (slotIndex) =>
slotRowsModel.find((r) => Number(r.slot_index) === Number(slotIndex)) || {}
const applyGuided = (key, value, kind) => {
if (kind === 'bool') {
const res = updateProfileGuided(arch, methodProfileJson || '{}', key, value, 'bool')
if (!res.ok) return
onChangeMethodProfileJson(res.json)
setPresetHint(null)
return
}
if (value === '' || value === undefined || value === null) {
const res = updateProfileGuided(arch, methodProfileJson || '{}', key, '', 'int')
if (!res.ok) return
onChangeMethodProfileJson(res.json)
setPresetHint(null)
return
}
const num = typeof value === 'number' ? value : parseInt(String(value), 10)
if (!Number.isFinite(num)) return
const def = METHOD_PROFILE_GUI_FIELDS[arch]?.find((f) => f.key === key && f.kind === 'int')
const c = clampInt(num, def?.min, def?.max)
if (c == null) return
const res = updateProfileGuided(arch, methodProfileJson || '{}', key, c, 'int')
if (!res.ok) return
onChangeMethodProfileJson(res.json)
setPresetHint(null)
}
const onSlotField = (slotIx, field, rawStr) => {
const patched = patchMethodProfile(methodProfileJson || '{}', (d) =>
patchSlotTimingField(d, slotIx, field, rawStr)
)
if (!patched.ok) return
onChangeMethodProfileJson(patched.json)
setPresetHint(null)
}
const onSlotAdvanceChange = (slotIx, rawMode) => {
const patched = patchMethodProfile(methodProfileJson || '{}', (d) =>
patchSlotAdvanceMode(d, slotIx, rawMode)
)
if (!patched.ok) return
onChangeMethodProfileJson(patched.json)
setPresetHint(null)
}
const onSlotRepSeriesCount = (slotIx, rawStr) => {
const trimmed = String(rawStr ?? '').trim()
const effective = trimmed === '' ? '1' : trimmed
const pn = parseInt(effective, 10)
const clearIntra = !Number.isFinite(pn) || pn < 2
const patched = patchMethodProfile(methodProfileJson || '{}', (d) => {
patchSlotTimingField(d, slotIx, 'rep_series_count', effective)
if (clearIntra) patchSlotTimingField(d, slotIx, 'intra_rep_rest_sec', '')
})
if (!patched.ok) return
onChangeMethodProfileJson(patched.json)
setPresetHint(null)
}
const runCircuitPreset = (presetId) => {
const r = patchMethodProfile(methodProfileJson || '{}', (draft) => {
const pr = applyCircuitRotateQuickRatio(draft, presetId)
if (!pr.ok) setPresetHint(pr.error || '')
else setPresetHint(null)
})
if (!r.ok) return
onChangeMethodProfileJson(r.json)
}
const runIntervalPreset = (presetId) => {
const r = patchMethodProfile(methodProfileJson || '{}', (draft) => {
const pr = applyIntervalDomainQuickRatio(draft, presetId)
if (!pr.ok) setPresetHint(pr.error || '')
else setPresetHint(null)
})
if (!r.ok) return
onChangeMethodProfileJson(r.json)
}
const archeLabel = arch ? combinationArchetypeLabel(arch) : null
const openAdvanced = () => {
setRawOpenError(null)
const p = parseProfileJson(methodProfileJson || '{}')
setRawDraft(p.ok ? JSON.stringify(p.obj, null, 2) : String(methodProfileJson || ''))
}
const showExpertSection = allowExpertJson && !plannerMode
return (
<div style={{ marginTop: '10px' }}>
{presetHint ? (
<p style={{ fontSize: '12px', color: 'var(--danger)', margin: '0 0 8px', lineHeight: 1.4 }}>{presetHint}</p>
) : null}
{arch ? (
<p style={{ fontSize: '12px', color: 'var(--text2)', lineHeight: 1.48, margin: '0 0 12px' }}>
<strong style={{ color: 'var(--text1)' }}>
Coach &amp; Planung:{' '}
{archeLabel && archeLabel !== arch ? `${archeLabel} · ` : ''}
</strong>
{archetypeCoachHint(arch)}
</p>
) : (
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '0 0 12px' }}>
Wähle einen MethodenArchetyp besonders beim <strong>freien Methodenblock</strong> stehen alle
typischen StationsZeiten zur Verfügung. Ohne Archetyp keine geführten Eingaben.
</p>
)}
{!parseState.ok ? (
<p style={{ color: 'var(--danger)', fontSize: '13px', marginBottom: '10px' }}>{parseState.error}</p>
) : null}
{fields && fields.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '12px' }}>
{arch === 'circuit_rotate_time' ? (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
marginBottom: '4px',
alignItems: 'center',
}}
>
<span style={{ fontSize: '11px', color: 'var(--text3)', marginRight: '6px' }}>Schnellwahl:</span>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => runCircuitPreset('transition_equals_work')}>
Wechsel Arbeit
</button>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => runCircuitPreset('round_rest_equals_work')}>
RundenPause Arbeit
</button>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => runCircuitPreset('round_rest_two_thirds_work')}>
RundenPause Arbeit
</button>
</div>
) : null}
{arch === 'time_domain_interval' ? (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
marginBottom: '4px',
alignItems: 'center',
}}
>
<span style={{ fontSize: '11px', color: 'var(--text3)', marginRight: '6px' }}>Schnellwahl:</span>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => runIntervalPreset('rest_equals_work')}>
Erholung = Belastung
</button>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => runIntervalPreset('rest_two_thirds_work')}>
Erholung Belastung
</button>
</div>
) : null}
{fields.map((def) => {
if (def.kind === 'bool') {
const ck = !!profileObj[def.key]
return (
<label
key={def.key}
style={{ display: 'flex', gap: '8px', alignItems: 'center', cursor: 'pointer', fontSize: '0.9rem' }}
>
<input type="checkbox" checked={ck} onChange={(e) => applyGuided(def.key, e.target.checked, 'bool')} />
<span>{def.label}</span>
</label>
)
}
const v = profileObj[def.key]
const str =
v === undefined || v === null ? '' : typeof v === 'number' && Number.isFinite(v) ? String(v) : String(v)
return (
<div className="form-row" key={def.key}>
<label className="form-label" style={{ fontSize: '12px' }}>
{def.label}
</label>
<input
type="number"
className="form-input"
min={def.min}
max={def.max}
value={str}
placeholder="optional"
onChange={(e) => {
const t = e.target.value
if (t.trim() === '') applyGuided(def.key, '', 'int')
else applyGuided(def.key, parseInt(t, 10), 'int')
}}
/>
</div>
)
})}
</div>
) : arch && fields && fields.length === 0 ? (
<div style={{ marginBottom: '12px' }}>
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '0 0 10px', lineHeight: 1.45 }}>
Dieser Archetyp ist für <strong>maximal flexible</strong> Stationsblöcke gedacht die ZeitEckdaten sind
unten je Station möglich. Freitexte der Kombination beschreiben alles Organisatorische, was nicht in
Sekunden gefasst wird.
</p>
</div>
) : null}
{showSlotTiming ? (
<div
style={{
marginTop: '6px',
marginBottom: '14px',
padding: '12px 14px',
borderRadius: '10px',
border: '1px solid var(--border)',
background: 'var(--surface)',
}}
>
<div style={{ fontSize: '0.88rem', fontWeight: 700, marginBottom: '10px', color: 'var(--text1)' }}>
Pro Station / Slot Steuerung &amp; Sekunden
</div>
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '0 0 12px', lineHeight: 1.45 }}>
<strong>Steuerung:</strong> zeitlich (ArbeitsCountdown), Zielzahl Wiederholungen oder Coachgeführt ohne
Arbeitsuhr. Pausen/Wechsel bleiben unabhängig planbar.&nbsp;Felder können leer bleiben z.&nbsp;B. nutzt der
Zirkel erst die globalen ArbeitSekunden.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{outlineSorted.map((slot, ordIdx) => {
const siRaw = slot.slot_index
const si =
siRaw === '' || siRaw == null ? null : typeof siRaw === 'number' ? siRaw : parseInt(String(siRaw), 10)
if (!Number.isFinite(si)) return null
const row = lookupSlotTiming(si)
const ttl = ((slot.title || '').trim() || `Station ${ordIdx + 1}`).trim()
const slotAdv = normalizeAdvanceMode(row.advance_mode)
const serieLabel =
slotAdv === 'timed' ? 'Wdh. ohne Wechsel' : slotAdv === 'rep' ? 'Wdh. / Serie' : 'Richtwert'
const showMultiSeries = slotAdv === 'rep' || slotAdv === 'manual'
const serienUi = parseComboRepSeriesCountUi(row.rep_series_count)
const showInterSeriesPause = showMultiSeries && serienUi >= 2
const intraLabel = slotAdv === 'timed' ? 'Pause zwischen Wdh.' : 'Pause zw. Serien'
return (
<div
key={`slot-timing-${si}`}
style={{
padding: '10px',
borderRadius: '10px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
}}
>
<div style={{ marginBottom: '10px', fontWeight: 700, color: 'var(--text1)', fontSize: '0.9rem' }}>{ttl}</div>
<div className="form-row" style={{ marginBottom: '10px', maxWidth: '22rem' }}>
<label className="form-label" style={{ fontSize: '11px' }}>
Steuerung
</label>
<select
className="form-input"
style={{ fontSize: '0.8125rem' }}
value={slotAdv}
onChange={(e) => onSlotAdvanceChange(si, e.target.value)}
>
<option value="timed">Zeit (Arbeit in Sekunden)</option>
<option value="rep">Wiederholungen (Ziel)</option>
<option value="manual">Coach (ohne Arbeitsuhr)</option>
</select>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
gap: '10px',
alignItems: 'end',
}}
>
{slotAdv === 'timed' ? (
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '11px' }}>
Belastung (s)
</label>
<input
type="number"
min={0}
className="form-input"
placeholder=""
value={row.load_sec != null ? String(row.load_sec) : ''}
onChange={(e) => onSlotField(si, 'load_sec', e.target.value)}
/>
</div>
) : null}
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '11px' }}>
{serieLabel}
</label>
<input
type="number"
min={slotAdv === 'rep' ? 1 : undefined}
className="form-input"
placeholder={slotAdv === 'manual' ? 'optional' : 'oft 1'}
value={row.consecutive_reps != null ? String(row.consecutive_reps) : ''}
onChange={(e) => onSlotField(si, 'consecutive_reps', e.target.value)}
/>
</div>
{showMultiSeries ? (
<div className="form-row" style={{ marginBottom: 0 }}>
<label
className="form-label"
style={{ fontSize: '11px' }}
title="Wie oft die Wdh.-Zahl pro Serie hintereinander (mit Pause zwischen den Serien)?"
>
Serien
</label>
<input
type="number"
min={slotAdv === 'rep' ? 1 : undefined}
className="form-input"
placeholder="1"
value={String(parseComboRepSeriesCountUi(row.rep_series_count))}
onChange={(e) => onSlotRepSeriesCount(si, e.target.value)}
/>
</div>
) : null}
{slotAdv === 'timed' || showInterSeriesPause ? (
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '11px' }}>
{intraLabel} (s)
</label>
<input
type="number"
min={0}
className="form-input"
placeholder=""
value={row.intra_rep_rest_sec != null ? String(row.intra_rep_rest_sec) : ''}
onChange={(e) => onSlotField(si, 'intra_rep_rest_sec', e.target.value)}
/>
</div>
) : null}
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '11px' }}>
Wechsel (s)
</label>
<input
type="number"
min={0}
className="form-input"
placeholder=""
value={row.transition_after_sec != null ? String(row.transition_after_sec) : ''}
onChange={(e) => onSlotField(si, 'transition_after_sec', e.target.value)}
/>
</div>
</div>
{showMultiSeries && serienUi < 2 ? (
<p style={{ fontSize: '10px', color: 'var(--text3)', margin: '6px 0 0', lineHeight: 1.38 }}>
<strong>Wechsel (s)</strong> zur <strong>nächsten Station</strong>. Pause zw. Serien nur ab&nbsp;2
Serien.
</p>
) : null}
</div>
)
})}
</div>
</div>
) : null}
{showExpertSection ? (
<details
style={{
marginTop: '4px',
borderRadius: '8px',
border: '1px solid var(--border)',
padding: '8px 10px',
background: 'var(--surface2)',
}}
onToggle={(ev) => {
if (ev.target.open) openAdvanced()
}}
>
<summary style={{ cursor: 'pointer', fontSize: '0.82rem', fontWeight: 600 }}>
Support / Entwicklung: Rohdaten (JSON)
</summary>
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '8px 0 6px', lineHeight: 1.4 }}>
Für Migrationen und Sonderfälle. Geführte Felder setzen weiterhin gültige Standardschlüssel.
</p>
<textarea
className="form-input"
rows={8}
style={{ fontFamily: 'Consolas,monospace', fontSize: '12px' }}
value={rawDraft != null ? rawDraft : methodProfileJson || '{}'}
onChange={(e) => {
setRawDraft(e.target.value)
setRawOpenError(null)
}}
spellCheck={false}
onBlur={() => {
const src = rawDraft != null ? rawDraft : methodProfileJson
const res = setFullProfileRawJson(src || '{}')
if (!res.ok) {
setRawOpenError(res.error)
return
}
setRawOpenError(null)
setRawDraft(null)
onChangeMethodProfileJson(res.json)
}}
/>
{rawOpenError ? (
<p style={{ color: 'var(--danger)', fontSize: '12px', marginTop: '6px' }}>{rawOpenError}</p>
) : null}
</details>
) : null}
</div>
)
}

View File

@ -0,0 +1,134 @@
/**
* Kombination: konsolidierte Darstellung globales Profil + Stationen mit Zeiten (Vorschau, Plan-Ansicht, Druck).
*/
import React, { useMemo } from 'react'
import {
archetypeCoachHint,
combinationArchetypeLabel,
sortCombinationSlotsForDisplay,
} from '../constants/combinationArchetypes'
import {
describeGlobalComboProfile,
effectiveStationTimingSummary,
readSlotProfilesV1,
stationPrimaryLoadLabel,
} from '../utils/combinationMethodProfileUi'
function candidateLine(slot) {
const cands = slot.candidates
if (Array.isArray(cands) && cands.length > 0) {
return cands
.map((c) =>
((c.title || '').trim() || (c.exercise_id != null ? `Übung #${c.exercise_id}` : '')).trim(),
)
.filter(Boolean)
.join(' ↔ ')
}
const ids = slot.candidate_exercise_ids || []
return ids
.map((raw) => {
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
return Number.isFinite(n) ? `Übung #${n}` : ''
})
.filter(Boolean)
.join(' ↔ ')
}
export default function CombinationPlanBracket({
methodArchetype,
methodProfile,
combinationSlots,
planningAdjusted = false,
}) {
const arch = typeof methodArchetype === 'string' ? methodArchetype.trim() : ''
const archLabel = arch ? combinationArchetypeLabel(arch) : null
const globals = describeGlobalComboProfile(arch, methodProfile || {})
const slotsSorted = useMemo(() => sortCombinationSlotsForDisplay(combinationSlots || []), [combinationSlots])
const timingByIx = useMemo(() => {
const mp = methodProfile || {}
const rows = readSlotProfilesV1(mp)
const m = new Map()
for (const r of rows) {
m.set(Number(r.slot_index), r)
}
return m
}, [methodProfile])
const coachHint = arch ? archetypeCoachHint(arch) : ''
return (
<div className="combo-plan-bracket">
<div className="combo-plan-bracket__accent" aria-hidden />
<div className="combo-plan-bracket__body">
<header className="combo-plan-bracket__head">
<div className="combo-plan-bracket__head-main">
<span className="combo-plan-bracket__kicker">KombinationsPlan</span>
<span className="combo-plan-bracket__archetype">{archLabel || arch || 'Archetyp'}</span>
{planningAdjusted ? (
<span className="combo-plan-bracket__badge">Planung angepasst</span>
) : null}
</div>
{coachHint ? <p className="combo-plan-bracket__hint">{coachHint}</p> : null}
</header>
{globals.length > 0 ? (
<section className="combo-plan-bracket__globals" aria-label="Globale Eckdaten">
<div className="combo-plan-bracket__globals-title">Runden · Zeiten · Pausen (global)</div>
<ul className="combo-plan-bracket__chip-row">
{globals.map((g) => (
<li key={g.key} className="combo-plan-bracket__chip" title={g.detailLabel}>
<span className="combo-plan-bracket__chip-cap">{g.caption}</span>
<span className="combo-plan-bracket__chip-val">{g.value}</span>
</li>
))}
</ul>
</section>
) : (
<p className="combo-plan-bracket__globals-empty">
Keine globalen Zahlenfelder gesetzt Steuerung erfolgt nur je Station oder über den Freitext der Kombination.
</p>
)}
<ol className="combo-plan-bracket__stations">
{slotsSorted.map((slot, si) => {
const siRaw = slot.slot_index
const ixParsed =
siRaw === '' || siRaw == null ? si : typeof siRaw === 'number' ? siRaw : parseInt(String(siRaw), 10)
const stationIx = Number.isFinite(ixParsed) ? ixParsed : si
const displayStep = si + 1
const stationTitle = ((slot.title || '').trim() || `Station ${displayStep}`).trim()
const names = candidateLine(slot)
const slotProfRow = timingByIx.get(stationIx)
const loadBadge = stationPrimaryLoadLabel(slotProfRow)
const timing = effectiveStationTimingSummary(arch, methodProfile || {}, slotProfRow)
return (
<li key={`slot-${stationIx}-${si}`} className="combo-plan-bracket__station">
<div
className="combo-plan-bracket__station-load"
title={loadBadge ? 'Belastung je Station (Sekunden oder Wiederholungen)' : undefined}
>
{loadBadge || '—'}
</div>
<div className="combo-plan-bracket__station-main">
<div className="combo-plan-bracket__station-title">{stationTitle}</div>
<div className="combo-plan-bracket__station-exercises">{names || '(keine Einzelübung)'}</div>
{timing ? (
<div className="combo-plan-bracket__station-timing">
<span className="combo-plan-bracket__timing-label">Zeit / Steuerung</span>
<span>{timing}</span>
</div>
) : (
<div className="combo-plan-bracket__station-timing combo-plan-bracket__station-timing--muted">
Keine eigene StationsZeit im Profil ggf. nur globale Vorgaben oder Freitext.
</div>
)}
</div>
</li>
)
})}
</ol>
</div>
</div>
)
}

View File

@ -5,6 +5,8 @@
import React from 'react'
import { Link } from 'react-router-dom'
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
import CombinationCoachSlots from './CombinationCoachSlots'
import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
function TagRow({ exercise }) {
const tags = []
@ -52,9 +54,9 @@ function metaParts(exercise) {
}
/**
* @param {{ exercise?: object|null, loading?: boolean, error?: string|null, exerciseId?: number, variantId?: number|string|null }} props
* @param {{ exercise?: object|null, loading?: boolean, error?: string|null, exerciseId?: number, variantId?: number|string|null, planningComboMethodProfile?: object|null, catalogMethodProfileSnapshot?: object|null }} props
*/
export default function ExerciseFullContent({ exercise, loading, error, exerciseId, variantId }) {
export default function ExerciseFullContent({ exercise, loading, error, exerciseId, variantId, planningComboMethodProfile, catalogMethodProfileSnapshot }) {
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '1rem' }}>
@ -76,6 +78,22 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
? exercise.variants.find((v) => String(v.id) === String(variantId)) || null
: null
const isCombination =
String(exercise.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
const catalogFromExercise =
exercise.method_profile && typeof exercise.method_profile === 'object' && !Array.isArray(exercise.method_profile)
? exercise.method_profile
: {}
const catalogFromUnit =
catalogMethodProfileSnapshot && typeof catalogMethodProfileSnapshot === 'object' && !Array.isArray(catalogMethodProfileSnapshot)
? catalogMethodProfileSnapshot
: {}
const coachComboProfile = isCombination
? effectiveComboMethodProfile({ ...catalogFromExercise, ...catalogFromUnit }, planningComboMethodProfile)
: null
return (
<div className="exercise-coach-catalog" style={{ fontSize: '0.93rem', lineHeight: 1.5 }}>
{variant ? (
@ -106,6 +124,13 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
) : null}
</section>
) : null}
{isCombination && Array.isArray(exercise.combination_slots) ? (
<CombinationCoachSlots
combinationSlots={exercise.combination_slots}
methodArchetype={exercise.method_archetype}
methodProfile={coachComboProfile}
/>
) : null}
<h2 style={{ margin: '0 0 8px', fontSize: '1.2rem', lineHeight: 1.35 }}>{exercise.title}</h2>
{meta.length > 0 && (
<p className="exercise-meta-line" style={{ marginBottom: '10px', color: 'var(--text3)', fontSize: '0.86rem' }}>

View File

@ -1,10 +1,12 @@
/**
* Schnellansicht einer Übung aus dem Katalog (ohne die Planungsseite zu verlassen).
*/
import React, { useEffect, useState } from 'react'
import React, { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
import CombinationPlanBracket from './CombinationPlanBracket'
import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
function TagMini({ exercise }) {
const parts = []
@ -29,6 +31,8 @@ export default function ExercisePeekModal({
variantId,
onClose,
titleFallback,
/** Nur Planung: effektives method_profile aus Zeilen-Katalog + Planungs-Override */
peekExtras,
}) {
const [loading, setLoading] = useState(false)
const [err, setErr] = useState(null)
@ -39,6 +43,22 @@ export default function ExercisePeekModal({
? exercise.variants.find((v) => String(v.id) === String(variantId)) || null
: null
const isCombination =
exercise &&
String(exercise.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
const comboMethodProfileEffective = useMemo(() => {
if (!exercise || !isCombination) return {}
const fromPeek =
peekExtras?.catalog_method_profile &&
typeof peekExtras.catalog_method_profile === 'object' &&
!Array.isArray(peekExtras.catalog_method_profile) &&
Object.keys(peekExtras.catalog_method_profile).length > 0
? peekExtras.catalog_method_profile
: exercise.method_profile || {}
return effectiveComboMethodProfile(fromPeek, peekExtras?.planning_method_profile ?? null)
}, [exercise, isCombination, peekExtras])
useEffect(() => {
if (!open) {
setExercise(null)
@ -69,6 +89,8 @@ export default function ExercisePeekModal({
if (!open) return null
const sheetWide = Boolean(isCombination && exercise && !loading)
return (
<div className="admin-modal-backdrop" role="presentation" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div
@ -77,7 +99,7 @@ export default function ExercisePeekModal({
aria-modal="true"
aria-labelledby="exercise-peek-title"
style={{
maxWidth: '620px',
maxWidth: sheetWide ? 'min(840px, 96vw)' : '620px',
width: '100%',
maxHeight: '88vh',
display: 'flex',
@ -103,6 +125,24 @@ export default function ExercisePeekModal({
{!loading && err && <p style={{ color: 'var(--danger)' }}>{err}</p>}
{!loading && exercise && (
<>
{isCombination ? (
<>
<CombinationPlanBracket
methodArchetype={exercise.method_archetype || ''}
methodProfile={comboMethodProfileEffective}
combinationSlots={
Array.isArray(exercise.combination_slots) ? exercise.combination_slots : []
}
planningAdjusted={
peekExtras?.planning_method_profile != null &&
typeof peekExtras.planning_method_profile === 'object' &&
!Array.isArray(peekExtras.planning_method_profile)
}
/>
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '1rem 0' }} />
</>
) : null}
{variant ? (
<div
style={{

View File

@ -32,6 +32,8 @@ export default function ExercisePickerModal({
multiSelect = false,
onSelectExercises = null,
enableQuickCreateDraft = false,
/** Wenn gesetzt: z. B. ['simple'] oder ['combination'] — sonst alle Übungsarten */
exerciseKindAny = undefined,
}) {
const { user } = useAuth()
const [catalogs, setCatalogs] = useState({
@ -213,8 +215,14 @@ export default function ExercisePickerModal({
if (filters.include_archived) q.include_archived = true
if (debouncedSearch) q.search = debouncedSearch
if (debouncedAi) q.ai_search = debouncedAi
if (
Array.isArray(exerciseKindAny) &&
exerciseKindAny.length > 0
) {
q.exercise_kind_any = exerciseKindAny
}
return q
}, [filters, debouncedSearch, debouncedAi])
}, [filters, debouncedSearch, debouncedAi, exerciseKindAny])
const reload = useCallback(async () => {
if (!open || !catalogsReady) return
@ -606,6 +614,19 @@ export default function ExercisePickerModal({
{ex.focus_area}
</span>
)}
{(ex.exercise_kind || '').toLowerCase().trim() === 'combination' ? (
<span
className="exercise-tag"
style={{
marginTop: 6,
marginLeft: 6,
background: 'var(--accent-soft)',
color: 'var(--accent-dark)',
}}
>
Kombination
</span>
) : null}
</>
)
if (multiSelect) {

View File

@ -1055,7 +1055,12 @@ export default function ExerciseProgressionGraphPanel({
</div>
)}
<ExercisePickerModal open={pickerOpen} onClose={() => setPickContext(null)} onSelectExercise={applyPickedExercise} />
<ExercisePickerModal
open={pickerOpen}
onClose={() => setPickContext(null)}
onSelectExercise={applyPickedExercise}
exerciseKindAny={['simple']}
/>
</div>
)
}

View File

@ -0,0 +1,25 @@
import ReactMarkdown from 'react-markdown'
import remarkBreaks from 'remark-breaks'
/**
* Rechtstext-Absatz aus Markdown (ohne Roht-HTML; Links mit target=_blank).
*/
export default function LegalDocumentBody({ content, muted }) {
if (content == null || content === '') return null
return (
<div className={`legal-doc-body ${muted ? 'legal-doc-body--muted' : ''}`}>
<ReactMarkdown
remarkPlugins={[remarkBreaks]}
components={{
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
),
}}
>
{content}
</ReactMarkdown>
</div>
)
}

View File

@ -0,0 +1,111 @@
import { useEffect } from 'react'
import LegalDocumentBody from './LegalDocumentBody'
import { legalSectionNumber } from '../utils/legalPdfExport'
/** Inhalt wie auf der öffentlichen Rechtstextseite (inkl. §-Nummerierung). */
export function LegalDocumentPublicPreviewContent({
title,
sections,
showDraftNotice = true,
metaLine,
}) {
const safeTitle = (title || '').trim() || 'Ohne Titel'
return (
<div className="legal-admin-preview-inner">
{showDraftNotice && (
<div
className="card"
style={{
marginBottom: '1.25rem',
borderLeft: '4px solid var(--warn)',
background: 'var(--surface)',
}}
>
<strong style={{ color: 'var(--text1)' }}>Vorschau</strong>
<p style={{ margin: '0.35rem 0 0', fontSize: '0.88rem', color: 'var(--text2)' }}>
So erscheint der Text für Besucher nach Veröffentlichung (Markdown wird gerendert, §-Nummern wie online).
</p>
</div>
)}
{metaLine && (
<p style={{ fontSize: '0.85rem', color: 'var(--text3)', margin: '0 0 1rem' }}>{metaLine}</p>
)}
<h1 style={{ margin: '0 0 1.5rem', color: 'var(--text1)', fontSize: '1.5rem' }}>{safeTitle}</h1>
{(sections || []).map((section, i) => (
<div key={i} style={{ marginBottom: '1.75rem' }}>
<h2 style={{ fontSize: '1.05rem', marginBottom: '0.4rem', color: 'var(--text1)' }}>
{section.heading?.trim()
? `${legalSectionNumber(i)} ${section.heading}`
: legalSectionNumber(i)}
</h2>
<LegalDocumentBody content={section.content} />
</div>
))}
{sections?.length === 0 && (
<p style={{ color: 'var(--text3)', fontSize: '0.9rem' }}>Noch keine Abschnitte.</p>
)}
</div>
)
}
/**
* Modal: gerenderte Rechtstext-Vorschau (Editor oder gespeicherte Version).
*/
export function LegalPreviewModal({
open,
onClose,
title,
sections,
metaLine,
loading,
showDraftNotice = true,
}) {
useEffect(() => {
if (!open) return
const onKey = (e) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, onClose])
if (!open) return null
return (
<div
className="legal-preview-modal-backdrop"
role="presentation"
onClick={onClose}
>
<div
className="legal-preview-modal"
role="dialog"
aria-modal="true"
aria-labelledby="legal-preview-modal-title"
onClick={(e) => e.stopPropagation()}
>
<div className="legal-preview-modal__header">
<h3 id="legal-preview-modal-title" style={{ margin: 0, fontSize: '1.05rem' }}>
Öffentliche Darstellung
</h3>
<button type="button" className="btn btn-secondary" onClick={onClose}>
Schließen
</button>
</div>
<div className="legal-preview-modal__body">
{loading ? (
<div className="spinner" style={{ margin: '2rem auto' }} />
) : (
<LegalDocumentPublicPreviewContent
title={title}
sections={sections}
metaLine={metaLine}
showDraftNotice={showDraftNotice}
/>
)}
</div>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,26 @@
/**
* Darstellung Herkunft Trainingsmodul in Abschnitten (Planungs-Editor).
*
* Der Modus kommt aus dem Profil (`training_planning_prefs.module_display_mode`).
* Standard: kompakt (Tags + Legende). Voll: großer Kopf mit Auflistung der Übungen.
*
* Früher: Umschalter nur in dieser Datei. Jetzt: unter Einstellungen speichern;
* hier nur Konstanten und Resolver.
*/
export const PLANNING_MODULE_DISPLAY_MODES = /** @type {const} */ ({
COMPACT_TAG_LEGEND: 'compact_tag_legend',
FULL_OUTLINE_HEADERS: 'full_outline_headers',
})
/** @param {string | undefined | null} pref */
export function resolvePlanningModuleDisplayMode(pref) {
if (pref === PLANNING_MODULE_DISPLAY_MODES.FULL_OUTLINE_HEADERS) {
return PLANNING_MODULE_DISPLAY_MODES.FULL_OUTLINE_HEADERS
}
return PLANNING_MODULE_DISPLAY_MODES.COMPACT_TAG_LEGEND
}
/** @param {string | undefined | null} pref */
export function isCompactTagLegendMode(pref) {
return resolvePlanningModuleDisplayMode(pref) === PLANNING_MODULE_DISPLAY_MODES.COMPACT_TAG_LEGEND
}

View File

@ -0,0 +1,75 @@
/** API `method_archetype`-Werte (Backend `COMBINATION_ARCHETYPE_IDS`). */
export const COMBINATION_ARCHETYPE_OPTIONS = [
{ id: 'sequence_linear', label: 'Lineare Sequenz' },
{ id: 'circuit_rotate_time', label: 'Rotierender Zirkel (Zeit)' },
{ id: 'circuit_all_parallel', label: 'Parallele Stationen' },
{ id: 'station_parcour', label: 'Parcours' },
{ id: 'pair_superset', label: 'Partner- / Paarwechsel' },
{ id: 'time_domain_interval', label: 'Intervallblock (Zeitdomäne)' },
{ id: 'free_method_block', label: 'Freier Methodenblock' },
]
const LABEL_BY_ID = Object.fromEntries(
COMBINATION_ARCHETYPE_OPTIONS.map((o) => [String(o.id), o.label]),
)
/** Coach-/Lesetexte: strukturieren die Erwartung, nicht die komplette Methodik */
const COACH_HINT_BY_ID = {
sequence_linear:
'Station für Station der Reihenfolge nach durchfahren. Pro Abschnitt zuerst klarziehen, dann erst zur nächsten Übung übergehen.',
circuit_rotate_time:
'Zirkelsystem nach Zeitfenster drehen oder Gruppe weitergeben. Halte Rotation und Pausen an der Kombi-Beschreibung fest.',
circuit_all_parallel:
'Alle Stationen parallel nutzen: Aufteilen, gleicher Zeitraum bzw. Rundenlogik wie in dieser Kombination beschrieben.',
station_parcour:
'Parcours: Besuchsreihenfolge und Regeln (z.\u202fB. Stopp-/Wechselpunkte) aus der Kombi-Beschreibung und Stationsnamen.',
pair_superset:
'Nach Paar-/Superset-Logik abstimmen: z.\u202fB. Übung A ↔ B oder Abwechseln — Rhythmus an der Kombi oder Stationen ausrichten.',
time_domain_interval:
'Strikt an die Zeituhr bzw. Intervallarbeit halten (Arbeit, Pause, Etappen). Kombi beschreibt meist ArbeitPauseSchema.',
free_method_block:
'Lockerer Stationenblock: Reihenfolge und Verweildauer können flexibel sein — Stationsübungen unten sind die angebotenen Bausteine.',
}
export function combinationArchetypeLabel(archetypeId) {
if (archetypeId == null || String(archetypeId).trim() === '') {
return null
}
const key = String(archetypeId).trim()
return LABEL_BY_ID[key] || key
}
export function archetypeCoachHint(archetypeId) {
if (archetypeId == null || String(archetypeId).trim() === '') {
return 'Nutze die Stationen wie in der Kombi-Beschreibung oben angelegt.'
}
const key = String(archetypeId).trim()
return COACH_HINT_BY_ID[key] || 'Nutze die Stationen entsprechend dem gewählten Archetyp und der Kombination-Beschreibung.'
}
export function sortCombinationSlotsForDisplay(slotsRaw) {
if (!Array.isArray(slotsRaw) || slotsRaw.length === 0) return []
return [...slotsRaw].sort((a, b) => {
const ia = Number(a.slot_index)
const ib = Number(b.slot_index)
const na = Number.isFinite(ia) ? ia : 0
const nb = Number.isFinite(ib) ? ib : 0
if (na !== nb) return na - nb
return String(a.title || '').localeCompare(String(b.title || ''), 'de')
})
}
/**
* Vorgabe Serien pro Station bei Steuerung rep/manual, wenn kein Wert in `slot_profiles_v1` steht.
* Nur Archetypen eintragen, die fachlich 1 verlangen; sonst Standard 1.
*/
export const ARCHETYPE_DEFAULT_REP_SERIES_COUNT = Object.freeze({})
export function defaultRepSeriesCountForArchetype(archetypeId) {
const key = archetypeId != null ? String(archetypeId).trim() : ''
const raw = key ? ARCHETYPE_DEFAULT_REP_SERIES_COUNT[key] : undefined
const n = typeof raw === 'number' ? raw : raw != null ? parseInt(String(raw), 10) : NaN
if (!Number.isFinite(n) || n < 1) return 1
return Math.round(n)
}

View File

@ -1,6 +1,10 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import {
PLANNING_MODULE_DISPLAY_MODES,
resolvePlanningModuleDisplayMode,
} from '../config/planningModuleUx'
import api from '../utils/api'
/**
@ -17,6 +21,12 @@ function AccountSettingsPage() {
const [joinMessage, setJoinMessage] = useState('')
const [joinBusy, setJoinBusy] = useState(false)
const [planningPrefsBusy, setPlanningPrefsBusy] = useState(false)
/** @type {[string, React.Dispatch<React.SetStateAction<string>>]} */
const [moduleDisplayDraft, setModuleDisplayDraft] = useState(
PLANNING_MODULE_DISPLAY_MODES.COMPACT_TAG_LEGEND
)
const [newPw1, setNewPw1] = useState('')
const [newPw2, setNewPw2] = useState('')
const [savingPw, setSavingPw] = useState(false)
@ -29,6 +39,11 @@ function AccountSettingsPage() {
setName(typeof user?.name === 'string' ? user.name : '')
}, [user])
useEffect(() => {
const m = resolvePlanningModuleDisplayMode(user?.training_planning_prefs?.module_display_mode)
setModuleDisplayDraft(m)
}, [user?.id, user?.training_planning_prefs])
const refreshJoinRequests = () => {
api.getMyClubJoinRequests().then(setMyJoinRequests).catch(() => {})
}
@ -93,6 +108,26 @@ function AccountSettingsPage() {
}
}
const handleSavePlanningPrefs = async (e) => {
e.preventDefault()
if (!user?.id) return
setPlanningPrefsBusy(true)
try {
const base =
user.training_planning_prefs && typeof user.training_planning_prefs === 'object'
? { ...user.training_planning_prefs }
: {}
const merged = { ...base, module_display_mode: moduleDisplayDraft }
await api.updateProfile(user.id, { training_planning_prefs: merged })
await checkAuth()
showOk('Planungs-Anzeige gespeichert.')
} catch (err) {
showErr(err.message || 'Konnte nicht speichern.')
} finally {
setPlanningPrefsBusy(false)
}
}
const handleResendVerification = async () => {
const em = user?.email
if (!em) return
@ -276,6 +311,49 @@ function AccountSettingsPage() {
</div>
</div>
<div className="card" style={{ marginBottom: '1rem' }}>
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Trainingsplanung</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
Wie kopiert aus der Modul-Bibliothek übernommenen Blöcken in einer Einheit dargestellt werden.
</p>
<form onSubmit={handleSavePlanningPrefs}>
<fieldset style={{ margin: 0, padding: 0, border: 'none' }}>
<legend className="form-label" style={{ marginBottom: '0.5rem' }}>
Darstellung Aus Modul
</legend>
<label
style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-start', cursor: 'pointer', marginBottom: '0.65rem' }}
>
<input
type="radio"
name="planning-module-display"
checked={moduleDisplayDraft === PLANNING_MODULE_DISPLAY_MODES.COMPACT_TAG_LEGEND}
onChange={() => setModuleDisplayDraft(PLANNING_MODULE_DISPLAY_MODES.COMPACT_TAG_LEGEND)}
/>
<span style={{ lineHeight: 1.45, fontSize: '0.9rem', color: 'var(--text1)' }}>
<strong>Kompakt</strong> farbige Markierung je Modul, Tag pro Zeile, Legende im Abschnitt.
</span>
</label>
<label
style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-start', cursor: 'pointer', marginBottom: '0.75rem' }}
>
<input
type="radio"
name="planning-module-display"
checked={moduleDisplayDraft === PLANNING_MODULE_DISPLAY_MODES.FULL_OUTLINE_HEADERS}
onChange={() => setModuleDisplayDraft(PLANNING_MODULE_DISPLAY_MODES.FULL_OUTLINE_HEADERS)}
/>
<span style={{ lineHeight: 1.45, fontSize: '0.9rem', color: 'var(--text1)' }}>
<strong>Ausführlich</strong> großer Modul-Kopfbereich mit nummerierter Übungsliste.
</span>
</label>
</fieldset>
<button type="submit" className="btn btn-secondary" disabled={planningPrefsBusy}>
{planningPrefsBusy ? 'Speichern…' : 'Anzeige speichern'}
</button>
</form>
</div>
<div className="card" style={{ marginBottom: '1rem' }}>
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Vereinsbeitritt</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>

View File

@ -1,90 +1,11 @@
import { useState, useEffect, useCallback } from 'react'
import { jsPDF } from 'jspdf'
import { FileText, Plus, Edit2, Archive, CheckCircle, Clock, Copy, Download, ChevronUp, ChevronDown } from 'lucide-react'
import { FileText, Plus, Edit2, Archive, CheckCircle, Clock, Copy, Download, ChevronUp, ChevronDown, Eye } from 'lucide-react'
import api from '../utils/api'
import { generateLegalPdf, legalSectionNumber } from '../utils/legalPdfExport'
import LegalDocumentBody from '../components/LegalDocumentBody'
import { LegalPreviewModal } from '../components/LegalDocumentPreview'
// PDF generation
function generateLegalPdf(doc) {
const pdf = new jsPDF({ format: 'a4', unit: 'mm' })
const marginL = 22
const marginR = 22
const marginTop = 28
const pageW = 210
const contentW = pageW - marginL - marginR
const bottomLimit = 277 // A4 297mm - 20mm bottom margin
let y = marginTop
const checkBreak = (need) => {
if (y + need > bottomLimit) {
pdf.addPage()
y = marginTop
}
}
// Title
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(20)
pdf.text(doc.title, marginL, y)
y += 10
// Meta line
const STATUS_DE = { published: 'Gültig seit', draft: 'Entwurf — Stand', archived: 'Archiviert — Stand' }
const dateStr = doc.published_at
? new Date(doc.published_at).toLocaleDateString('de-DE')
: new Date(doc.updated_at || doc.created_at).toLocaleDateString('de-DE')
const metaLine = `Version ${doc.version} | ${STATUS_DE[doc.status] || doc.status} ${dateStr}`
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(10)
pdf.setTextColor(90, 90, 90)
pdf.text(metaLine, marginL, y)
y += 3
pdf.setDrawColor(0, 0, 0)
pdf.setLineWidth(0.4)
pdf.line(marginL, y, pageW - marginR, y)
y += 8
pdf.setTextColor(0, 0, 0)
// Sections
for (const section of (doc.content_sections || [])) {
checkBreak(14)
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(11)
pdf.text(section.heading || '', marginL, y)
y += 6
if (section.content) {
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(10)
const lines = pdf.splitTextToSize(section.content, contentW)
for (const line of lines) {
checkBreak(5)
pdf.text(line, marginL, y)
y += 5
}
}
y += 5
}
// Footer on every page
const total = pdf.getNumberOfPages()
for (let i = 1; i <= total; i++) {
pdf.setPage(i)
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(8)
pdf.setTextColor(150, 150, 150)
const fy = 289
pdf.text(
`Shinkan Jinkendo | Exportiert am ${new Date().toLocaleDateString('de-DE')}`,
marginL, fy
)
pdf.text(`Seite ${i} von ${total}`, pageW - marginR, fy, { align: 'right' })
pdf.setTextColor(0, 0, 0)
}
pdf.save(`${doc.document_type}_v${doc.version}.pdf`)
}
const PDF_STATUS_META = { published: 'Gültig seit', draft: 'Entwurf — Stand', archived: 'Archiviert — Stand' }
// Sub-components
@ -190,13 +111,35 @@ function SectionEditor({ sections, onChange }) {
placeholder="Abschnittsüberschrift"
/>
</div>
<div className="form-row">
<label className="form-label">Inhalt</label>
<textarea
className="form-input" rows={4} value={sec.content}
onChange={e => update(i, 'content', e.target.value)}
placeholder="Textinhalt des Abschnitts"
/>
<div className="legal-section-split">
<div className="legal-section-split__editor">
<label className="form-label">
Inhalt
<span className="form-sub">
Einfache Markdown-Formatierung: **fett**, *kursiv*, Listen (- oder 1.), [Link](https://), Zeilenumbruch mit Leerzeile.
</span>
</label>
<textarea
className="form-input" rows={6} value={sec.content}
onChange={e => update(i, 'content', e.target.value)}
placeholder="Textinhalt des Abschnitts"
/>
</div>
<div className="legal-section-split__preview">
<span className="form-label" style={{ fontSize: '0.8rem', display: 'block', marginBottom: '6px' }}>
Live-Vorschau
</span>
<div className="legal-section-preview-box" style={{ marginBottom: 0 }}>
<h4>
{sec.heading?.trim()
? `${legalSectionNumber(i)} ${sec.heading}`
: legalSectionNumber(i)}
</h4>
{sec.content
? <LegalDocumentBody content={sec.content} />
: <span style={{ color: 'var(--text3)', fontSize: '0.85rem' }}>(noch kein Text)</span>}
</div>
</div>
</div>
</div>
<InsertButton afterIndex={i} />
@ -211,7 +154,7 @@ function SectionEditor({ sections, onChange }) {
)
}
function DocumentRow({ doc, onPublish, onArchive, onEdit, onCopy, onDownload, onViewAudit }) {
function DocumentRow({ doc, onPublish, onArchive, onEdit, onCopy, onDownload, onViewAudit, onRenderedPreview }) {
const [downloading, setDownloading] = useState(false)
const handleDownload = async () => {
@ -259,6 +202,10 @@ function DocumentRow({ doc, onPublish, onArchive, onEdit, onCopy, onDownload, on
<Archive size={13} />
</button>
)}
<button className="btn btn-secondary" style={{ padding: '4px 10px', fontSize: '0.78rem' }}
onClick={() => onRenderedPreview(doc)} title="Gerenderte Vorschau">
<Eye size={13} />
</button>
<button className="btn btn-secondary" style={{ padding: '4px 10px', fontSize: '0.78rem' }}
onClick={() => onCopy(doc)} title="Als neuen Entwurf kopieren">
<Copy size={13} />
@ -276,7 +223,7 @@ function DocumentRow({ doc, onPublish, onArchive, onEdit, onCopy, onDownload, on
)
}
function EditForm({ docType, editDoc, onSaved, onCancel }) {
function EditForm({ docType, editDoc, onSaved, onCancel, onShowRenderedPreview }) {
const [title, setTitle] = useState(editDoc ? editDoc.title : docType.defaultTitle)
const [sections, setSections] = useState([])
const [changeNote, setChangeNote] = useState('')
@ -342,10 +289,23 @@ function EditForm({ docType, editDoc, onSaved, onCancel }) {
onChange={e => setChangeNote(e.target.value)}
placeholder="z. B. Erste Version nach juristischer Prüfung" />
</div>
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.5rem' }}>
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.5rem', flexWrap: 'wrap' }}>
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !title.trim()}>
{saving ? 'Speichern…' : 'Entwurf speichern'}
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() =>
onShowRenderedPreview?.({
title,
sections,
metaLine: 'Aktueller Editorstand (nicht automatisch gespeichert)',
})}
style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}
>
<Eye size={15} /> Vollständige Vorschau
</button>
<button className="btn btn-secondary" onClick={onCancel}>Abbrechen</button>
</div>
</div>
@ -405,6 +365,13 @@ export default function AdminLegalDocumentsPage() {
const [editDoc, setEditDoc] = useState(null)
const [auditDocId, setAuditDocId] = useState(null)
const [confirmPublish, setConfirmPublish] = useState(null)
const [legalPreview, setLegalPreview] = useState({
open: false,
loading: false,
title: '',
sections: [],
metaLine: '',
})
const activeDocType = DOC_TYPES.find(d => d.key === activeType)
@ -452,9 +419,49 @@ export default function AdminLegalDocumentsPage() {
catch (e) { alert('Fehler: ' + e.message) }
}
const closeLegalPreview = () =>
setLegalPreview({ open: false, loading: false, title: '', sections: [], metaLine: '' })
const openLegalPreviewFromEditor = (payload) => {
setLegalPreview({
open: true,
loading: false,
title: payload.title,
sections: payload.sections,
metaLine: payload.metaLine || '',
})
}
const openLegalPreviewFromList = async (doc) => {
setLegalPreview({
open: true,
loading: true,
title: '',
sections: [],
metaLine: 'Laden…',
})
try {
const full = await api.getLegalDocument(doc.id)
setLegalPreview({
open: true,
loading: false,
title: full.title,
sections: full.content_sections || [],
metaLine: `Version ${full.version} · ${STATUS_LABELS[full.status]?.label || full.status}`,
})
} catch (e) {
closeLegalPreview()
alert('Fehler: ' + e.message)
}
}
const handleDownload = async (doc) => {
const full = await api.getLegalDocument(doc.id)
generateLegalPdf(full)
const dateStr = full.published_at
? new Date(full.published_at).toLocaleDateString('de-DE')
: new Date(full.updated_at || full.created_at).toLocaleDateString('de-DE')
const metaLine = `Version ${full.version} | ${PDF_STATUS_META[full.status] || full.status} ${dateStr}`
generateLegalPdf(full, metaLine)
}
const publishedDoc = documents.find(d => d.status === 'published')
@ -545,6 +552,7 @@ export default function AdminLegalDocumentsPage() {
onCopy={handleCopy}
onDownload={handleDownload}
onViewAudit={handleViewAudit}
onRenderedPreview={openLegalPreviewFromList}
/>
))
)}
@ -555,9 +563,19 @@ export default function AdminLegalDocumentsPage() {
editDoc={editDoc}
onSaved={handleSaved}
onCancel={() => { setShowForm(false); setEditDoc(null) }}
onShowRenderedPreview={openLegalPreviewFromEditor}
/>
)}
<LegalPreviewModal
open={legalPreview.open}
onClose={closeLegalPreview}
title={legalPreview.title}
sections={legalPreview.sections}
metaLine={legalPreview.metaLine}
loading={legalPreview.loading}
/>
{auditDocId && (
<AuditLog docId={auditDocId} onClose={() => setAuditDocId(null)} />
)}

View File

@ -4,6 +4,7 @@ import api from '../utils/api'
import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock'
import ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaStrip'
import { formatSkillLevelSlug } from '../constants/skillLevels'
import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
function TagRow({ exercise }) {
const tags = []
@ -136,6 +137,40 @@ function ExerciseDetailPage() {
{meta.length > 0 && <p className="exercise-meta-line">{meta.join(' · ')}</p>}
</div>
{(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' &&
Array.isArray(exercise.combination_slots) &&
exercise.combination_slots.length > 0 && (
<section className="card exercise-detail-section">
<h2>Stationen und Übungspools</h2>
{exercise.method_archetype ? (
<p style={{ fontSize: '14px', color: 'var(--text2)', marginTop: 0 }}>
Archetyp: <code>{String(exercise.method_archetype)}</code>
</p>
) : null}
<ol style={{ paddingLeft: '1.25rem', marginBottom: 0 }}>
{sortCombinationSlotsForDisplay(exercise.combination_slots).map((s, idx) => (
<li key={`${s.slot_index}-${idx}-${(s.title || '').slice(0, 8)}`} style={{ marginBottom: '10px' }}>
<strong>{(s.title || '').trim() || `Station ${idx + 1}`}</strong>
<ul style={{ margin: '4px 0 0', paddingLeft: '1.2rem' }}>
{(s.candidates && s.candidates.length
? s.candidates
: (s.candidate_exercise_ids || []).map((id) => ({
exercise_id: id,
title: null,
}))
).map((c) => (
<li key={c.exercise_id}>
<Link to={`/exercises/${c.exercise_id}`}>Übung #{c.exercise_id}</Link>
{c.title ? `${c.title}` : ''}
</li>
))}
</ul>
</li>
))}
</ol>
</section>
)}
{exercise.goal && (
<section className="card exercise-detail-section">
<h2>Ziel</h2>

View File

@ -7,6 +7,8 @@ import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGrap
import ExerciseMediaThumbTile from '../components/ExerciseMediaThumbTile'
import MediaPreviewModal from '../components/MediaPreviewModal'
import ReportContentModal from '../components/ReportContentModal'
import CombinationMethodProfileEditor from '../components/CombinationMethodProfileEditor'
import ExercisePickerModal from '../components/ExercisePickerModal'
import {
SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
buildExerciseMediaDragPayload,
@ -14,6 +16,9 @@ import {
import { autoScrollForDragNearEdges } from '../utils/dragAutoScroll'
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
import { useAuth } from '../context/AuthContext'
import { COMBINATION_ARCHETYPE_OPTIONS, ARCHETYPE_DEFAULT_REP_SERIES_COUNT, defaultRepSeriesCountForArchetype } from '../constants/combinationArchetypes'
import { readSlotProfilesV1, normalizeAdvanceMode, parseComboRepSeriesCountUi } from '../utils/combinationMethodProfileUi'
import { GripVertical } from 'lucide-react'
const INTENSITY_OPTIONS = [
{ value: '', label: '—' },
@ -30,6 +35,76 @@ const VARIANT_DIFFICULTY = [
{ value: 'adapted', label: 'Angepasst' },
]
/** HTML5-DnD für Kombi-Stationen (Reihenfolge = Ablauf). */
const DND_EXERCISE_COMBO_STATION = 'application/x-shinkan-exercise-combo-station-v1'
/** Pro Station meist 1 Übung; bis zu 3 wenn kurzer Auswahl-Pool sinnvoll ist. */
const MAX_COMBO_CANDIDATES_PER_STATION = 3
const comboTinyNumberInputSx = {
width: '3.5rem',
maxWidth: '100%',
padding: '4px 6px',
fontSize: '0.8125rem',
textAlign: 'center',
}
function emptyComboSlotRow() {
return {
title: '',
candidate_exercise_ids: [],
exercise_title_by_id: {},
advance_mode: 'timed',
load_sec: '',
consecutive_reps: '',
rep_series_count: '1',
intra_rep_rest_sec: '',
transition_after_sec: '',
}
}
function comboSlotsFromDetail(exercise) {
const raw = exercise?.combination_slots
const arch = exercise?.method_archetype != null ? String(exercise.method_archetype).trim() : ''
const serienFallback = defaultRepSeriesCountForArchetype(arch)
const mp =
exercise?.method_profile &&
typeof exercise.method_profile === 'object' &&
!Array.isArray(exercise.method_profile)
? exercise.method_profile
: {}
const spvList = readSlotProfilesV1(mp)
const byIx = new Map(spvList.map((r) => [Number(r.slot_index), r]))
if (!Array.isArray(raw) || raw.length === 0) {
return [emptyComboSlotRow()]
}
const sorted = [...raw].sort((a, b) => (Number(a.slot_index) || 0) - (Number(b.slot_index) || 0))
return sorted.map((s) => {
const si = Number(s.slot_index)
const st = byIx.get(si) || {}
const cands = Array.isArray(s.candidate_exercise_ids)
? s.candidate_exercise_ids.map((x) => Number(x)).filter((n) => Number.isFinite(n))
: []
const mode = normalizeAdvanceMode(st.advance_mode)
let repSer = ''
if (st.rep_series_count != null) repSer = String(st.rep_series_count)
else if (mode === 'rep' || mode === 'manual') repSer = String(serienFallback)
else repSer = '1'
return {
title: s.title != null ? String(s.title) : '',
candidate_exercise_ids: cands,
exercise_title_by_id: {},
advance_mode: mode,
load_sec: st.load_sec != null ? String(st.load_sec) : '',
consecutive_reps: st.consecutive_reps != null ? String(st.consecutive_reps) : '',
rep_series_count: repSer,
intra_rep_rest_sec: st.intra_rep_rest_sec != null ? String(st.intra_rep_rest_sec) : '',
transition_after_sec: st.transition_after_sec != null ? String(st.transition_after_sec) : '',
}
})
}
function emptyVariantDraft() {
return {
variant_name: '',
@ -249,6 +324,10 @@ function emptyForm() {
visibility: 'private',
status: 'draft',
skills: [],
exercise_kind: 'simple',
method_archetype: '',
method_profile_json: '{}',
combination_slots: [emptyComboSlotRow()],
}
}
@ -291,6 +370,18 @@ function detailToForm(exercise) {
required_level: normalizeSkillLevelSlug(s.required_level),
target_level: normalizeSkillLevelSlug(s.target_level),
})) || [],
exercise_kind:
String(exercise.exercise_kind || 'simple').toLowerCase() === 'combination'
? 'combination'
: 'simple',
method_archetype: exercise.method_archetype != null ? String(exercise.method_archetype) : '',
method_profile_json:
typeof exercise.method_profile === 'object' &&
exercise.method_profile != null &&
!Array.isArray(exercise.method_profile)
? JSON.stringify(exercise.method_profile, null, 2)
: '{}',
combination_slots: comboSlotsFromDetail(exercise),
}
}
@ -539,6 +630,85 @@ function ExerciseFormPage() {
setFormData((prev) => ({ ...prev, [field]: value }))
}
const [comboStationPickerIx, setComboStationPickerIx] = useState(null)
const [comboDropTargetIx, setComboDropTargetIx] = useState(null)
const reorderCombinationSlots = (fromI, toBeforeIx) => {
setFormDirty(true)
setFormData((prev) => {
const rows = [...(prev.combination_slots || [])]
if (fromI < 0 || fromI >= rows.length) return prev
const [moved] = rows.splice(fromI, 1)
let insertAt = toBeforeIx
if (fromI < toBeforeIx) insertAt = toBeforeIx - 1
insertAt = Math.max(0, Math.min(insertAt, rows.length))
rows.splice(insertAt, 0, moved)
return { ...prev, combination_slots: rows }
})
}
const patchComboSlotRow = (idx, patch) => {
setFormDirty(true)
setFormData((prev) => {
const rows = [...(prev.combination_slots || [])]
if (!rows[idx]) return prev
rows[idx] = { ...rows[idx], ...patch }
return { ...prev, combination_slots: rows }
})
}
const removeCandidateFromSlot = (slotIdx, exerciseId) => {
setFormDirty(true)
setFormData((prev) => {
const rows = [...(prev.combination_slots || [])]
const row = rows[slotIdx]
if (!row) return prev
const nextIds = (row.candidate_exercise_ids || []).filter((id) => Number(id) !== Number(exerciseId))
const labels = row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object' ? { ...row.exercise_title_by_id } : {}
delete labels[Number(exerciseId)]
rows[slotIdx] = { ...row, candidate_exercise_ids: nextIds, exercise_title_by_id: labels }
return { ...prev, combination_slots: rows }
})
}
const mergePickedExercisesIntoSlot = (slotIdx, pickedList) => {
if (!Array.isArray(pickedList) || !pickedList.length) return
const rowNow = (formData.combination_slots || [])[slotIdx] || emptyComboSlotRow()
const existingIds = Array.isArray(rowNow.candidate_exercise_ids)
? rowNow.candidate_exercise_ids.map((n) => Number(n)).filter((n) => Number.isFinite(n))
: []
const ordered = [...existingIds]
pickedList.forEach((ex) => {
if (ex?.id == null) return
const id = Number(ex.id)
if (!Number.isFinite(id)) return
if (!ordered.includes(id)) ordered.push(id)
})
let nextIds = ordered
if (nextIds.length > MAX_COMBO_CANDIDATES_PER_STATION) {
window.alert(
`Pro Station höchstens ${MAX_COMBO_CANDIDATES_PER_STATION} Übungen — üblich eine feste Übung; zwei bis drei nur als kleiner WechselPool. Überschüssige Auswahl wurde abgeschnitten.`,
)
nextIds = nextIds.slice(0, MAX_COMBO_CANDIDATES_PER_STATION)
}
setFormDirty(true)
setFormData((prev) => {
const rows = [...(prev.combination_slots || [])]
const row = rows[slotIdx] || emptyComboSlotRow()
const labels =
row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object' ? { ...row.exercise_title_by_id } : {}
pickedList.forEach((ex) => {
if (ex && ex.id != null) {
const id = Number(ex.id)
const t = (ex.title || '').trim()
if (t) labels[id] = t
}
})
rows[slotIdx] = { ...row, candidate_exercise_ids: nextIds, exercise_title_by_id: labels }
return { ...prev, combination_slots: rows }
})
}
const addSkillRow = () => {
const id = skillPick ? parseInt(skillPick, 10) : null
if (!id) {
@ -949,6 +1119,457 @@ function ExerciseFormPage() {
/>
</div>
<div
style={{
padding: '12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
marginBottom: '12px',
}}
>
<h3 style={{ marginTop: 0, marginBottom: '10px', fontSize: '1rem' }}>Übungstyp</h3>
<div className="form-row">
<label className="form-label">Art</label>
<select
className="form-input"
value={formData.exercise_kind === 'combination' ? 'combination' : 'simple'}
onChange={(e) => {
const nk = e.target.value
setFormDirty(true)
setFormData((prev) => ({
...prev,
exercise_kind: nk,
...(nk === 'simple'
? {
method_archetype: '',
method_profile_json: '{}',
combination_slots: [emptyComboSlotRow()],
}
: {}),
}))
}}
>
<option value="simple">Einzelübung</option>
<option value="combination">Kombinationsübung (Stationen / Pool)</option>
</select>
</div>
{formData.exercise_kind === 'combination' ? (
<>
<div className="form-row">
<label className="form-label">Methoden-Archetyp (für Coach &amp; Planung empfohlen)</label>
<select
className="form-input"
value={formData.method_archetype || ''}
onChange={(e) => {
const arch = (e.target.value || '').trim()
const forced = ARCHETYPE_DEFAULT_REP_SERIES_COUNT[arch]
setFormDirty(true)
setFormData((prev) => {
const slots = prev.combination_slots || []
const nextSlots =
forced !== undefined && forced !== null
? slots.map((row) =>
normalizeAdvanceMode(row.advance_mode) !== 'timed'
? {
...row,
rep_series_count: String(Math.max(1, Math.round(Number(forced)))),
}
: row,
)
: slots
return { ...prev, method_archetype: arch, combination_slots: nextSlots }
})
}}
>
<option value=""> noch nicht festgelegt </option>
{COMBINATION_ARCHETYPE_OPTIONS.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</div>
{String(formData.method_archetype || '').trim() === 'station_parcour' ? (
<p
style={{
fontSize: '12px',
color: 'var(--text2)',
margin: '4px 0 10px',
lineHeight: 1.48,
padding: '10px 12px',
borderRadius: '8px',
background: 'var(--surface)',
border: '1px solid var(--border)',
}}
>
<strong>Parcours / Bahnsystem:</strong> typischerweise starten alle an Station 1 und durchlaufen der
Reihe nach alle Punkte (Geschwindigkeit variabel). Die Stationsreihenfolge unten ist der Ablaufweg;
Zeitangaben pro Station und <strong>Gesamtdurchläufe</strong> im Ablaufprofil strukturieren das
spätere Coaching.
</p>
) : null}
<div style={{ paddingTop: '4px', borderTop: '1px dashed var(--border)', marginBottom: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: '8px', flexWrap: 'wrap' }}>
<strong style={{ fontSize: '14px' }}>Stationen</strong>
<span style={{ fontSize: '11px', color: 'var(--text3)' }}>
Ablauf = Reihenfolge · ziehen / Pfeile · Einzelübungen · max. {MAX_COMBO_CANDIDATES_PER_STATION}/Station
</span>
</div>
<p style={{ fontSize: '12px', color: 'var(--text2)', margin: '6px 0 12px', lineHeight: 1.48 }}>
Pro Station oft <strong>eine</strong> feste Übung; höchstens <strong>drei</strong> als kleiner AuswahlPool.
Unter <strong>Steuerung</strong> wählen: zeitlich, nach Wiederholungszahl oder ohne Arbeitsuhr (Coach führt).
</p>
{(formData.combination_slots || []).map((row, idx) => {
const candIds = Array.isArray(row.candidate_exercise_ids) ? row.candidate_exercise_ids : []
const comboPoolFull = candIds.length >= MAX_COMBO_CANDIDATES_PER_STATION
const slotAdv = normalizeAdvanceMode(row.advance_mode)
const serieLabel =
slotAdv === 'timed' ? 'Serie' : slotAdv === 'rep' ? 'Wdh. / Serie' : 'Richtwert'
const seriePlaceholder = slotAdv === 'rep' ? '10' : slotAdv === 'manual' ? '' : '1'
const showMultiSeries = slotAdv === 'rep' || slotAdv === 'manual'
const serienCountUi = parseComboRepSeriesCountUi(row.rep_series_count)
const showInterSeriesPause = showMultiSeries && serienCountUi >= 2
const intraLabel = slotAdv === 'timed' ? 'Pause (s)' : 'Pause zw. Serien'
const lbl =
row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object'
? row.exercise_title_by_id
: {}
const isDropHere = comboDropTargetIx === idx
return (
<div
key={`combo-slot-${idx}`}
onDragOver={(e) => {
if (!e.dataTransfer?.types?.includes?.(DND_EXERCISE_COMBO_STATION)) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
setComboDropTargetIx(idx)
}}
onDragLeave={() => setComboDropTargetIx((cur) => (cur === idx ? null : cur))}
onDrop={(e) => {
const rawFrom = e.dataTransfer.getData(DND_EXERCISE_COMBO_STATION)
const fromI = parseInt(rawFrom, 10)
e.preventDefault()
setComboDropTargetIx(null)
if (!Number.isFinite(fromI)) return
reorderCombinationSlots(fromI, idx)
}}
style={{
marginBottom: '12px',
padding: '12px 14px',
borderRadius: '12px',
border: `1px solid ${isDropHere ? 'var(--accent)' : 'var(--border)'}`,
background: 'var(--surface)',
boxShadow: isDropHere ? '0 0 0 2px var(--accent-soft)' : 'none',
}}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-start', marginBottom: '12px' }}>
<button
type="button"
draggable
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData(DND_EXERCISE_COMBO_STATION, String(idx))
}}
onDragEnd={() => setComboDropTargetIx(null)}
aria-label={`Station ${idx + 1} ziehen`}
title="Ziehen zum Sortieren"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
style={{ cursor: 'grab', padding: '6px 8px', touchAction: 'none' }}
>
<GripVertical size={18} strokeWidth={2} aria-hidden />
</button>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
aria-label="Station nach oben"
disabled={idx === 0}
onClick={() => reorderCombinationSlots(idx, idx - 1)}
>
</button>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
aria-label="Station nach unten"
disabled={idx === (formData.combination_slots || []).length - 1}
onClick={() => reorderCombinationSlots(idx, idx + 2)}
>
</button>
</div>
<div className="form-row" style={{ flex: '1 1 200px', marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '12px' }}>
Name (St.&nbsp;{idx + 1})
</label>
<input
type="text"
className="form-input"
value={row.title || ''}
placeholder="z.&nbsp;B. Liegestütz"
onChange={(e) => patchComboSlotRow(idx, { title: e.target.value })}
/>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'flex-end' }}>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
disabled={comboPoolFull}
title={
comboPoolFull
? `Max. ${MAX_COMBO_CANDIDATES_PER_STATION} Übungen — eine entfernen, um weitere zu wählen.`
: 'Einzelübung zur Station hinzufügen'
}
onClick={() => setComboStationPickerIx(idx)}
>
+ Übung
</button>
<button
type="button"
className="btn framework-ctrl framework-ctrl--xs"
style={{ fontSize: '12px' }}
title="Diese Station entfernen"
onClick={() => {
const prev = formData.combination_slots || []
const next = prev.filter((_, j) => j !== idx)
updateFormField('combination_slots', next.length ? next : [emptyComboSlotRow()])
}}
>
Entfernen
</button>
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<span className="form-label" style={{ fontSize: '11px', display: 'block', marginBottom: '6px' }}>
Übungen ({candIds.length}/{MAX_COMBO_CANDIDATES_PER_STATION})
</span>
{candIds.length === 0 ? (
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>
Mindestens eine Übung mit + Übung wählen.
</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{candIds.map((id) => (
<li
key={`${idx}-c-${id}`}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '4px 10px',
borderRadius: '999px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
fontSize: '12px',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '14rem' }} title={`#${id}`}>
{(lbl[id] || lbl[String(id)] || '').trim() || `Übung #${id}`}
</span>
<button
type="button"
className="tu-icon-btn"
aria-label={`Übung ${id} entfernen`}
title="Entfernen"
onClick={() => removeCandidateFromSlot(idx, id)}
>
</button>
</li>
))}
</ul>
)}
</div>
<div className="form-row" style={{ marginBottom: '8px', maxWidth: '22rem' }}>
<label className="form-label" style={{ fontSize: '11px' }}>
Steuerung
</label>
<select
className="form-input"
style={{ fontSize: '0.8125rem' }}
value={slotAdv}
onChange={(e) => {
const m = normalizeAdvanceMode(e.target.value)
const patch = { advance_mode: m }
if (m !== 'timed') patch.load_sec = ''
if (m === 'rep' || m === 'manual') {
const curSer = String(row.rep_series_count ?? '').trim()
if (!curSer) {
patch.rep_series_count = String(
defaultRepSeriesCountForArchetype(formData.method_archetype || ''),
)
}
}
patchComboSlotRow(idx, patch)
}}
>
<option value="timed">Zeit (Arbeit in Sekunden)</option>
<option value="rep">Wiederholungen (Ziel)</option>
<option value="manual">Coach (Weiter nach Freigabe)</option>
</select>
</div>
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '0 0 10px', lineHeight: 1.42 }}>
{slotAdv === 'timed'
? 'Arbeit (s): geplantes Ende nach Countdown möglich. Serie: Wiederholungen ohne Stationswechsel innerhalb einer Phase.'
: slotAdv === 'rep'
? 'Ohne Pflicht-Arbeits-Timer: Ziel über Wiederholungen. Ab zwei Serien: Pause zwischen diesen Serien; sonst nur Wechsel zur nächsten Station.'
: 'Coach: keine feste Arbeitsuhr — Fortschritt später per Tipp. Ab 2 Serien: Pause zwischen Serien; sonst nur Wechsel zur nächsten Station zeitlich planen.'}
</p>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(5.25rem, 1fr))',
gap: '8px 10px',
alignItems: 'end',
}}
>
{slotAdv === 'timed' ? (
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '10px' }}>
Arbeit (s)
</label>
<input
type="number"
min={0}
className="form-input"
style={comboTinyNumberInputSx}
placeholder=""
value={row.load_sec || ''}
onChange={(e) => patchComboSlotRow(idx, { load_sec: e.target.value })}
/>
</div>
) : null}
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '10px' }}>
{serieLabel}
</label>
<input
type="number"
min={slotAdv === 'rep' ? 1 : undefined}
className="form-input"
style={comboTinyNumberInputSx}
placeholder={seriePlaceholder}
value={row.consecutive_reps || ''}
onChange={(e) => patchComboSlotRow(idx, { consecutive_reps: e.target.value })}
/>
</div>
{showMultiSeries ? (
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '10px' }} title="Wie oft die angegebene Wdh.-Zahl hintereinander (mit Pause zw. Serien)?">
Serien
</label>
<input
type="number"
min={slotAdv === 'rep' ? 1 : undefined}
className="form-input"
style={comboTinyNumberInputSx}
placeholder="1"
value={row.rep_series_count || ''}
onChange={(e) => {
let rawSer = e.target.value.trim()
if (rawSer === '') rawSer = '1'
const pn = parseInt(String(rawSer).trim(), 10)
const patch = { rep_series_count: rawSer }
if (!Number.isFinite(pn) || pn < 2) patch.intra_rep_rest_sec = ''
patchComboSlotRow(idx, patch)
}}
/>
</div>
) : null}
{slotAdv === 'timed' || showInterSeriesPause ? (
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '10px' }}>
{intraLabel}
</label>
<input
type="number"
min={0}
className="form-input"
style={comboTinyNumberInputSx}
placeholder=""
value={row.intra_rep_rest_sec || ''}
onChange={(e) => patchComboSlotRow(idx, { intra_rep_rest_sec: e.target.value })}
/>
</div>
) : null}
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '10px' }}>
Wechsel (s)
</label>
<input
type="number"
min={0}
className="form-input"
style={comboTinyNumberInputSx}
placeholder=""
value={row.transition_after_sec || ''}
onChange={(e) => patchComboSlotRow(idx, { transition_after_sec: e.target.value })}
/>
</div>
</div>
{showMultiSeries && serienCountUi < 2 ? (
<p style={{ fontSize: '10px', color: 'var(--text3)', margin: '6px 0 0', lineHeight: 1.38 }}>
<strong>Wechsel (s)</strong> = Pause bis zur <strong>nächsten Station</strong>. Feld Pause zw.
Serien erscheint erst ab&nbsp;2 Serien (sonst keine Pause zwischen zwei Blöcken nötig).
</p>
) : null}
</div>
)
})}
<div
onDragOver={(e) => {
if (!e.dataTransfer?.types?.includes?.(DND_EXERCISE_COMBO_STATION)) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}}
onDrop={(e) => {
const rawFrom = e.dataTransfer.getData(DND_EXERCISE_COMBO_STATION)
const fromI = parseInt(rawFrom, 10)
e.preventDefault()
setComboDropTargetIx(null)
if (!Number.isFinite(fromI)) return
const len = (formData.combination_slots || []).length
reorderCombinationSlots(fromI, len)
}}
style={{
padding: '10px',
textAlign: 'center',
fontSize: '11px',
color: 'var(--text3)',
border: '1px dashed var(--border)',
borderRadius: '10px',
marginBottom: '8px',
}}
>
Hier ablegen zum Anhängen am Ende der Reihenfolge
</div>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px', marginTop: '4px' }}
onClick={() => updateFormField('combination_slots', [...(formData.combination_slots || []), emptyComboSlotRow()])}
>
+ Station
</button>
</div>
<div className="form-row">
<label className="form-label">Ablaufprofil (Runden &amp; global)</label>
<CombinationMethodProfileEditor
methodArchetype={formData.method_archetype || ''}
methodProfileJson={formData.method_profile_json || '{}'}
onChangeMethodProfileJson={(s) => updateFormField('method_profile_json', s)}
comboSlotsOutline={(formData.combination_slots || []).map((r, i) => ({
slot_index: i,
title: r.title || '',
}))}
omitPerSlotTiming
/>
</div>
</>
) : null}
</div>
<div className="form-row">
<label className="form-label">Ziel *</label>
<RichTextEditor
@ -1219,7 +1840,7 @@ function ExerciseFormPage() {
</form>
</div>
{isEdit && (
{isEdit && formData.exercise_kind !== 'combination' && (
<details ref={variantsDetailsRef} className="card exercise-variants-details" style={{ marginTop: '16px' }}>
<summary className="exercise-variants-summary">
<span className="exercise-variants-summary__title">Übungsvarianten</span>
@ -1380,7 +2001,7 @@ function ExerciseFormPage() {
</details>
)}
{isEdit && (
{isEdit && formData.exercise_kind !== 'combination' && (
<details className="card exercise-variants-details" style={{ marginTop: '16px' }}>
<summary className="exercise-variants-summary">
<span className="exercise-variants-summary__title">Progressionsgraph</span>
@ -1670,6 +2291,18 @@ function ExerciseFormPage() {
}
/>
)}
<ExercisePickerModal
open={comboStationPickerIx !== null}
onClose={() => setComboStationPickerIx(null)}
exerciseKindAny={['simple']}
multiSelect
enableQuickCreateDraft
onSelectExercises={(picked) => {
if (comboStationPickerIx === null) return
mergePickedExercisesIntoSlot(comboStationPickerIx, picked)
setComboStationPickerIx(null)
}}
/>
{reportTarget && (
<ReportContentModal
targetType="media_asset"

View File

@ -1408,6 +1408,11 @@ function ExercisesListPage() {
{typeNames.map((name) => (
<span key={`tt:${name}`} className="exercise-tag exercise-tag--training">{name}</span>
))}
{(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' ? (
<span className="exercise-tag" style={{ background: 'var(--accent-soft)', color: 'var(--accent-dark)' }}>
Kombination
</span>
) : null}
</div>
{exercise.summary && String(exercise.summary).trim() ? (
<div className="exercise-card-summary exercise-card-summary--rich">

View File

@ -1,76 +1,9 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { jsPDF } from 'jspdf'
import LegalDocumentBody from '../components/LegalDocumentBody'
import { generateLegalPdf, legalSectionNumber } from '../utils/legalPdfExport'
import api from '../utils/api'
function generateLegalPdf(doc) {
const pdf = new jsPDF({ format: 'a4', unit: 'mm' })
const marginL = 22, marginR = 22, marginTop = 28, pageW = 210
const contentW = pageW - marginL - marginR
const bottomLimit = 277
let y = marginTop
const checkBreak = (need) => {
if (y + need > bottomLimit) { pdf.addPage(); y = marginTop }
}
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(20)
pdf.text(doc.title, marginL, y)
y += 10
const dateStr = doc.published_at
? new Date(doc.published_at).toLocaleDateString('de-DE')
: new Date(doc.updated_at || doc.created_at).toLocaleDateString('de-DE')
const metaLine = `Version ${doc.version} | Gueltig seit ${dateStr}`
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(10)
pdf.setTextColor(90, 90, 90)
pdf.text(metaLine, marginL, y)
y += 3
pdf.setDrawColor(0, 0, 0)
pdf.setLineWidth(0.4)
pdf.line(marginL, y, pageW - marginR, y)
y += 8
pdf.setTextColor(0, 0, 0)
for (const section of (doc.content_sections || [])) {
checkBreak(14)
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(11)
pdf.text(section.heading || '', marginL, y)
y += 6
if (section.content) {
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(10)
const lines = pdf.splitTextToSize(section.content, contentW)
for (const line of lines) {
checkBreak(5)
pdf.text(line, marginL, y)
y += 5
}
}
y += 5
}
const total = pdf.getNumberOfPages()
for (let i = 1; i <= total; i++) {
pdf.setPage(i)
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(8)
pdf.setTextColor(150, 150, 150)
const fy = 289
pdf.text(
`Shinkan Jinkendo | Exportiert am ${new Date().toLocaleDateString('de-DE')}`,
marginL, fy
)
pdf.text(`Seite ${i} von ${total}`, pageW - marginR, fy, { align: 'right' })
}
pdf.save(`${doc.document_type}_v${doc.version}.pdf`)
}
// document_type values used in the DB / API
const TYPE_MAP = {
impressum: 'impressum',
@ -283,7 +216,13 @@ function LegalPage({ type }) {
<h1 style={{ margin: 0, color: 'var(--text1)' }}>{title}</h1>
{apiDoc && (
<button
onClick={() => generateLegalPdf(apiDoc)}
onClick={() => {
const dateStr = apiDoc.published_at
? new Date(apiDoc.published_at).toLocaleDateString('de-DE')
: new Date(apiDoc.updated_at || apiDoc.created_at).toLocaleDateString('de-DE')
const metaLine = `Version ${apiDoc.version} | Gueltig seit ${dateStr}`
generateLegalPdf(apiDoc, metaLine)
}}
className="btn btn-secondary"
style={{ fontSize: '0.82rem', padding: '4px 12px', flexShrink: 0 }}
>
@ -295,11 +234,11 @@ function LegalPage({ type }) {
{sections.map((section, i) => (
<div key={i} style={{ marginBottom: '1.75rem' }}>
<h2 style={{ fontSize: '1.05rem', marginBottom: '0.4rem', color: 'var(--text1)' }}>
{section.heading}
{section.heading
? `${legalSectionNumber(i)} ${section.heading}`
: legalSectionNumber(i)}
</h2>
<p style={{ color: isPlaceholder ? 'var(--text3)' : 'var(--text1)', fontStyle: isPlaceholder ? 'italic' : 'normal', margin: 0, whiteSpace: 'pre-wrap' }}>
{section.content}
</p>
<LegalDocumentBody content={section.content} muted={isPlaceholder} />
</div>
))}
</>

View File

@ -740,6 +740,16 @@ export default function TrainingCoachPage() {
exercise={catalogExercise}
exerciseId={currentEntry?.item?.exercise_id ?? null}
variantId={currentEntry?.item?.exercise_variant_id ?? null}
catalogMethodProfileSnapshot={
String(currentEntry?.item?.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
? currentEntry?.item?.catalog_method_profile ?? null
: null
}
planningComboMethodProfile={
String(currentEntry?.item?.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
? currentEntry?.item?.planning_method_profile ?? null
: null
}
/>
</div>
</>

View File

@ -609,6 +609,7 @@ export default function TrainingFrameworkProgramEditPage() {
<TrainingUnitSectionsEditor
heading={`Ablauf · Session ${si + 1}`}
sections={slot.sections}
betweenInsertMenus={false}
showExecutionExtras={false}
wideExerciseGrid
slotIndex={si}
@ -628,11 +629,15 @@ export default function TrainingFrameworkProgramEditPage() {
),
}))
}}
onRequestExercisePick={({ sectionIndex, itemIndex }) =>
onRequestExercisePick={({ sectionIndex, itemIndex, insertBeforeIndex }) =>
setSectionPickerCtx({
slotIdx: si,
sectionIndex,
itemIndex: typeof itemIndex === 'number' ? itemIndex : undefined,
insertBeforeIndex:
typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
? insertBeforeIndex
: undefined,
})
}
onPeekExercise={(id, variantId) =>
@ -1096,7 +1101,7 @@ export default function TrainingFrameworkProgramEditPage() {
if (row) rows.push(row)
}
if (!rows.length) return
const { slotIdx, sectionIndex: sIdx, itemIndex: iIdx } = sectionPickerCtx
const { slotIdx, sectionIndex: sIdx, itemIndex: iIdx, insertBeforeIndex } = sectionPickerCtx
setForm((prev) => ({
...prev,
slots: prev.slots.map((sl, ii) => {
@ -1121,7 +1126,13 @@ export default function TrainingFrameworkProgramEditPage() {
if (tail.length) items.splice(iIdx + 1, 0, ...tail)
return { ...sec, items }
}
return { ...sec, items: [...items, ...rows] }
const rawAt =
typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
? insertBeforeIndex
: items.length
const at = Math.max(0, Math.min(rawAt, items.length))
items.splice(at, 0, ...rows)
return { ...sec, items }
}),
}
}),

View File

@ -0,0 +1,523 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../utils/api'
import ExercisePickerModal from '../components/ExercisePickerModal'
import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm'
import { useAuth } from '../context/AuthContext'
import { activeClubMemberships, getResolvedActiveClubIdForUi } from '../utils/activeClub'
function nextLocalKey() {
return `m-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
function swapItems(arr, i, j) {
if (i === j || i < 0 || j < 0 || i >= arr.length || j >= arr.length) return [...arr]
const n = [...arr]
;[n[i], n[j]] = [n[j], n[i]]
return n
}
export default function TrainingModuleEditPage() {
const { id: routeId } = useParams()
const navigate = useNavigate()
const isNew = !routeId || routeId === 'new'
const moduleId = !isNew ? parseInt(routeId, 10) : NaN
const [loading, setLoading] = useState(!isNew)
const [saving, setSaving] = useState(false)
const [methods, setMethods] = useState([])
const [pickerOpen, setPickerOpen] = useState(false)
const [error, setError] = useState('')
const [title, setTitle] = useState('')
const [summary, setSummary] = useState('')
const [goal, setGoal] = useState('')
const [recommendedDurationMin, setRecommendedDurationMin] = useState('')
const [targetGroupNotes, setTargetGroupNotes] = useState('')
const [deploymentContextNotes, setDeploymentContextNotes] = useState('')
const [visibility, setVisibility] = useState('club')
const [clubIdField, setClubIdField] = useState('')
const [primaryMethodId, setPrimaryMethodId] = useState('')
const [items, setItems] = useState([])
const { user } = useAuth()
const clubChoices = useMemo(() => activeClubMemberships(user?.clubs ?? []), [user?.clubs])
useEffect(() => {
if (!isNew || visibility !== 'club') return
if ((clubIdField || '').trim() !== '') return
if (clubChoices.length === 1) setClubIdField(String(clubChoices[0].id))
else {
const r = getResolvedActiveClubIdForUi(user)
if (r) setClubIdField(String(r))
}
}, [isNew, visibility, clubIdField, clubChoices, user])
const itemsPayload = items.map((it, i) => {
if (it.item_type === 'note') {
return { item_type: 'note', order_index: i, note_body: it.note_body ?? '' }
}
const vid =
it.exercise_variant_id !== '' && it.exercise_variant_id != null
? parseInt(it.exercise_variant_id, 10)
: null
return {
item_type: 'exercise',
order_index: i,
exercise_id: parseInt(it.exercise_id, 10),
exercise_variant_id: Number.isFinite(vid) ? vid : null,
planned_duration_min:
it.planned_duration_min !== '' && it.planned_duration_min != null
? parseInt(String(it.planned_duration_min), 10)
: null,
notes: it.notes?.trim() ? it.notes.trim() : null,
}
})
const loadCatalogs = useCallback(async () => {
try {
const m = await api.listMethods({})
setMethods(Array.isArray(m) ? m : [])
} catch {
setMethods([])
}
}, [])
useEffect(() => {
loadCatalogs()
}, [loadCatalogs])
useEffect(() => {
if (isNew || !Number.isFinite(moduleId)) {
setLoading(false)
return
}
let cancelled = false
async function load() {
setLoading(true)
setError('')
try {
const m = await api.getTrainingModule(moduleId)
if (cancelled) return
setTitle((m.title || '').trim())
setSummary((m.summary || '').trim())
setGoal(m.goal || '')
setRecommendedDurationMin(
m.recommended_duration_min != null && m.recommended_duration_min !== ''
? String(m.recommended_duration_min)
: ''
)
setTargetGroupNotes(m.target_group_notes || '')
setDeploymentContextNotes(m.deployment_context_notes || '')
setVisibility((m.visibility || 'club').trim())
setClubIdField(m.club_id != null ? String(m.club_id) : '')
setPrimaryMethodId(m.primary_method_id != null ? String(m.primary_method_id) : '')
const nextItems = []
for (const row of Array.isArray(m.items) ? m.items : []) {
if (row.item_type === 'note') {
nextItems.push({ localKey: nextLocalKey(), item_type: 'note', note_body: row.note_body || '' })
continue
}
const ex = await hydrateExercisePlanningRow({
id: row.exercise_id,
title: '',
variants: [],
})
if (ex) {
ex.localKey = nextLocalKey()
if (row.exercise_variant_id) ex.exercise_variant_id = String(row.exercise_variant_id)
ex.planned_duration_min =
row.planned_duration_min != null && row.planned_duration_min !== ''
? String(row.planned_duration_min)
: ''
ex.notes = row.notes || ''
nextItems.push(ex)
}
}
setItems(nextItems)
} catch (e) {
if (!cancelled) setError(e.message || 'Laden fehlgeschlagen')
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => {
cancelled = true
}
}, [isNew, moduleId])
const buildBody = () => {
let cid = null
if (visibility === 'club') {
const raw = (clubIdField || '').trim()
if (raw !== '') {
const p = parseInt(raw, 10)
if (Number.isFinite(p) && p >= 1) cid = p
} else if (clubChoices.length === 1) {
cid = clubChoices[0].id
}
}
const pm =
primaryMethodId !== '' && primaryMethodId != null ? parseInt(primaryMethodId, 10) : null
return {
title: title.trim(),
summary: summary.trim() || null,
goal: goal.trim() || null,
recommended_duration_min:
recommendedDurationMin !== '' ? parseInt(recommendedDurationMin, 10) : null,
target_group_notes: targetGroupNotes.trim() || null,
deployment_context_notes: deploymentContextNotes.trim() || null,
visibility,
club_id:
cid != null && Number.isFinite(cid) && cid >= 1
? cid
: visibility === 'club'
? undefined
: null,
primary_method_id:
pm != null && Number.isFinite(pm) && pm >= 1 ? pm : null,
items: itemsPayload.filter((row) =>
row.item_type === 'note' ? true : Number.isFinite(row.exercise_id) && row.exercise_id >= 1
),
}
}
const handleSave = async (e) => {
e.preventDefault()
if (!title.trim()) {
alert('Titel ist Pflicht.')
return
}
setSaving(true)
setError('')
try {
const body = buildBody()
if (isNew) {
const created = await api.createTrainingModule(body)
navigate(`/planning/training-modules/${created.id}`, { replace: true })
} else {
await api.updateTrainingModule(moduleId, body)
alert('Trainingsmodul gespeichert.')
}
} catch (err) {
setError(err.message || 'Speichern fehlgeschlagen')
} finally {
setSaving(false)
}
}
const pickExercise = async (ex) => {
if (!ex?.id) return
const row = await hydrateExercisePlanningRow(ex)
if (row) row.localKey = nextLocalKey()
if (row) setItems((prev) => [...prev, row])
setPickerOpen(false)
}
return (
<div className="app-page">
<p style={{ marginBottom: '0.75rem' }}>
<Link to="/planning/training-modules" style={{ color: 'var(--accent-dark)', fontWeight: 600 }}>
Zurück zur ModulBibliothek
</Link>
</p>
<h1 className="page-title">{isNew ? 'Neues Trainingsmodul' : 'Trainingsmodul bearbeiten'}</h1>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', marginBottom: '1.25rem', maxWidth: '40rem' }}>
Reihenfolge der Positionen entspricht der späteren Übernahme in einen Abschnitt der Einheit (Kopie).
</p>
{error ? <p style={{ color: 'var(--danger)', marginBottom: '1rem' }}>{error}</p> : null}
{loading ? (
<p style={{ color: 'var(--text2)' }}>Laden </p>
) : (
<form className="card" style={{ padding: 'clamp(14px, 3vw, 1.75rem)', maxWidth: '720px' }} onSubmit={handleSave}>
<div className="form-row">
<label className="form-label">Titel *</label>
<input className="form-input" value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div className="form-row">
<label className="form-label">Kurzbeschreibung</label>
<textarea className="form-input" rows={2} value={summary} onChange={(e) => setSummary(e.target.value)} />
</div>
<div className="form-row">
<label className="form-label">Ziel</label>
<textarea className="form-input" rows={3} value={goal} onChange={(e) => setGoal(e.target.value)} />
</div>
<div className="form-row" style={{ display: 'grid', gap: '1rem', gridTemplateColumns: '1fr 1fr' }}>
<div>
<label className="form-label">Empfohlene Dauer (Min.)</label>
<input
className="form-input"
type="number"
min={0}
value={recommendedDurationMin}
onChange={(e) => setRecommendedDurationMin(e.target.value)}
/>
</div>
<div>
<label className="form-label">Primäre Trainingsmethode</label>
<select
className="form-input"
value={primaryMethodId}
onChange={(e) => setPrimaryMethodId(e.target.value)}
>
<option value=""></option>
{methods.map((m) => (
<option key={m.id} value={String(m.id)}>
{(m.name || '').trim() || `Methode #${m.id}`}
</option>
))}
</select>
</div>
</div>
<div className="form-row">
<label className="form-label">Empfohlene Zielgruppe (Freitext)</label>
<textarea
className="form-input"
rows={2}
value={targetGroupNotes}
onChange={(e) => setTargetGroupNotes(e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Einsatz / Kontext</label>
<textarea
className="form-input"
rows={2}
value={deploymentContextNotes}
onChange={(e) => setDeploymentContextNotes(e.target.value)}
/>
</div>
<div className="form-row" style={{ display: 'grid', gap: '1rem', gridTemplateColumns: '1fr 1fr' }}>
<div>
<label className="form-label">Sichtbarkeit</label>
<select
className="form-input"
value={visibility}
onChange={(e) => {
const v = e.target.value
setVisibility(v)
if (v !== 'club') {
setClubIdField('')
return
}
const xs = clubChoices
if (xs.length === 1) setClubIdField(String(xs[0].id))
else if (xs.length === 0) setClubIdField('')
else {
const resolved = getResolvedActiveClubIdForUi(user)
setClubIdField(resolved != null ? String(resolved) : '')
}
}}
>
<option value="private">Privat</option>
<option value="club">Vereinsintern</option>
<option value="official">Offiziell</option>
</select>
</div>
<div>
<label className="form-label">Verein (bei Vereinsintern)</label>
{visibility !== 'club' ? (
<p style={{ margin: '0.25rem 0 0', fontSize: '0.85rem', color: 'var(--text3)', lineHeight: 1.45 }}>
Bei privaten oder offiziellen Modulen ist keine Vereinszuordnung nötig (Server legt keine
Vereinsbindung fest).
</p>
) : clubChoices.length === 0 ? (
<p style={{ margin: '0.25rem 0 0', fontSize: '0.85rem', color: 'var(--danger)', lineHeight: 1.45 }}>
Kein aktiver Verein im Profil bitte zuerst einem Verein beitreten.
</p>
) : clubChoices.length === 1 ? (
<>
<input
className="form-input"
disabled
readOnly
value={
(clubChoices[0].short_name || clubChoices[0].name || '').trim() ||
`Verein #${clubChoices[0].id}`
}
/>
<p style={{ margin: '0.35rem 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
Fixiert durch deine Mitgliedschaft. Verein-ID {clubChoices[0].id} wird beim Speichern verwendet.
</p>
</>
) : (
<>
<select
className="form-input"
value={clubIdField}
onChange={(e) => setClubIdField(e.target.value)}
>
<option value="">Automatisch (aktueller Verein im Profil)</option>
{clubChoices.map((c) => {
const ln = `${((c.short_name || c.name || '').trim() || '') || `Verein #${c.id}`}`
return (
<option key={c.id} value={String(c.id)}>
{ln}
</option>
)
})}
</select>
<p style={{ margin: '0.35rem 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
Bei Automatisch entscheidet der aktiv gewählte Verein beim Speichern (wie bei anderen
Bibliotheksinhalten).
</p>
</>
)}
</div>
</div>
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '1.25rem 0' }} />
<h3 style={{ fontSize: '1rem', marginBottom: '0.75rem' }}>Positionen</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '12px' }}>
<button type="button" className="btn btn-secondary" onClick={() => setPickerOpen(true)}>
Übung hinzufügen
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() =>
setItems((prev) => [...prev, { localKey: nextLocalKey(), item_type: 'note', note_body: '' }])
}
>
Notiz hinzufügen
</button>
</div>
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '10px' }}>
{items.map((it, idx) => (
<li key={it.localKey || idx} style={{ padding: '10px', background: 'var(--surface2)', borderRadius: '8px' }}>
{it.item_type === 'note' ? (
<div>
<span style={{ fontWeight: 600, fontSize: '0.85rem', color: 'var(--text3)' }}>Notiz</span>
<textarea
className="form-input"
style={{ marginTop: '8px' }}
rows={2}
value={it.note_body}
onChange={(e) => {
const v = e.target.value
setItems((prev) =>
prev.map((x, j) =>
j === idx && x.item_type === 'note' ? { ...x, note_body: v } : x
)
)
}}
/>
</div>
) : (
<>
<div style={{ fontWeight: 700, wordBreak: 'break-word', marginBottom: '8px' }}>
{(it.exercise_title || '').trim() || `Übung #${it.exercise_id}`}
</div>
<div style={{ display: 'grid', gap: '8px', gridTemplateColumns: '1fr 1fr' }}>
<div>
<label className="form-label" style={{ fontSize: '0.78rem' }}>
Variante
</label>
<select
className="form-input"
value={String(it.exercise_variant_id ?? '')}
onChange={(e) => {
const v = e.target.value
setItems((prev) =>
prev.map((row, j) =>
j === idx && row.item_type === 'exercise' ? { ...row, exercise_variant_id: v } : row
)
)
}}
>
<option value=""></option>
{(it.variants || []).map((v) => (
<option key={v.id} value={String(v.id)}>
{(v.name || '').trim() || `Variante #${v.id}`}
</option>
))}
</select>
</div>
<div>
<label className="form-label" style={{ fontSize: '0.78rem' }}>
Minuten (plan)
</label>
<input
className="form-input"
type="number"
min={0}
value={it.planned_duration_min}
onChange={(e) =>
setItems((prev) =>
prev.map((row, j) =>
j === idx && row.item_type === 'exercise'
? { ...row, planned_duration_min: e.target.value }
: row
)
)
}
/>
</div>
</div>
<div style={{ marginTop: '8px' }}>
<label className="form-label" style={{ fontSize: '0.78rem' }}>
Hinweis zur Position
</label>
<input
className="form-input"
value={it.notes ?? ''}
onChange={(e) =>
setItems((prev) =>
prev.map((row, j) =>
j === idx && row.item_type === 'exercise' ? { ...row, notes: e.target.value } : row
)
)
}
/>
</div>
</>
)}
<div style={{ display: 'flex', gap: '8px', marginTop: '10px', flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-secondary"
disabled={idx < 1}
onClick={() => setItems((prev) => swapItems(prev, idx, idx - 1))}
>
Nach oben
</button>
<button
type="button"
className="btn btn-secondary"
disabled={idx >= items.length - 1}
onClick={() => setItems((prev) => swapItems(prev, idx, idx + 1))}
>
Nach unten
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => setItems((prev) => prev.filter((_, j) => j !== idx))}
>
Entfernen
</button>
</div>
</li>
))}
</ul>
<div style={{ display: 'flex', gap: '10px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Speichern …' : isNew ? 'Anlegen' : 'Speichern'}
</button>
<Link to="/planning/training-modules" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
Abbrechen
</Link>
</div>
</form>
)}
<ExercisePickerModal open={pickerOpen} onClose={() => setPickerOpen(false)} onSelectExercise={pickExercise} />
</div>
)
}

View File

@ -0,0 +1,131 @@
import React, { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
export default function TrainingModulesListPage() {
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.listTrainingModules()
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(`Trainingsmodul „${title || id}“ wirklich löschen?`)) return
try {
await api.deleteTrainingModule(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 className="page-title" style={{ marginBottom: '0.35rem' }}>
Trainingsmodule
</h1>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '38rem', margin: 0 }}>
Wiederverwendbare Übungsfolgen für die{' '}
<Link to="/planning" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
Trainingsplanung
</Link>
. Übernahme in eine Einheit erfolgt dort als lokale Kopie (mit Herkunftsmarkierung).
</p>
</div>
<Link to="/planning/training-modules/new" className="btn btn-primary" style={{ textDecoration: 'none' }}>
Neues Modul
</Link>
</div>
{error ? (
<p style={{ color: 'var(--danger)', marginBottom: '1rem' }}>{error}</p>
) : null}
{loading ? (
<p style={{ color: 'var(--text2)' }}>Laden </p>
) : rows.length === 0 ? (
<div className="card" style={{ padding: '1.25rem' }}>
<p style={{ margin: 0, color: 'var(--text2)' }}>Noch keine Module angelegt.</p>
</div>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '10px' }}>
{rows.map((r) => (
<li key={r.id} className="card" style={{ padding: '1rem 1.15rem' }}>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-between',
gap: '10px',
alignItems: 'flex-start',
}}
>
<div style={{ flex: '1 1 220px', minWidth: 0 }}>
<Link
to={`/planning/training-modules/${r.id}`}
style={{
fontWeight: 700,
fontSize: '1.05rem',
color: 'var(--accent-dark)',
textDecoration: 'none',
wordBreak: 'break-word',
}}
>
{(r.title || '').trim() || `Modul #${r.id}`}
</Link>
<p style={{ margin: '0.35rem 0 0', fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45 }}>
{(r.summary || '').trim() || '—'}{' '}
<span style={{ color: 'var(--text3)' }}>
({Number(r.items_count) || 0} Position{r.items_count === 1 ? '' : 'en'})
</span>
</p>
<p style={{ margin: '0.35rem 0 0', fontSize: '0.8rem', color: 'var(--text3)' }}>
Sichtbarkeit: <strong>{r.visibility || '—'}</strong>
</p>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
<Link
className="btn btn-secondary"
style={{ textDecoration: 'none' }}
to={`/planning/training-modules/${r.id}`}
>
Bearbeiten
</Link>
<button type="button" className="btn btn-secondary" onClick={() => handleDelete(r.id, r.title)}>
Löschen
</button>
</div>
</div>
</li>
))}
</ul>
)}
</div>
)
}

View File

@ -14,8 +14,18 @@ import {
enrichSectionsWithVariants,
buildSectionsPayload,
hydrateExercisePlanningRow,
insertTrainingModuleIntoPlanningSections,
} from '../utils/trainingUnitSectionsForm'
/** Kurz-Anzeige Sichtbarkeit (Trainingsmodule, Übungen) */
function trainingVisibilityShortDE(visibility) {
const v = String(visibility || '').trim().toLowerCase()
if (v === 'official') return 'Öffentliche Bibliothek'
if (v === 'club') return 'Verein'
if (v === 'private') return 'Privat'
return visibility ? String(visibility) : ''
}
function addDaysIsoDate(isoDay, daysDelta) {
const d = new Date(`${isoDay}T12:00:00`)
d.setDate(d.getDate() + daysDelta)
@ -144,6 +154,23 @@ function TrainingPlanningPage() {
const [fwImportIntervalDays, setFwImportIntervalDays] = useState(7)
const [fwImportSubmitting, setFwImportSubmitting] = useState(false)
const [moduleApplyOpen, setModuleApplyOpen] = useState(false)
const [moduleApplyBusy, setModuleApplyBusy] = useState(false)
const [moduleApplyList, setModuleApplyList] = useState([])
const [moduleApplyModuleId, setModuleApplyModuleId] = useState('')
const [moduleApplySectionIx, setModuleApplySectionIx] = useState(0)
const [moduleApplyInsertSlot, setModuleApplyInsertSlot] = useState('before:0')
const [moduleApplyErr, setModuleApplyErr] = useState('')
const [moduleApplyPlacementLocked, setModuleApplyPlacementLocked] = useState(false)
const [moduleApplySearchQuery, setModuleApplySearchQuery] = useState('')
const [modulePickPreview, setModulePickPreview] = useState({
loading: false,
moduleId: '',
exercises: [],
notes: 0,
err: '',
})
const [startDate, setStartDate] = useState(today)
const [endDate, setEndDate] = useState(thirtyDaysLater)
const [planView, setPlanView] = useState('list')
@ -180,6 +207,55 @@ function TrainingPlanningPage() {
const planningFormRef = useRef(formData)
planningFormRef.current = formData
const moduleApplyFilteredList = useMemo(() => {
const q = moduleApplySearchQuery.trim().toLowerCase().replace(/\s+/g, ' ')
const words = q ? q.split(' ').filter(Boolean) : []
const list = Array.isArray(moduleApplyList) ? moduleApplyList : []
if (!words.length) return list
return list.filter((m) => {
const blob = [
m.title,
m.summary,
m.goal,
m.target_group_notes,
m.deployment_context_notes,
]
.map((x) => String(x ?? '').toLowerCase())
.join('\n')
return words.every((w) => blob.includes(w))
})
}, [moduleApplySearchQuery, moduleApplyList])
const modulePlacementSummary = useMemo(() => {
const secs = Array.isArray(formData.sections) ? formData.sections : []
let si =
typeof moduleApplySectionIx === 'number'
? moduleApplySectionIx
: parseInt(String(moduleApplySectionIx), 10)
if (!Number.isFinite(si)) si = 0
si = Math.max(0, Math.min(si, secs.length ? secs.length - 1 : 0))
const cap = secs[si]?.items?.length ?? 0
let beforeIx = cap
if (typeof moduleApplyInsertSlot === 'string' && moduleApplyInsertSlot.startsWith('before:')) {
const zi = parseInt(moduleApplyInsertSlot.slice('before:'.length), 10)
if (Number.isFinite(zi)) beforeIx = Math.min(Math.max(0, zi), cap)
}
const rawTitle = (secs[si]?.title || '').trim()
const secTitle = rawTitle || `Abschnitt ${si + 1}`
let positionDescription
if (cap <= 0) positionDescription = 'als erste Einträge dieses Abschnitts'
else if (beforeIx <= 0) positionDescription = 'vor dem ersten Eintrag dieses Abschnitts'
else if (beforeIx >= cap) positionDescription = 'nach dem letzten Eintrag dieses Abschnitts'
else positionDescription = `unmittelbar vor Eintrag ${beforeIx + 1} (${cap} Einträge im Abschnitt)`
return { secTitle, positionDescription }
}, [formData.sections, moduleApplySectionIx, moduleApplyInsertSlot])
useEffect(() => {
if (!moduleApplyOpen || !moduleApplyFilteredList.length) return
if (moduleApplyFilteredList.some((m) => String(m.id) === String(moduleApplyModuleId))) return
setModuleApplyModuleId(String(moduleApplyFilteredList[0].id))
}, [moduleApplyOpen, moduleApplyFilteredList, moduleApplyModuleId])
const planningModalClubId = useMemo(() => {
const gid = Number(formData.group_id)
if (!Number.isFinite(gid) || gid < 1) return null
@ -189,6 +265,19 @@ function TrainingPlanningPage() {
return Number.isFinite(c) ? c : null
}, [groups, formData.group_id])
const moduleApplyTargetItems = useMemo(() => {
const secs = formData.sections || []
if (!secs.length) return []
let ix =
typeof moduleApplySectionIx === 'number'
? moduleApplySectionIx
: parseInt(String(moduleApplySectionIx), 10)
if (!Number.isFinite(ix)) ix = 0
if (ix < 0 || ix >= secs.length) return []
const sec = secs[ix]
return Array.isArray(sec?.items) ? sec.items : []
}, [formData.sections, moduleApplySectionIx])
const refreshPlanningSectionMeta = useCallback(async () => {
const next = await enrichSectionsWithVariants(planningFormRef.current.sections)
setFormData((prev) => ({ ...prev, sections: next }))
@ -662,6 +751,182 @@ function TrainingPlanningPage() {
}
}
const openModuleApplyModal = useCallback(async (placement) => {
setModuleApplyErr('')
setModuleApplySearchQuery('')
const placementLocked =
placement != null &&
typeof placement.sectionIndex === 'number' &&
typeof placement.insertBeforeIndex === 'number'
setModuleApplyPlacementLocked(placementLocked)
const secs = planningFormRef.current?.sections ?? []
let secIx = 0
let before = 0
if (secs.length) {
if (placement && typeof placement.sectionIndex === 'number') {
secIx = Math.min(Math.max(0, placement.sectionIndex), secs.length - 1)
const items = Array.isArray(secs[secIx]?.items) ? secs[secIx].items : []
const cap = items.length
if (typeof placement.insertBeforeIndex === 'number' && Number.isFinite(placement.insertBeforeIndex)) {
before = Math.min(Math.max(0, placement.insertBeforeIndex), cap)
} else before = cap
} else {
const items = Array.isArray(secs[0]?.items) ? secs[0].items : []
before = items.length
secIx = 0
}
}
setModuleApplySectionIx(secIx)
setModuleApplyInsertSlot(`before:${before}`)
setModuleApplyOpen(true)
try {
const list = await api.listTrainingModules()
const arr = Array.isArray(list) ? list : []
setModuleApplyList(arr)
setModuleApplyModuleId(arr.length ? String(arr[0].id) : '')
} catch (e) {
setModuleApplyErr(e.message || 'Module konnten nicht geladen werden')
setModuleApplyList([])
}
}, [])
const handleApplyTrainingModuleConfirm = useCallback(async () => {
const mid = parseInt(moduleApplyModuleId, 10)
if (!Number.isFinite(mid)) {
alert('Bitte ein Trainingsmodul wählen.')
return
}
let secIx = parseInt(String(moduleApplySectionIx), 10)
if (!Number.isFinite(secIx)) secIx = 0
const baseSections = planningFormRef.current?.sections ?? formData.sections ?? []
if (!baseSections.length) {
alert('Keine Abschnitte im Formular.')
return
}
if (secIx < 0 || secIx >= baseSections.length) secIx = 0
const secItems = Array.isArray(baseSections[secIx]?.items) ? baseSections[secIx].items : []
const itemCap = secItems.length
let insertBefore = itemCap
if (typeof moduleApplyInsertSlot === 'string' && moduleApplyInsertSlot.startsWith('before:')) {
const zi = parseInt(moduleApplyInsertSlot.slice('before:'.length), 10)
if (Number.isFinite(zi)) insertBefore = Math.min(Math.max(0, zi), itemCap)
}
setModuleApplyBusy(true)
setModuleApplyErr('')
try {
const detail = await api.getTrainingModule(mid)
let nextSections = await insertTrainingModuleIntoPlanningSections({
sections: baseSections,
moduleDetail: detail,
sectionIndex: secIx,
insertBeforeItemIndex: insertBefore,
})
nextSections = await enrichSectionsWithVariants(nextSections)
setFormData((fd) => ({ ...fd, sections: nextSections }))
setModuleApplyOpen(false)
setModuleApplyPlacementLocked(false)
} catch (e) {
setModuleApplyErr(e.message || 'Einfügen fehlgeschlagen')
} finally {
setModuleApplyBusy(false)
}
}, [moduleApplyModuleId, moduleApplySectionIx, moduleApplyInsertSlot])
useEffect(() => {
if (!moduleApplyOpen) {
setModulePickPreview({
loading: false,
moduleId: '',
exercises: [],
notes: 0,
err: '',
})
return undefined
}
const mid = parseInt(String(moduleApplyModuleId), 10)
if (!Number.isFinite(mid) || mid < 1) {
setModulePickPreview({
loading: false,
moduleId: '',
exercises: [],
notes: 0,
err: '',
})
return undefined
}
let cancelled = false
setModulePickPreview({
loading: true,
moduleId: String(mid),
exercises: [],
notes: 0,
err: '',
})
;(async () => {
try {
const detail = await api.getTrainingModule(mid)
if (cancelled) return
const itemsSorted = [...(detail.items ?? [])].sort(
(a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)
)
const uniqueEx = new Set()
let notes = 0
for (const row of itemsSorted) {
if ((row.item_type || '') !== 'note') {
const eid = row.exercise_id
if (eid) uniqueEx.add(Number(eid))
continue
}
const b = String(row.note_body ?? '').trim()
if (b === '---') continue
notes += 1
}
const titleById = new Map()
await Promise.all(
[...uniqueEx].map(async (eid) => {
try {
const ex = await api.getExercise(eid)
titleById.set(eid, (ex?.title || '').trim() || `Übung #${eid}`)
} catch {
titleById.set(eid, `Übung #${eid}`)
}
})
)
if (cancelled) return
const exTitlesInOrder = []
for (const row of itemsSorted) {
if ((row.item_type || '') !== 'exercise') continue
const eid = Number(row.exercise_id)
if (!Number.isFinite(eid)) continue
exTitlesInOrder.push(titleById.get(eid) || `Übung #${eid}`)
}
setModulePickPreview({
loading: false,
moduleId: String(mid),
exercises: exTitlesInOrder,
notes,
err: '',
})
} catch (e) {
if (!cancelled) {
setModulePickPreview({
loading: false,
moduleId: String(mid),
exercises: [],
notes: 0,
err: e?.message || 'Vorschau fehlgeschlagen',
})
}
}
})()
return () => {
cancelled = true
}
}, [moduleApplyOpen, moduleApplyModuleId])
const handleTakeLead = async (unit) => {
if (!user?.id) return
try {
@ -972,12 +1237,19 @@ function TrainingPlanningPage() {
</p>
<div className="card" style={{ marginBottom: '1.25rem', padding: '12px 14px' }}>
<p style={{ margin: 0, fontSize: '0.92rem', color: 'var(--text2)' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: '0.92rem', color: 'var(--text2)' }}>
Mehrere Einheiten strukturieren auf einmal:{' '}
<Link to="/planning/framework-programs" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
Trainingsrahmenprogramme
</Link>{' '}
(Ziele, Slots, Übungen als Vorlage).
(Ziele, Sessions, VorlagenAblauf).
</p>
<p style={{ margin: 0, fontSize: '0.92rem', color: 'var(--text2)' }}>
Wiederverwendbare Blöcke innerhalb einer Einheit:{' '}
<Link to="/planning/training-modules" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
Trainingsmodule
</Link>{' '}
(übernahme als Kopie beim Bearbeiten einer Einheit).
</p>
</div>
{!loading && groups.length === 0 && (
@ -1796,6 +2068,307 @@ function TrainingPlanningPage() {
</div>
) : null}
{moduleApplyOpen && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1010,
padding: '1rem',
overflowY: 'auto',
}}
role="presentation"
onMouseDown={(ev) => {
if (ev.target !== ev.currentTarget || moduleApplyBusy) return
setModuleApplyOpen(false)
setModuleApplyPlacementLocked(false)
}}
>
<div
className="card"
style={{
padding: 'clamp(14px, 3vw, 1.75rem)',
maxWidth: 'min(560px, 100%)',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
boxSizing: 'border-box',
}}
role="dialog"
aria-labelledby="module-apply-title"
>
<h2 id="module-apply-title" style={{ marginBottom: '0.5rem', fontSize: '1.15rem' }}>
Trainingsmodul einfügen
</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.87rem', marginBottom: '0.85rem', lineHeight: 1.5 }}>
Alle Positionen des gewählten Moduls werden <strong>als neue Zeilen</strong> eingefügt (Kopie, mit klarer
Herkunft im Ablauf). Die Einheit brauchst du dafür nicht vorher gespeichert zu haben Speichern am Ende
wie gewohnt. <strong>Vollständige Textsuche oder Modulkategorien</strong> planen wir serverseitig für
eine spätere Iteration; vorerst steht hier eine{' '}
<strong>Schnellsuche über Titel und Freitext-Felder</strong> zur Verfügung.
</p>
{moduleApplyErr ? (
<p style={{ color: 'var(--danger)', fontSize: '0.9rem', marginBottom: '0.75rem' }}>{moduleApplyErr}</p>
) : null}
{moduleApplyPlacementLocked ? (
<>
<p style={{ margin: '0 0 0.75rem', fontSize: '0.85rem', lineHeight: 1.5, color: 'var(--text2)' }}>
Aktuelle Einfügeposition: Abschnitt <strong>{modulePlacementSummary.secTitle}</strong>{' '}
<span aria-hidden>/</span> {modulePlacementSummary.positionDescription}
</p>
<details className="tu-module-apply-placement-details">
<summary style={{ outline: 'none' }}>Abschnitt oder Position ändern</summary>
<div style={{ marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.85rem' }}>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Abschnitt (Reihenfolge wie im Editor)</label>
<select
className="form-input"
value={String(moduleApplySectionIx)}
onChange={(e) => {
const newIx = parseInt(e.target.value, 10)
setModuleApplySectionIx(newIx)
const secsNow = planningFormRef.current?.sections ?? []
const len = Array.isArray(secsNow[newIx]?.items) ? secsNow[newIx].items.length : 0
setModuleApplyInsertSlot(`before:${len}`)
}}
disabled={moduleApplyBusy || !formData.sections?.length}
>
{(formData.sections || []).map((s, i) => (
<option key={`sec-opt-u-${i}`} value={String(i)}>
{(s.title || `Abschnitt ${i + 1}`).trim()}
</option>
))}
</select>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Position in diesem Abschnitt</label>
<select
className="form-input"
value={moduleApplyInsertSlot}
onChange={(e) => setModuleApplyInsertSlot(e.target.value)}
disabled={moduleApplyBusy || !(formData.sections?.length > 0)}
>
<option value={`before:${moduleApplyTargetItems.length}`}>
Am Ende einfügen (nach allen Einträgen)
</option>
<option value="before:0">An den Anfang (vor dem ersten Eintrag)</option>
{moduleApplyTargetItems.map((row, xi) => {
const labelPart =
row.item_type === 'note'
? 'Zwischen-Anmerkung'
: (row.exercise_title || '').trim() || `Übung #${row.exercise_id || '—'}`
const clipped =
labelPart.length > 44 ? `${labelPart.slice(0, 43).trim()}` : labelPart
return (
<option key={`before-u-${xi}`} value={`before:${xi}`}>
Vor Eintrag {xi + 1}: {clipped}
</option>
)
})}
</select>
</div>
</div>
</details>
</>
) : (
<>
<div className="form-row">
<label className="form-label">Abschnitt (Reihenfolge wie im Editor)</label>
<select
className="form-input"
value={String(moduleApplySectionIx)}
onChange={(e) => {
const newIx = parseInt(e.target.value, 10)
setModuleApplySectionIx(newIx)
const secsNow = planningFormRef.current?.sections ?? []
const len = Array.isArray(secsNow[newIx]?.items) ? secsNow[newIx].items.length : 0
setModuleApplyInsertSlot(`before:${len}`)
}}
disabled={moduleApplyBusy || !formData.sections?.length}
>
{(formData.sections || []).map((s, i) => (
<option key={`sec-opt-${i}`} value={String(i)}>
{(s.title || `Abschnitt ${i + 1}`).trim()}
</option>
))}
</select>
</div>
<div className="form-row">
<label className="form-label">Position in diesem Abschnitt</label>
<select
className="form-input"
value={moduleApplyInsertSlot}
onChange={(e) => setModuleApplyInsertSlot(e.target.value)}
disabled={moduleApplyBusy || !(formData.sections?.length > 0)}
>
<option value={`before:${moduleApplyTargetItems.length}`}>
Am Ende einfügen (nach allen Einträgen)
</option>
<option value="before:0">An den Anfang (vor dem ersten Eintrag)</option>
{moduleApplyTargetItems.map((row, xi) => {
const labelPart =
row.item_type === 'note'
? 'Zwischen-Anmerkung'
: (row.exercise_title || '').trim() || `Übung #${row.exercise_id || '—'}`
const clipped =
labelPart.length > 44 ? `${labelPart.slice(0, 43).trim()}` : labelPart
return (
<option key={`before-${xi}`} value={`before:${xi}`}>
Vor Eintrag {xi + 1}: {clipped}
</option>
)
})}
</select>
</div>
</>
)}
<div className="form-row" style={{ marginTop: moduleApplyPlacementLocked ? '1rem' : undefined }}>
<label className="form-label">Suche Module</label>
<input
type="search"
enterKeyHint="search"
className="form-input tu-modulepick-search"
placeholder="Freitext: Titel, Kurzbeschreibung, Ziel, Zielgruppe …"
value={moduleApplySearchQuery}
onChange={(e) => setModuleApplySearchQuery(e.target.value)}
disabled={moduleApplyBusy}
aria-label="Module durch Freitext filtern"
/>
</div>
<div className="form-row" style={{ marginBottom: '0.65rem' }}>
<label className="form-label" id="module-pick-label">
Modulliste
</label>
</div>
<div
className="tu-modulepick-list"
role="listbox"
aria-labelledby="module-pick-label"
aria-activedescendant={
moduleApplyModuleId ? `module-pick-opt-${moduleApplyModuleId}` : undefined
}
>
{!moduleApplyFilteredList.length ? (
<p style={{ margin: '0.45rem', fontSize: '0.86rem', color: 'var(--text3)', lineHeight: 1.45 }}>
{!moduleApplyList.length ? 'Keine Module verfügbar oder keine Berechtigung.' : 'Kein Modul entspricht der Suche.'}
</p>
) : (
moduleApplyFilteredList.map((m) => {
const title = ((m.title || '').trim() || `Modul #${m.id}`).trim()
const visLbl = trainingVisibilityShortDE(m.visibility)
const nPos = typeof m.items_count === 'number' ? m.items_count : '—'
const selected = String(m.id) === String(moduleApplyModuleId)
return (
<button
key={m.id}
id={`module-pick-opt-${m.id}`}
type="button"
role="option"
aria-selected={selected}
className={`tu-modulepick-item${selected ? ' tu-modulepick-item--active' : ''}`}
disabled={moduleApplyBusy}
onClick={() => setModuleApplyModuleId(String(m.id))}
>
<span className="tu-modulepick-item__title">{title}</span>
<span className="tu-modulepick-item__meta">
{nPos} {typeof nPos === 'number' ? (nPos === 1 ? 'Position' : 'Positionen') : 'Position(en)'}
{visLbl ? <> · {visLbl}</> : null}
{m.summary ? <> · {(m.summary || '').trim().slice(0, 72)}{(m.summary || '').trim().length > 72 ? '…' : ''}</> : null}
</span>
</button>
)
})
)}
</div>
{moduleApplyModuleId ? (
<div className="tu-modulepick-preview" aria-live="polite">
<div className="tu-modulepick-preview__title">Ablauf-Vorschau (Bibliotheksmodul)</div>
{modulePickPreview.loading ? (
<p style={{ margin: '0.15rem 0 0', fontSize: '0.86rem', color: 'var(--text3)' }}>
Übungen und Hinweise laden
</p>
) : modulePickPreview.err ? (
<p style={{ margin: '0.15rem 0 0', fontSize: '0.86rem', color: 'var(--danger)' }}>
{modulePickPreview.err}
</p>
) : !modulePickPreview.exercises.length && !modulePickPreview.notes ? (
<p style={{ margin: '0.15rem 0 0', fontSize: '0.86rem', color: 'var(--text3)' }}>
Keine Übungspositionen in diesem Eintrag gefunden (prüfen, ob Übungen im Modul gültige IDs haben).
</p>
) : (
<>
<ol className="tu-modulepick-preview__list">
{(modulePickPreview.exercises.slice(0, 12)).map((t, qi) => (
<li key={`pv-ex-${qi}`}>{t}</li>
))}
</ol>
{modulePickPreview.exercises.length > 12 ? (
<p className="tu-modulepick-preview__more">
und noch {modulePickPreview.exercises.length - 12} weitere Übungen in genau dieser Modulreihenfolge.
</p>
) : null}
{modulePickPreview.notes > 0 ? (
<p className="tu-modulepick-preview__more">
zusätzlich {modulePickPreview.notes}{' '}
{modulePickPreview.notes === 1 ? 'Position mit Hinweis' : 'Positionen mit Hinweisen'}{' '}
(ohne Aufzählung)
</p>
) : null}
</>
)}
</div>
) : null}
<div
style={{
display: 'flex',
gap: '0.65rem',
flexWrap: 'wrap',
justifyContent: 'flex-end',
marginTop: '1.25rem',
}}
>
<button
type="button"
className="btn btn-secondary"
disabled={moduleApplyBusy}
onClick={() => {
if (moduleApplyBusy) return
setModuleApplyOpen(false)
setModuleApplyPlacementLocked(false)
}}
>
Abbrechen
</button>
<button type="button" className="btn btn-primary" disabled={moduleApplyBusy} onClick={handleApplyTrainingModuleConfirm}>
{moduleApplyBusy ? 'Einfügen …' : 'Einfügen'}
</button>
</div>
<p style={{ margin: '1rem 0 0', fontSize: '0.8rem', color: 'var(--text3)', lineHeight: 1.45 }}>
Neue Module kannst du unter{' '}
<Link to="/planning/training-modules" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
Trainingsmodule
</Link>{' '}
anlegen.
</p>
</div>
</div>
)}
{frameworkImportOpen && (
<div
style={{
@ -2291,9 +2864,11 @@ function TrainingPlanningPage() {
<TrainingUnitSectionsEditor
heading="Abschnitte & Übungen"
headingAccessory={
<button type="button" className="btn btn-secondary" onClick={handleSaveAsTemplate}>
Vorlage aus Aufbau speichern
</button>
<>
<button type="button" className="btn btn-secondary" onClick={handleSaveAsTemplate}>
Vorlage aus Aufbau speichern
</button>
</>
}
sections={formData.sections}
wideExerciseGrid
@ -2303,15 +2878,26 @@ function TrainingPlanningPage() {
sections: updater(prev.sections),
}))
}
onRequestExercisePick={({ sectionIndex, itemIndex }) => {
onRequestTrainingModulePick={(ctx) => {
void openModuleApplyModal(ctx)
}}
onRequestExercisePick={({ sectionIndex, itemIndex, insertBeforeIndex }) => {
setExercisePickerTarget({
sIdx: sectionIndex,
iIdx: typeof itemIndex === 'number' ? itemIndex : undefined,
insertBeforeIndex:
typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
? insertBeforeIndex
: undefined,
})
setExercisePickerOpen(true)
}}
onPeekExercise={(id, variantId) =>
setPlanningPeekCtx({ exerciseId: id, variantId: variantId ?? null })
onPeekExercise={(id, variantId, peekExtras) =>
setPlanningPeekCtx({
exerciseId: id,
variantId: variantId ?? null,
peekExtras: peekExtras ?? null,
})
}
showExecutionExtras={Boolean(editingUnit) && sectionsEditMode === 'debrief'}
/>
@ -2459,7 +3045,7 @@ function TrainingPlanningPage() {
if (row) rows.push(row)
}
if (!rows.length) return
const { sIdx, iIdx } = exercisePickerTarget
const { sIdx, iIdx, insertBeforeIndex } = exercisePickerTarget
setFormData((prev) => ({
...prev,
sections: prev.sections.map((s, si) => {
@ -2479,7 +3065,13 @@ function TrainingPlanningPage() {
if (tail.length) items.splice(iIdx + 1, 0, ...tail)
return { ...s, items }
}
return { ...s, items: [...items, ...rows] }
const rawAt =
typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
? insertBeforeIndex
: items.length
const at = Math.max(0, Math.min(rawAt, items.length))
items.splice(at, 0, ...rows)
return { ...s, items }
}),
}))
setExercisePickerOpen(false)
@ -2490,6 +3082,7 @@ function TrainingPlanningPage() {
open={planningPeekCtx != null}
exerciseId={planningPeekCtx?.exerciseId}
variantId={planningPeekCtx?.variantId ?? undefined}
peekExtras={planningPeekCtx?.peekExtras ?? undefined}
onClose={() => setPlanningPeekCtx(null)}
/>
</div>

View File

@ -5,7 +5,9 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../utils/api'
import ExercisePeekModal from '../components/ExercisePeekModal'
import CombinationPlanBracket from '../components/CombinationPlanBracket'
import { itemStableKey, sortedSections, sortedItems } from '../utils/trainingPlanUtils'
import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
function storageKey(unitId) {
return `sj_training_run_checked_${unitId}`
@ -146,6 +148,7 @@ export default function TrainingUnitRunPage() {
open={peekCtx != null}
exerciseId={peekCtx?.exerciseId}
variantId={peekCtx?.variantId ?? undefined}
peekExtras={peekCtx?.peekExtras ?? undefined}
onClose={() => setPeekCtx(null)}
/>
@ -271,7 +274,15 @@ export default function TrainingUnitRunPage() {
const plan = formatMin(it.planned_duration_min)
const extras = []
if (it.exercise_focus_area) extras.push(it.exercise_focus_area)
const metaParts = [...extras, plan].filter(Boolean)
const exKind = String(it.exercise_kind || 'simple').toLowerCase().trim()
const isComboRow = exKind === 'combination'
const metaParts = [...extras, isComboRow ? 'Kombination' : null, plan].filter(Boolean)
const comboEffectiveProfile = isComboRow
? effectiveComboMethodProfile(
it.catalog_method_profile || {},
it.planning_method_profile ?? null,
)
: null
return (
<li key={ck} className={`training-run-item training-run-item--exercise${done ? ' training-run-item--done' : ''}`}>
@ -309,6 +320,22 @@ export default function TrainingUnitRunPage() {
)}
</div>
)}
{isComboRow && it.exercise_id ? (
<div className="training-run-combo-embed">
<CombinationPlanBracket
methodArchetype={String(it.catalog_method_archetype || '').trim()}
methodProfile={comboEffectiveProfile || {}}
combinationSlots={
Array.isArray(it.combination_slots) ? it.combination_slots : []
}
planningAdjusted={
it.planning_method_profile != null &&
typeof it.planning_method_profile === 'object' &&
!Array.isArray(it.planning_method_profile)
}
/>
</div>
) : null}
{it.exercise_id && (
<div className="no-print" style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', marginTop: '0.55rem', alignItems: 'center' }}>
<button
@ -318,7 +345,16 @@ export default function TrainingUnitRunPage() {
onClick={() =>
setPeekCtx({
exerciseId: it.exercise_id,
variantId: it.exercise_variant_id != null ? Number(it.exercise_variant_id) : null,
variantId:
it.exercise_variant_id != null
? Number(it.exercise_variant_id)
: null,
peekExtras: isComboRow
? {
catalog_method_profile: it.catalog_method_profile,
planning_method_profile: it.planning_method_profile,
}
: undefined,
})
}
>

View File

@ -5,6 +5,7 @@
*/
import { stripHtmlToText } from './htmlUtils'
import { normalizeAdvanceMode, parseComboRepSeriesCountUi } from './combinationMethodProfileUi'
const API_URL = import.meta.env.VITE_API_URL || ''
@ -451,7 +452,7 @@ export function buildExerciseApiPayload(formData, extras = {}) {
.filter((x) => x && x.target_group_id)
.map((x) => ({ target_group_id: Number(x.target_group_id), is_primary: !!x.is_primary }))
return {
const payload = {
title: (formData.title || '').trim(),
summary: formData.summary || null,
goal: goalHtml.trim() ? goalHtml : null,
@ -478,8 +479,106 @@ export function buildExerciseApiPayload(formData, extras = {}) {
visibility: formData.visibility || 'private',
status: formData.status || 'draft',
club_id: formData.club_id ?? null,
exercise_kind:
String(formData.exercise_kind || 'simple').toLowerCase() === 'combination'
? 'combination'
: 'simple',
...extras,
}
const isCombo = payload.exercise_kind === 'combination'
if (isCombo) {
let mpObj = {}
const mpRaw = typeof formData.method_profile_json === 'string' ? formData.method_profile_json.trim() : ''
if (mpRaw) {
try {
const parsed = JSON.parse(mpRaw)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Ablaufprofil muss ein JSON-Objekt sein.')
}
mpObj = parsed
} catch (e) {
if (e instanceof SyntaxError) {
throw new Error('Ablaufprofil (JSON): Syntax ungültig.')
}
throw e
}
}
const slotRows = Array.isArray(formData.combination_slots) ? formData.combination_slots : []
const combination_slots = []
function parseTimingField(raw) {
if (raw === '' || raw == null || raw === undefined) return undefined
const n = parseInt(String(raw), 10)
return Number.isFinite(n) ? n : undefined
}
for (let i = 0; i < slotRows.length; i += 1) {
const row = slotRows[i] || {}
let ids = Array.isArray(row.candidate_exercise_ids)
? row.candidate_exercise_ids.map((x) => Number(x)).filter((n) => Number.isFinite(n))
: []
/** Legacy: noch idsText Unterstützung für Import von älteren FormStand */
if ((!ids || ids.length === 0) && typeof row.idsText === 'string' && row.idsText.trim()) {
ids = row.idsText
.split(/[\s,;]+/)
.map((s) => s.trim())
.filter(Boolean)
.map((s) => parseInt(s, 10))
.filter((n) => Number.isFinite(n))
}
combination_slots.push({
slot_index: i,
title: (typeof row.title === 'string' && row.title.trim()) || null,
candidate_exercise_ids: ids,
})
}
const slot_profiles_v1_next = []
for (let i = 0; i < slotRows.length; i += 1) {
const row = slotRows[i] || {}
const o = { slot_index: i }
const advanceMode = normalizeAdvanceMode(row.advance_mode)
if (advanceMode !== 'timed') o.advance_mode = advanceMode
const load = parseTimingField(row.load_sec)
const crs = parseTimingField(row.consecutive_reps)
const rsc = parseTimingField(row.rep_series_count)
const intra = parseTimingField(row.intra_rep_rest_sec)
const tran = parseTimingField(row.transition_after_sec)
const serienUi = parseComboRepSeriesCountUi(row.rep_series_count)
const allowInterSeriesPause =
advanceMode === 'timed' ||
((advanceMode === 'rep' || advanceMode === 'manual') && serienUi >= 2)
if (advanceMode === 'timed' && load !== undefined && load >= 0) o.load_sec = Math.round(load)
if (crs !== undefined && crs >= 1) o.consecutive_reps = Math.round(crs)
if (
rsc !== undefined &&
rsc >= 1 &&
(advanceMode === 'rep' || advanceMode === 'manual')
) {
o.rep_series_count = Math.round(rsc)
}
if (intra !== undefined && intra >= 0 && allowInterSeriesPause) o.intra_rep_rest_sec = Math.round(intra)
if (tran !== undefined && tran >= 0) o.transition_after_sec = Math.round(tran)
if (Object.keys(o).length > 1) slot_profiles_v1_next.push(o)
}
payload.method_archetype = (formData.method_archetype || '').trim() || null
if (slot_profiles_v1_next.length > 0) mpObj.slot_profiles_v1 = slot_profiles_v1_next
else delete mpObj.slot_profiles_v1
payload.method_profile = mpObj
payload.combination_slots = combination_slots
} else {
payload.method_archetype = null
payload.method_profile = {}
}
return payload
}
export async function uploadExerciseMedia(exerciseId, formData) {
@ -1327,6 +1426,40 @@ export async function deleteTrainingPlanTemplate(id) {
return request(`/api/training-plan-templates/${id}`, { method: 'DELETE' })
}
export async function listTrainingModules() {
return request('/api/training-modules')
}
export async function getTrainingModule(id) {
return request(`/api/training-modules/${id}`)
}
export async function createTrainingModule(data) {
return request('/api/training-modules', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateTrainingModule(id, data) {
return request(`/api/training-modules/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteTrainingModule(id) {
return request(`/api/training-modules/${id}`, { method: 'DELETE' })
}
/** Kopiert Modul-Inhalte ans Ende eines Abschnitts (section_order_index 0-basiert). */
export async function applyTrainingModuleToTrainingUnit(unitId, data) {
return request(`/api/training-units/${unitId}/apply-training-module`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function listTrainingFrameworkPrograms() {
return request('/api/training-framework-programs')
}
@ -1478,6 +1611,12 @@ export const api = {
createTrainingPlanTemplate,
updateTrainingPlanTemplate,
deleteTrainingPlanTemplate,
listTrainingModules,
getTrainingModule,
createTrainingModule,
updateTrainingModule,
deleteTrainingModule,
applyTrainingModuleToTrainingUnit,
listTrainingFrameworkPrograms,
getTrainingFrameworkProgram,
createTrainingFrameworkProgram,

View File

@ -0,0 +1,608 @@
/**
* Geführtes method_profile für Kombinationsübungen Felder nach method_archetype.
* Unbekannte JSON-Schlüssel bleiben beim Zusammenführen erhalten (Erweiterbarkeit).
*/
const INT_MAX = 86400
/** Pro Station: zeitlich (Standard), mengenorientiert oder coachgeführt (ohne Arbeits-Countdown). */
export const SLOT_ADVANCE_MODES = Object.freeze(['timed', 'rep', 'manual'])
export function normalizeAdvanceMode(v) {
const s = typeof v === 'string' ? v.trim().toLowerCase() : ''
if (s === 'rep' || s === 'reps' || s === 'count') return 'rep'
if (s === 'manual' || s === 'coach' || s === 'coach_led') return 'manual'
return 'timed'
}
/**
* Modus aus Roh-Zeile (Legacy ohne advance_mode; oder nur ZielWdh. ohne Sekunden).
*/
export function inferAdvanceModeFromStoredSlotRow(row) {
if (!row || typeof row !== 'object') return 'timed'
const explicitRaw = row.advance_mode
if (explicitRaw !== undefined && explicitRaw !== null && String(explicitRaw).trim() !== '') {
const e = normalizeAdvanceMode(explicitRaw)
return e === 'rep' || e === 'manual' ? e : 'timed'
}
const load =
row.load_sec !== undefined && row.load_sec !== null && row.load_sec !== ''
? normalizeOptionalNonNegInt(row.load_sec)
: undefined
const reps =
row.consecutive_reps !== undefined && row.consecutive_reps !== null && row.consecutive_reps !== ''
? normalizeOptionalPositiveInt(row.consecutive_reps)
: undefined
if (load != null && reps == null) return 'timed'
if (reps != null && load == null) return 'rep'
if (load != null && reps != null) return 'timed'
return 'timed'
}
/** UI: Serien-Anzahl aus Formularfeld; leer/ungültig ⇒ 1 (eine Serie). */
export function parseComboRepSeriesCountUi(raw) {
if (raw === '' || raw === undefined || raw === null) return 1
const n =
typeof raw === 'number' && Number.isFinite(raw) ? Math.round(raw) : parseInt(String(raw).trim(), 10)
if (!Number.isFinite(n) || n < 1) return 1
return n
}
function parseProfileJson(raw) {
if (typeof raw !== 'string' || !raw.trim()) return { ok: true, obj: {} }
try {
const p = JSON.parse(raw)
if (!p || typeof p !== 'object' || Array.isArray(p)) {
return { ok: false, error: 'Ablaufprofil muss ein JSON-Objekt sein.' }
}
return { ok: true, obj: { ...p } }
} catch {
return { ok: false, error: 'Ablaufprofil (JSON): Syntax ungültig.' }
}
}
/** Pro Archetyp: UI-Feldbeschreibungen (Werte werden in method_profile geschrieben) */
export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({
sequence_linear: [
{
key: 'rounds',
kind: 'int',
label: 'Anzahl Gesamtdurchläufe (komplette Sequenz, alle Stationen nacheinander)',
min: 1,
max: 999,
},
{
key: 'hint_step_duration_sec',
kind: 'int',
label: 'Orientierung: Sekunden je Station/Schritt (optional)',
min: 5,
max: INT_MAX,
},
{
key: 'block_intro_sec',
kind: 'int',
label: 'Einführung / Demon am Block Gesamt (Sek., optional)',
min: 0,
max: INT_MAX,
},
],
circuit_rotate_time: [
{
key: 'rounds',
kind: 'int',
label:
'Anzahl Gesamtdurchläufe (jede Station pro Sportler mehrfach beim Umlauf, z. B. 4 Stationen × 2 = zwei komplette Runden)',
min: 1,
max: 999,
},
{
key: 'work_seconds',
kind: 'int',
label: 'Arbeitszeit pro Station (Sek.)',
min: 5,
max: INT_MAX,
},
{
key: 'transition_seconds',
kind: 'int',
label: 'Wechsel / Rotation (Sek., optional)',
min: 0,
max: INT_MAX,
},
{
key: 'rest_seconds',
kind: 'int',
label: 'Pause zwischen Runden oder Stationen-Folgen (Sek., optional)',
min: 0,
max: INT_MAX,
},
],
circuit_all_parallel: [
{
key: 'rounds',
kind: 'int',
label: 'Anzahl Durchläufe (wenn alle parallel dieselbe Rundenlogik haben, optional)',
min: 1,
max: 999,
},
{
key: 'explain_before_seconds',
kind: 'int',
label: 'Zeitfenster VorabErklärung aller Stationen (Sek., optional)',
min: 0,
max: INT_MAX,
},
{
key: 'simultaneous_start',
kind: 'bool',
label: 'Alle Stationen starten zusammen nach Erklärung',
},
],
station_parcour: [
{
key: 'rounds',
kind: 'int',
label: 'Anzahl Durchläufe des Parcours (Start Station 1, alle Bahnpunkte, Wiederholung bei Bedarf)',
min: 1,
max: 999,
},
{
key: 'allow_free_visit_order',
kind: 'bool',
label: 'Reihenfolge der Besuche frei (Parcours / Abhaken-Logik später im Coach)',
},
],
pair_superset: [
{
key: 'switch_seconds',
kind: 'int',
label: 'Orientierung: Wechselpause A↔B (Sek., optional)',
min: 0,
max: INT_MAX,
},
{
key: 'work_seconds_per_side',
kind: 'int',
label: 'Arbeit pro Rolle oder Seite (Sek., optional)',
min: 5,
max: INT_MAX,
},
],
time_domain_interval: [
{
key: 'work_seconds',
kind: 'int',
label: 'Intervall: Belastungszeit (Sek.)',
min: 5,
max: INT_MAX,
},
{
key: 'rest_seconds',
kind: 'int',
label: 'Intervall: Erholungszeit (Sek., optional)',
min: 0,
max: INT_MAX,
},
{
key: 'interval_rounds',
kind: 'int',
label: 'Anzahl Wiederholungen der Intervalldomäne (komplette Zyklen Arbeit/Pause)',
min: 1,
max: 999,
},
],
free_method_block: [],
})
function shortenComboGuiCaption(label) {
const t = (label || '').trim()
if (!t) return ''
const cut = t.split('(')[0].trim()
return cut.length > 52 ? `${cut.slice(0, 50)}` : cut
}
/**
* Globale Archetyp-Felder aus method_profile für Lesetext (Vorschau, Druck).
* Ignoriert slot_profiles_v1 (kommt separat je Station).
*/
export function describeGlobalComboProfile(archetypeKey, profileObj) {
const arch = typeof archetypeKey === 'string' ? archetypeKey.trim() : ''
if (!profileObj || typeof profileObj !== 'object' || Array.isArray(profileObj)) return []
const defs = METHOD_PROFILE_GUI_FIELDS[arch] || []
const rows = []
for (const def of defs) {
const val = profileObj[def.key]
if (val === undefined || val === null || val === '') continue
if (def.kind === 'bool') {
const on = val === true || val === 'true' || val === 1 || val === '1'
rows.push({
key: def.key,
caption: shortenComboGuiCaption(def.label),
detailLabel: def.label,
value: on ? 'Ja' : 'Nein',
})
} else {
rows.push({
key: def.key,
caption: shortenComboGuiCaption(def.label),
detailLabel: def.label,
value: String(val),
})
}
}
return rows
}
/**
* Aktualisiert method_profile unter Beibehaltung nicht-GUI Schlüssel.
*/
export function updateProfileGuided(archetype, rawJson, key, parsedValue, kind) {
const arch = typeof archetype === 'string' ? archetype.trim() : ''
const parsed = parseProfileJson(rawJson)
if (!parsed.ok) return parsed
const next = { ...parsed.obj }
if (kind === 'bool') {
if (parsedValue) next[key] = true
else delete next[key]
} else if (kind === 'int') {
if (parsedValue === null || parsedValue === undefined || parsedValue === '') {
delete next[key]
} else {
const n = typeof parsedValue === 'number' ? parsedValue : parseInt(String(parsedValue), 10)
if (!Number.isFinite(n)) delete next[key]
else next[key] = n
}
}
const outJson = JSON.stringify(next)
return { ok: true, obj: next, json: outJson === '{}' ? '{}' : outJson }
}
export function setFullProfileRawJson(rawEditable) {
const parsed = parseProfileJson(rawEditable)
if (!parsed.ok) return parsed
const j = JSON.stringify(parsed.obj)
return { ok: true, obj: parsed.obj, json: j === '{}' ? '{}' : j }
}
/**
* Pfad für slot_profiles_v1 und ähnliche strukturierte Erweiterungen.
* Ungültiges JSON gibt { ok:false } zurück; mutator erhält geklontes ProfilObjekt.
*/
export function patchMethodProfile(rawJson, mutator) {
const parsed = parseProfileJson(rawJson || '{}')
if (!parsed.ok) return parsed
const draft = { ...parsed.obj }
mutator(draft)
try {
const j = JSON.stringify(draft)
return { ok: true, obj: draft, json: j === '{}' ? '{}' : j }
} catch {
return { ok: false, error: 'Ablaufprofil konnte nicht gespeichert werden.' }
}
}
/** Normalisiert slot_profiles_v1 aus dem gespeicherten Profil */
export function readSlotProfilesV1(profileObj) {
if (!profileObj || typeof profileObj !== 'object') return []
const raw = profileObj.slot_profiles_v1
if (!Array.isArray(raw)) return []
return raw.map((row) => {
if (!row || typeof row !== 'object') return null
const si = Number(row.slot_index)
const inferredMode = inferAdvanceModeFromStoredSlotRow(row)
const out = {
slot_index: Number.isFinite(si) ? si : 0,
advance_mode: inferredMode,
load_sec: normalizeOptionalNonNegInt(row.load_sec),
consecutive_reps: normalizeOptionalPositiveInt(row.consecutive_reps),
rep_series_count: normalizeOptionalPositiveInt(row.rep_series_count),
intra_rep_rest_sec: normalizeOptionalNonNegInt(row.intra_rep_rest_sec),
transition_after_sec: normalizeOptionalNonNegInt(row.transition_after_sec),
}
return out
}).filter(Boolean)
}
/** Kurztext für Listen/strip (Coach „Plan:“ — gleiche Logik). */
export function summarizeSlotProfileBrief(r) {
if (!r) return null
const adv = r.advance_mode || 'timed'
const bits = []
if (adv === 'timed') {
bits.push('Zeit')
if (r.load_sec != null) bits.push(`${r.load_sec}s Arbeit`)
if (r.consecutive_reps != null)
bits.push(`${r.consecutive_reps}× Wdh. ohne Wechsel zur nächsten Station`)
} else if (adv === 'rep') {
bits.push('ZielWdh.')
const nSer = r.rep_series_count != null && r.rep_series_count >= 1 ? r.rep_series_count : 1
if (r.consecutive_reps != null) {
if (nSer >= 2) bits.push(`${nSer} Serien à ${r.consecutive_reps}×`)
else bits.push(`${r.consecutive_reps}×`)
}
} else {
bits.push('Coach')
const nSerMan = r.rep_series_count != null && r.rep_series_count >= 2 ? r.rep_series_count : 1
if (r.consecutive_reps != null) {
if (nSerMan >= 2) bits.push(`${nSerMan} Serien à Richtwert ${r.consecutive_reps}×`)
else bits.push(`Richtwert ${r.consecutive_reps}×`)
} else if (r.rep_series_count != null && r.rep_series_count >= 2) {
bits.push(`${r.rep_series_count} Serien`)
}
}
if (r.intra_rep_rest_sec != null) {
if (adv === 'timed')
bits.push(`Pause zw. Wdh. ${r.intra_rep_rest_sec}s (nicht Stationswechsel)`)
else if (adv === 'rep' && r.rep_series_count != null && r.rep_series_count >= 2)
bits.push(`Pause zw. Serien ${r.intra_rep_rest_sec}s`)
else if (
adv === 'manual' &&
r.rep_series_count != null &&
r.rep_series_count >= 2
) {
bits.push(`Pause zw. Serien ${r.intra_rep_rest_sec}s`)
}
}
if (r.transition_after_sec != null) bits.push(`Wechsel ${r.transition_after_sec}s`)
return bits.join(' · ')
}
/**
* Kompakte Stations-Belastung für die Plan-Klammer (links): Sekunden oder Wdh., nicht Slot-ID.
*/
export function stationPrimaryLoadLabel(slotRow) {
if (!slotRow || typeof slotRow !== 'object') return null
const adv = slotRow.advance_mode || 'timed'
if (adv === 'timed') {
if (slotRow.load_sec != null) return `${slotRow.load_sec}s`
if (slotRow.consecutive_reps != null) return `${slotRow.consecutive_reps}×`
return null
}
if (adv === 'rep') {
if (slotRow.consecutive_reps != null) return `${slotRow.consecutive_reps}×`
return null
}
if (adv === 'manual') {
if (slotRow.consecutive_reps != null) return `~${slotRow.consecutive_reps}×`
return null
}
return null
}
function globalTimingHintsForArchetype(arch, mp) {
if (!mp || typeof mp !== 'object' || Array.isArray(mp)) return []
const bits = []
switch (arch) {
case 'circuit_rotate_time':
if (mp.work_seconds != null && mp.work_seconds !== '') bits.push(`${mp.work_seconds}s Arbeit je Station`)
if (mp.transition_seconds != null && mp.transition_seconds !== '')
bits.push(`Rotation ${mp.transition_seconds}s`)
if (mp.rest_seconds != null && mp.rest_seconds !== '') bits.push(`Pause ${mp.rest_seconds}s`)
if (mp.rounds != null && mp.rounds !== '') bits.push(`${mp.rounds} UmlaufRunden`)
break
case 'sequence_linear':
if (mp.hint_step_duration_sec != null && mp.hint_step_duration_sec !== '')
bits.push(`~${mp.hint_step_duration_sec}s je Station`)
if (mp.rounds != null && mp.rounds !== '') bits.push(`${mp.rounds} SequenzDurchläufe`)
if (mp.block_intro_sec != null && mp.block_intro_sec !== '') bits.push(`BlockIntro ${mp.block_intro_sec}s`)
break
case 'time_domain_interval':
if (mp.work_seconds != null && mp.work_seconds !== '') bits.push(`${mp.work_seconds}s IntervallArbeit`)
if (mp.rest_seconds != null && mp.rest_seconds !== '') bits.push(`${mp.rest_seconds}s Erholung`)
if (mp.interval_rounds != null && mp.interval_rounds !== '') bits.push(`${mp.interval_rounds} IntervallZyklen`)
break
case 'pair_superset':
if (mp.work_seconds_per_side != null && mp.work_seconds_per_side !== '')
bits.push(`${mp.work_seconds_per_side}s Arbeit`)
if (mp.switch_seconds != null && mp.switch_seconds !== '') bits.push(`Wechsel ${mp.switch_seconds}s`)
break
case 'station_parcour':
if (mp.rounds != null && mp.rounds !== '') bits.push(`${mp.rounds} ParcoursRunden`)
break
case 'circuit_all_parallel':
if (mp.rounds != null && mp.rounds !== '') bits.push(`${mp.rounds} Runden`)
if (mp.explain_before_seconds != null && mp.explain_before_seconds !== '')
bits.push(`Erklärung ${mp.explain_before_seconds}s`)
break
default:
break
}
return bits
}
function isWeakSlotTimingSummary(txt) {
if (!txt || typeof txt !== 'string') return true
const t = txt.trim()
return t === 'Zeit' || t === 'Coach' || t === 'ZielWdh.'
}
/**
* Stationszeile für Lesetext: SlotZeiten + bei Bedarf globale Eckdaten (ZirkelSekunden, Runden ).
*/
export function effectiveStationTimingSummary(archetypeKey, profileObj, slotRow) {
const arch = typeof archetypeKey === 'string' ? archetypeKey.trim() : ''
const mp = profileObj && typeof profileObj === 'object' && !Array.isArray(profileObj) ? profileObj : {}
const slotTxt = summarizeSlotProfileBrief(slotRow)
const hints = globalTimingHintsForArchetype(arch, mp)
const hintStr = hints.join(' · ')
if (!isWeakSlotTimingSummary(slotTxt)) {
const extras = []
for (const h of hints) {
if (
/Runden|Durchläufe|Zyklen|Umlauf/i.test(h) &&
slotTxt &&
!/Runden|Serien|×|\d+s Arbeit|\d+s Erholung|\d+s Intervall/i.test(slotTxt)
) {
extras.push(h)
}
}
return extras.length ? `${slotTxt} · ${extras.join(' · ')}` : slotTxt
}
if (hintStr) return hintStr
if (slotTxt) return slotTxt
return null
}
function normalizeOptionalNonNegInt(v) {
if (v === '' || v === undefined || v === null) return undefined
const n = typeof v === 'number' ? v : parseInt(String(v), 10)
if (!Number.isFinite(n) || n < 0) return undefined
return Math.round(n)
}
function normalizeOptionalPositiveInt(v) {
const n = normalizeOptionalNonNegInt(v)
if (n === undefined) return undefined
if (n < 1) return undefined
return n
}
const SLOT_TIMING_FIELDS = /** @type {const} */ ([
'load_sec',
'consecutive_reps',
'rep_series_count',
'intra_rep_rest_sec',
'transition_after_sec',
])
function slotProfileRowShouldKeep(nextRow) {
if (!nextRow || typeof nextRow !== 'object') return false
const mode = normalizeAdvanceMode(nextRow.advance_mode)
if (mode !== 'timed') return true
return SLOT_TIMING_FIELDS.some((k) => nextRow[k] !== undefined && nextRow[k] !== null)
}
function writeSlotProfilesV1Arr(profileDraft, arr) {
const sorted = [...arr].sort((a, b) => Number(a.slot_index) - Number(b.slot_index))
if (sorted.length === 0) delete profileDraft.slot_profiles_v1
else profileDraft.slot_profiles_v1 = sorted
}
/** Steuert Ende der Arbeitsphase: Zeit, Wiederholungsziel oder nur manuell weiter. */
export function patchSlotAdvanceMode(profileDraft, slotIndex, modeRaw) {
const ix =
typeof slotIndex === 'number' && Number.isFinite(slotIndex)
? slotIndex
: parseInt(String(slotIndex), 10)
if (!Number.isFinite(ix)) return
const mode = normalizeAdvanceMode(modeRaw)
let arr = Array.isArray(profileDraft.slot_profiles_v1) ? [...profileDraft.slot_profiles_v1] : []
const found = arr.findIndex((r) => r && typeof r === 'object' && Number(r.slot_index) === ix)
const nextRow = {}
if (found >= 0 && arr[found] && typeof arr[found] === 'object') {
Object.assign(nextRow, arr[found])
}
nextRow.slot_index = ix
if (mode === 'timed') delete nextRow.advance_mode
else {
nextRow.advance_mode = mode
delete nextRow.load_sec
}
let nextArr
if (!slotProfileRowShouldKeep(nextRow)) {
nextArr = found >= 0 ? arr.filter((_, i) => i !== found) : arr
} else if (found >= 0) {
nextArr = [...arr]
nextArr[found] = nextRow
} else {
nextArr = [...arr, nextRow]
}
writeSlotProfilesV1Arr(profileDraft, nextArr)
}
/** '', null = Feld entfernen; sonst gültige Zahl setzen */
export function patchSlotTimingField(profileDraft, slotIndex, field, rawInput) {
if (!SLOT_TIMING_FIELDS.includes(field)) return
const ix =
typeof slotIndex === 'number' && Number.isFinite(slotIndex)
? slotIndex
: parseInt(String(slotIndex), 10)
if (!Number.isFinite(ix)) return
let arr = Array.isArray(profileDraft.slot_profiles_v1) ? [...profileDraft.slot_profiles_v1] : []
let found = arr.findIndex((r) => r && typeof r === 'object' && Number(r.slot_index) === ix)
const nextRow = {}
if (found >= 0 && arr[found] && typeof arr[found] === 'object') {
Object.assign(nextRow, arr[found])
}
nextRow.slot_index = ix
if (rawInput === null || rawInput === undefined || String(rawInput).trim() === '') {
delete nextRow[field]
} else if (field === 'consecutive_reps' || field === 'rep_series_count') {
const n = normalizeOptionalPositiveInt(rawInput)
if (n === undefined) delete nextRow[field]
else nextRow[field] = n
} else {
const n = normalizeOptionalNonNegInt(rawInput)
if (n === undefined) delete nextRow[field]
else nextRow[field] = n
}
const keep = slotProfileRowShouldKeep(nextRow)
if (found >= 0) {
if (!keep) {
arr = arr.filter((_, i) => i !== found)
} else {
arr[found] = nextRow
}
} else if (keep) {
arr.push(nextRow)
}
writeSlotProfilesV1Arr(profileDraft, arr)
}
/** Rotierender Zirkel: typische Ableitungen (setzt Sekunden konkret). */
export function applyCircuitRotateQuickRatio(profileDraft, preset) {
const wRaw = profileDraft.work_seconds
const work =
typeof wRaw === 'number' && Number.isFinite(wRaw) ? Math.round(wRaw) : parseInt(String(wRaw), 10)
if (!Number.isFinite(work) || work <= 0)
return { ok: false, error: 'Zuerst Arbeitszeit pro Station (Sek.) setzen.' }
profileDraft.timing_schema = profileDraft.timing_schema ?? 1
if (preset === 'transition_equals_work') {
profileDraft.transition_seconds = work
return { ok: true }
}
if (preset === 'round_rest_equals_work') {
profileDraft.rest_seconds = work
return { ok: true }
}
if (preset === 'round_rest_two_thirds_work') {
profileDraft.rest_seconds = Math.round((work * 2) / 3)
return { ok: true }
}
return { ok: false, error: 'Unbekannte Schnellwahl.' }
}
export function applyIntervalDomainQuickRatio(profileDraft, preset) {
const wRaw = profileDraft.work_seconds
const work =
typeof wRaw === 'number' && Number.isFinite(wRaw) ? Math.round(wRaw) : parseInt(String(wRaw), 10)
if (!Number.isFinite(work) || work <= 0)
return { ok: false, error: 'Zuerst Belastungszeit Intervall (Sek.) setzen.' }
profileDraft.timing_schema = profileDraft.timing_schema ?? 1
if (preset === 'rest_equals_work') {
profileDraft.rest_seconds = work
return { ok: true }
}
if (preset === 'rest_two_thirds_work') {
profileDraft.rest_seconds = Math.round((work * 2) / 3)
return { ok: true }
}
return { ok: false, error: 'Unbekannte Schnellwahl.' }
}
export { parseProfileJson, INT_MAX }

View File

@ -0,0 +1,134 @@
/** Effektives Ablaufprofil für Kombination im Coach/in der Planung */
import { inferAdvanceModeFromStoredSlotRow } from './combinationMethodProfileUi'
/** Top-Level: null/undefined aus Planungs-Snapshot löschen keine Katalog-Felder (API liefert oft JSON-null). */
function omitNullUndefinedTop(planObj) {
const out = {}
for (const [k, v] of Object.entries(planObj)) {
if (v === null || v === undefined) continue
out[k] = v
}
return out
}
/** Je Slot-Zeile: null/undefined aus Planung überschreiben keine Katalog-Werte (z. B. consecutive_reps). */
function patchHasExplicitAdvanceMode(patch) {
return (
Object.prototype.hasOwnProperty.call(patch, 'advance_mode') &&
patch.advance_mode !== null &&
patch.advance_mode !== undefined &&
String(patch.advance_mode).trim() !== ''
)
}
/** Nach Merge: Rep/Manual ohne Sekunden-Arbeit; Zeit ohne advance_mode-Schlüssel. */
function coerceMergedSlotProfileRow(row) {
if (!row || typeof row !== 'object') return row
const inferred = inferAdvanceModeFromStoredSlotRow(row)
if (inferred === 'rep' || inferred === 'manual') {
delete row.load_sec
row.advance_mode = inferred
} else {
delete row.advance_mode
}
return row
}
function mergeSlotProfileFields(prev, patch) {
const base = prev && typeof prev === 'object' ? { ...prev } : {}
if (!patch || typeof patch !== 'object') return coerceMergedSlotProfileRow(base)
for (const [k, v] of Object.entries(patch)) {
if (v === null || v === undefined) continue
base[k] = v
}
const loadExplicit =
Object.prototype.hasOwnProperty.call(patch, 'load_sec') &&
patch.load_sec !== null &&
patch.load_sec !== undefined &&
String(patch.load_sec).trim() !== ''
// Nur Arbeitsssekunden aus Planung → Zeitmodus soll Katalogadvance_mode rep nicht „festhalten“
if (loadExplicit && !patchHasExplicitAdvanceMode(patch)) {
delete base.advance_mode
}
const ix = Number(base.slot_index)
if (Number.isFinite(ix)) base.slot_index = ix
return coerceMergedSlotProfileRow(base)
}
/**
* Vereinigt slot_profiles_v1 aus Katalog und Planungs-Overlay (je slot_index).
* @param {unknown} catArr
* @param {unknown} planArr
*/
function mergeSlotProfilesV1(catArr, planArr) {
const c = Array.isArray(catArr) ? catArr : []
const p = Array.isArray(planArr) ? planArr : []
const byIx = new Map()
for (const r of c) {
if (!r || typeof r !== 'object') continue
const ix = Number(r.slot_index)
if (!Number.isFinite(ix)) continue
byIx.set(ix, { ...r })
}
for (const r of p) {
if (!r || typeof r !== 'object') continue
const ix = Number(r.slot_index)
if (!Number.isFinite(ix)) continue
const prev = byIx.get(ix) || {}
byIx.set(ix, mergeSlotProfileFields(prev, r))
}
return [...byIx.entries()].sort((a, b) => a[0] - b[0]).map(([, row]) => row)
}
/**
* Katalog-Basis + optionaler Planungs-Snapshot.
* Wichtig: `planning_method_profile` darf nur **Überschreibungen** sein nie den Katalog komplett ersetzen
* (sonst verschwinden Zeiten/Runden bei leerem Objekt oder Teil-Payload).
*/
export function effectiveComboMethodProfile(catalogDict, planningSnapshot) {
const cat =
catalogDict && typeof catalogDict === 'object' && !Array.isArray(catalogDict) ? { ...catalogDict } : {}
if (planningSnapshot === null || planningSnapshot === undefined) {
return { ...cat }
}
if (typeof planningSnapshot === 'string') {
const t = planningSnapshot.trim()
if (!t || t === 'null') return { ...cat }
try {
const p = JSON.parse(t)
return effectiveComboMethodProfile(catalogDict, p)
} catch {
return { ...cat }
}
}
if (typeof planningSnapshot !== 'object' || Array.isArray(planningSnapshot)) {
return { ...cat }
}
const planRaw = planningSnapshot
const merged = { ...cat, ...omitNullUndefinedTop(planRaw) }
if (Object.prototype.hasOwnProperty.call(planRaw, 'slot_profiles_v1')) {
merged.slot_profiles_v1 = mergeSlotProfilesV1(cat.slot_profiles_v1, planRaw.slot_profiles_v1)
}
return merged
}
export function comboPlanningProfileJsonForEditor(catalogDict, planningSnapshot) {
const o = effectiveComboMethodProfile(catalogDict, planningSnapshot)
try {
return JSON.stringify(Object.keys(o).length ? o : {})
} catch {
return '{}'
}
}

View File

@ -0,0 +1,391 @@
import { jsPDF } from 'jspdf'
import { lexer } from 'marked'
/** Fortlaufende Abschnittsnummer nur für die Ausgabe (nicht persistiert). */
export function legalSectionNumber(indexZeroBased) {
return `§${indexZeroBased + 1}`
}
const PDF_FONT = {
normal: ['helvetica', 'normal'],
bold: ['helvetica', 'bold'],
italic: ['helvetica', 'italic'],
bolditalic: ['helvetica', 'bolditalic'],
}
function setPdfFont(pdf, styleKey) {
const [name, variant] = PDF_FONT[styleKey] || PDF_FONT.normal
try {
pdf.setFont(name, variant)
} catch {
pdf.setFont('helvetica', styleKey === 'bolditalic' || styleKey === 'bold' ? 'bold' : 'normal')
}
}
/** Inline-Tokens → zusammengefügte Läufe mit Schriftstil */
function collectInlineRuns(tokens, base = { bold: false, italic: false }) {
const runs = []
const styleKey = (b) => {
if (b.bold && b.italic) return 'bolditalic'
if (b.bold) return 'bold'
if (b.italic) return 'italic'
return 'normal'
}
const flush = (text, b) => {
if (!text) return
runs.push({ style: styleKey(b), text })
}
const walk = (ts, b) => {
for (const t of ts || []) {
switch (t.type) {
case 'text':
case 'escape':
flush(t.text, b)
break
case 'strong':
walk(t.tokens || [{ type: 'text', text: t.text }], { ...b, bold: true })
break
case 'em':
walk(t.tokens || [{ type: 'text', text: t.text }], { ...b, italic: true })
break
case 'codespan':
flush(`${t.text}`, b)
break
case 'link': {
walk(t.tokens || [{ type: 'text', text: t.text }], b)
const href = t.href || ''
if (href) flush(` (${href})`, b)
break
}
case 'br':
flush('\n', b)
break
case 'del':
walk(t.tokens || [{ type: 'text', text: t.text }], b)
break
case 'image':
flush(`[${t.text || 'Bild'}]`, b)
break
default:
if (t.tokens) walk(t.tokens, b)
else if (t.text) flush(t.text, b)
}
}
}
walk(tokens, base)
const merged = []
for (const r of runs) {
const last = merged[merged.length - 1]
if (last && last.style === r.style) last.text += r.text
else merged.push({ ...r })
}
return merged
}
function splitLongWord(pdf, word, maxW) {
if (pdf.getTextWidth(word) <= maxW) return [word]
const out = []
let buf = ''
for (const ch of word) {
const tryBuf = buf + ch
if (pdf.getTextWidth(tryBuf) > maxW && buf) {
out.push(buf)
buf = ch
} else {
buf = tryBuf
}
}
if (buf) out.push(buf)
return out
}
/** Eine Zeile aus mehreren Läufen mit unterschiedlichen Schriften zeichnen */
function drawLine(pdf, pieces, x, y) {
let cx = x
for (const p of pieces) {
setPdfFont(pdf, p.style)
pdf.text(p.text, cx, y)
cx += pdf.getTextWidth(p.text)
}
}
function collectWordsFromRuns(pdf, runs, maxChunkW) {
const words = []
for (const run of runs) {
setPdfFont(pdf, run.style)
const parts = run.text.split(/(\s+)/)
for (const part of parts) {
if (!part) continue
if (part === '\n') {
words.push({ forceBreak: true })
continue
}
for (const c of splitLongWord(pdf, part, maxChunkW)) {
words.push({ ...run, text: c })
}
}
}
return words
}
/** Absatz aus Inline-Läufen umbrechen und zeichnen */
function runParagraphRuns(pdf, xStart, maxWidth, y, runs, lineStep, checkBreak, prefixRuns = []) {
const words = collectWordsFromRuns(pdf, [...prefixRuns, ...runs], maxWidth)
let linePieces = []
let lineW = 0
const flushLine = () => {
if (!linePieces.length) return
checkBreak(lineStep)
drawLine(pdf, linePieces, xStart, y)
y += lineStep
linePieces = []
lineW = 0
}
for (const w of words) {
if (w.forceBreak) {
flushLine()
continue
}
setPdfFont(pdf, w.style)
const tw = pdf.getTextWidth(w.text)
if (lineW + tw > maxWidth && linePieces.length) flushLine()
linePieces.push({ style: w.style, text: w.text })
lineW += tw
}
flushLine()
return y
}
/** Listenpunkt: Aufzählungszeichen in der ersten Zeile, hängender Einzug */
function renderListItemRuns(pdf, bullet, runs, x, y, maxW, lineStep, checkBreak) {
pdf.setFont('helvetica', 'normal')
const bulletW = pdf.getTextWidth(bullet)
const textX = x + bulletW
const textMaxW = Math.max(12, maxW - bulletW)
const words = collectWordsFromRuns(pdf, runs, textMaxW)
let linePieces = []
let lineW = 0
let firstLine = true
const flushLine = () => {
if (!linePieces.length) return
checkBreak(lineStep)
if (firstLine) {
pdf.setFont('helvetica', 'normal')
pdf.text(bullet, x, y)
firstLine = false
}
drawLine(pdf, linePieces, textX, y)
y += lineStep
linePieces = []
lineW = 0
}
for (const w of words) {
if (w.forceBreak) {
flushLine()
continue
}
setPdfFont(pdf, w.style)
const tw = pdf.getTextWidth(w.text)
if (lineW + tw > textMaxW && linePieces.length) flushLine()
linePieces.push({ style: w.style, text: w.text })
lineW += tw
}
flushLine()
return y
}
/**
* Fließtext mit **fett** / *kursiv* / Listen usw. ins PDF Zeilenumbruch per Wortgrenze.
* Gibt neue y-Position (mm) zurück.
*/
function renderMarkdownBlockToPdf(pdf, markdown, x, y, maxW, lineStep, checkBreak, gapAfterBlock = 2) {
if (!markdown || !String(markdown).trim()) return y + gapAfterBlock
const tokens = lexer(String(markdown), { gfm: true, breaks: true })
for (const token of tokens) {
switch (token.type) {
case 'space':
break
case 'paragraph': {
const runs = collectInlineRuns(token.tokens || [])
y = runParagraphRuns(pdf, x, maxW, y, runs, lineStep, checkBreak)
y += gapAfterBlock
break
}
case 'heading': {
checkBreak(8)
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(10.5)
const hruns = collectInlineRuns(token.tokens || [])
const htext = hruns.map(r => r.text).join('')
const lines = pdf.splitTextToSize(htext, maxW)
for (const line of lines) {
checkBreak(6)
pdf.text(line, x, y)
y += 5.5
}
pdf.setFontSize(10)
pdf.setFont('helvetica', 'normal')
y += gapAfterBlock
break
}
case 'blockquote': {
const bx = x + 4
const bw = Math.max(20, maxW - 4)
let quoteStartY = null
for (const inner of token.tokens || []) {
if (inner.type === 'paragraph') {
if (quoteStartY == null) quoteStartY = y
const runs = collectInlineRuns(inner.tokens || [])
y = runParagraphRuns(pdf, bx, bw, y, runs, lineStep, checkBreak)
}
}
if (quoteStartY != null) {
pdf.setDrawColor(180, 180, 180)
pdf.setLineWidth(0.3)
pdf.line(x, quoteStartY - 3.5, x, y - 0.5)
pdf.setDrawColor(0, 0, 0)
}
y += gapAfterBlock
break
}
case 'code': {
checkBreak(lineStep)
pdf.setFont('courier', 'normal')
pdf.setFontSize(9)
const lines = pdf.splitTextToSize(token.text, maxW)
for (const line of lines) {
checkBreak(4.5)
pdf.text(line, x, y)
y += 4.5
}
pdf.setFontSize(10)
pdf.setFont('helvetica', 'normal')
y += gapAfterBlock
break
}
case 'list': {
let n = typeof token.start === 'number' ? token.start : 1
for (const item of token.items || []) {
const bullet = token.ordered ? `${n}. ` : '• '
n += 1
for (const it of item.tokens || []) {
if (it.type === 'paragraph') {
const runs = collectInlineRuns(it.tokens || [])
y = renderListItemRuns(pdf, bullet, runs, x, y, maxW, lineStep, checkBreak)
}
}
}
y += gapAfterBlock
break
}
case 'hr':
checkBreak(4)
pdf.setDrawColor(200, 200, 200)
pdf.line(x, y, x + maxW, y)
y += 5
pdf.setDrawColor(0, 0, 0)
break
default:
break
}
}
return y
}
/**
* Rechtstext als PDF (Abschnitte mit §-Nummerierung, Markdown im Fließtext).
* @param {object} doc API-Dokument mit title, content_sections, version,
* @param {string} metaLine z. B. Version + Datum
*/
export function generateLegalPdf(doc, metaLine) {
const pdf = new jsPDF({ format: 'a4', unit: 'mm' })
const marginL = 22
const marginR = 22
const marginTop = 28
const pageW = 210
const contentW = pageW - marginL - marginR
const bottomLimit = 277
let y = marginTop
const checkBreak = (need) => {
if (y + need > bottomLimit) {
pdf.addPage()
y = marginTop
}
}
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(20)
pdf.text(doc.title, marginL, y)
y += 10
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(10)
pdf.setTextColor(90, 90, 90)
pdf.text(metaLine, marginL, y)
y += 3
pdf.setDrawColor(0, 0, 0)
pdf.setLineWidth(0.4)
pdf.line(marginL, y, pageW - marginR, y)
y += 8
pdf.setTextColor(0, 0, 0)
const sections = doc.content_sections || []
sections.forEach((section, si) => {
const secNum = legalSectionNumber(si)
const head = [section.heading || '']
.filter(Boolean)
.length
? `${secNum} ${section.heading || ''}`
: secNum
checkBreak(14)
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(11)
const headLines = pdf.splitTextToSize(head, contentW)
for (const hl of headLines) {
checkBreak(6)
pdf.text(hl, marginL, y)
y += 6
}
if (section.content) {
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(10)
y = renderMarkdownBlockToPdf(pdf, section.content, marginL, y, contentW, 5, checkBreak, 4)
}
y += 3
})
const total = pdf.getNumberOfPages()
for (let i = 1; i <= total; i++) {
pdf.setPage(i)
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(8)
pdf.setTextColor(150, 150, 150)
const fy = 289
pdf.text(
`Shinkan Jinkendo | Exportiert am ${new Date().toLocaleDateString('de-DE')}`,
marginL,
fy,
)
pdf.text(`Seite ${i} von ${total}`, pageW - marginR, fy, { align: 'right' })
pdf.setTextColor(0, 0, 0)
}
pdf.save(`${doc.document_type}_v${doc.version}.pdf`)
}

View File

@ -2,6 +2,7 @@
* Hilfen für Trainingsplan-Ansicht, Coach-Modus und API-Payload für PUT /training-units/:id.
*/
import { cloneJsonSerializablePlanningProfile } from './trainingUnitSectionsForm'
export function sortedSections(unit) {
const raw = unit?.sections
if (!Array.isArray(raw)) return []
@ -71,7 +72,9 @@ export function sectionsToPutPayload(unit, durationOverridesByItemId = {}) {
if (eid === '' || eid == null || Number.isNaN(Number(eid))) {
return null
}
const vid = it.exercise_variant_id
const isCombo =
String(it.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
const vid = isCombo ? null : it.exercise_variant_id
let actual =
durationOverridesByItemId[String(it.id)]?.actual_duration_min ??
it.actual_duration_min
@ -79,7 +82,7 @@ export function sectionsToPutPayload(unit, durationOverridesByItemId = {}) {
else actual = typeof actual === 'number' ? actual : parseInt(String(actual), 10)
if (actual !== null && !Number.isFinite(actual)) actual = null
return {
const row = {
item_type: 'exercise',
order_index: it.order_index ?? ii,
exercise_id: parseInt(String(eid), 10),
@ -90,6 +93,16 @@ export function sectionsToPutPayload(unit, durationOverridesByItemId = {}) {
notes: trimOrNull(it.notes),
modifications: trimOrNull(it.modifications),
}
if (isCombo) {
const pmp = it.planning_method_profile
if (pmp != null && typeof pmp === 'object' && !Array.isArray(pmp)) {
const cleaned = cloneJsonSerializablePlanningProfile(pmp)
if (cleaned && Object.keys(cleaned).length > 0) {
row.planning_method_profile = cleaned
}
}
}
return row
})
.filter(Boolean),
}))

View File

@ -1,42 +1,97 @@
import api from './api'
import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
export function defaultSection(title = 'Hauptteil') {
return { title, guidance_notes: '', items: [] }
}
function normalizeCatalogMethodProfile(cp) {
if (cp && typeof cp === 'object' && !Array.isArray(cp)) return { ...cp }
return {}
}
/** NULL = Planung folgt Katalogprofil der Übung (Reihenfolge beibehalten: zuerst String-JSON auflösen). */
function normalizePlanningMethodProfile(pm) {
if (pm == null) return null
if (typeof pm === 'string') {
const t = pm.trim()
if (!t || t === 'null') return null
try {
const p = JSON.parse(t)
if (p && typeof p === 'object' && !Array.isArray(p)) return { ...p }
return null
} catch {
return null
}
}
if (typeof pm === 'object' && !Array.isArray(pm)) return { ...pm }
return null
}
/** Reines JSON für PUT /training-units (vermeidet nicht serialisierbare Werte → 500). */
export function cloneJsonSerializablePlanningProfile(obj) {
if (obj == null || typeof obj !== 'object' || Array.isArray(obj)) return null
try {
return JSON.parse(JSON.stringify(obj))
} catch {
return {}
}
}
export function exerciseRow() {
return {
item_type: 'exercise',
exercise_id: '',
exercise_variant_id: '',
exercise_kind: 'simple',
exercise_title: '',
variants: [],
planned_duration_min: '',
actual_duration_min: '',
notes: '',
modifications: '',
source_training_module_id: '',
source_module_title: '',
catalog_method_archetype: '',
catalog_method_profile: {},
planning_method_profile: null,
}
}
export async function hydrateExercisePlanningRow(exercise) {
let variants = Array.isArray(exercise?.variants) ? exercise.variants : []
let title = exercise?.title || ''
let exerciseKind = exercise?.exercise_kind
const id = exercise?.id
if (!id) return null
let meta = {}
if (!variants.length) {
let full
async function ensureFull() {
if (full !== undefined) return full
try {
const full = await api.getExercise(id)
variants = Array.isArray(full?.variants) ? full.variants : []
title = full?.title || title
meta = {
exercise_visibility: full?.visibility || 'private',
exercise_club_id: full?.club_id ?? null,
exercise_created_by: full?.created_by ?? null,
exercise_status: full?.status || 'draft',
}
full = await api.getExercise(id)
} catch {
variants = []
full = null
}
return full
}
if (!variants.length) {
await ensureFull()
if (full) {
variants = Array.isArray(full.variants) ? full.variants : []
title = full.title || title
if (exerciseKind == null) exerciseKind = full.exercise_kind
meta = {
exercise_visibility: full.visibility || 'private',
exercise_club_id: full.club_id ?? null,
exercise_created_by: full.created_by ?? null,
exercise_status: full.status || 'draft',
catalog_method_archetype: typeof full.method_archetype === 'string' ? full.method_archetype.trim() : '',
catalog_method_profile: normalizeCatalogMethodProfile(full.method_profile),
}
}
} else {
meta = {
@ -45,31 +100,60 @@ export async function hydrateExercisePlanningRow(exercise) {
exercise_created_by: exercise?.created_by ?? null,
exercise_status: exercise?.status ?? null,
}
if (meta.exercise_visibility == null || meta.exercise_created_by == null) {
try {
const full = await api.getExercise(id)
if (meta.exercise_visibility == null) meta.exercise_visibility = full?.visibility || 'private'
if (meta.exercise_club_id == null) meta.exercise_club_id = full?.club_id ?? null
if (meta.exercise_created_by == null) meta.exercise_created_by = full?.created_by ?? null
if (meta.exercise_status == null) meta.exercise_status = full?.status || 'draft'
} catch {
/* keep partial meta */
if (
meta.exercise_visibility == null ||
meta.exercise_created_by == null ||
exerciseKind == null
) {
await ensureFull()
if (full) {
if (meta.exercise_visibility == null) meta.exercise_visibility = full.visibility || 'private'
if (meta.exercise_club_id == null) meta.exercise_club_id = full.club_id ?? null
if (meta.exercise_created_by == null) meta.exercise_created_by = full.created_by ?? null
if (meta.exercise_status == null) meta.exercise_status = full.status || 'draft'
if (exerciseKind == null) exerciseKind = full.exercise_kind
if (!variants.length) variants = Array.isArray(full.variants) ? full.variants : []
}
}
meta.exercise_visibility = meta.exercise_visibility || 'private'
meta.exercise_status = meta.exercise_status || 'draft'
}
const row = exerciseRow()
row.exercise_id = id
row.exercise_variant_id = ''
row.exercise_title = title
row.variants = variants
row.exercise_kind =
String(exerciseKind || 'simple').toLowerCase().trim() === 'combination' ? 'combination' : 'simple'
if (row.exercise_kind === 'combination') {
row.variants = []
row.exercise_variant_id = ''
} else {
row.variants = variants
}
Object.assign(row, meta)
if (row.exercise_kind === 'combination') {
if (full === undefined) await ensureFull()
if (full) {
row.catalog_method_archetype =
typeof full.method_archetype === 'string' ? full.method_archetype.trim() : ''
row.catalog_method_profile = normalizeCatalogMethodProfile(full.method_profile)
row.combination_slots = Array.isArray(full.combination_slots) ? full.combination_slots : []
}
}
row.planning_method_profile = null
return row
}
export function noteRow() {
return { item_type: 'note', note_body: '' }
return { item_type: 'note', note_body: '', source_training_module_id: '', source_module_title: '' }
}
/** Zur Serialisierung in die Planungs-API (persistente Modul-Herkunft). */
function parseOptionalSourceTrainingModuleIdForPayload(v) {
if (v === null || v === undefined || v === '') return null
const n = typeof v === 'number' ? v : parseInt(String(v).trim(), 10)
return Number.isFinite(n) && n >= 1 ? n : null
}
export function normalizeUnitToForm(fullUnit) {
@ -79,12 +163,31 @@ export function normalizeUnitToForm(fullUnit) {
guidance_notes: sec.guidance_notes || '',
items: (sec.items || []).map((it) => {
if (it.item_type === 'note') {
return { item_type: 'note', note_body: it.note_body || '' }
const sm = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
const rowNote = {
item_type: 'note',
note_body: it.note_body || '',
source_training_module_id: '',
source_module_title: '',
}
if (sm != null) {
rowNote.source_training_module_id = sm
rowNote.source_module_title = (
it.source_module_title ||
it.source_training_module_title ||
''
).trim()
}
return rowNote
}
const smEx = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
const ek = String(it.exercise_kind || 'simple').toLowerCase().trim()
const isCombo = ek === 'combination'
return {
item_type: 'exercise',
exercise_id: it.exercise_id,
exercise_variant_id: it.exercise_variant_id ?? '',
exercise_kind: isCombo ? 'combination' : 'simple',
exercise_variant_id: isCombo ? '' : it.exercise_variant_id ?? '',
exercise_title: it.exercise_title || '',
variants: [],
planned_duration_min:
@ -97,6 +200,19 @@ export function normalizeUnitToForm(fullUnit) {
: '',
notes: it.notes ?? '',
modifications: it.modifications ?? '',
catalog_method_archetype: String(it.catalog_method_archetype ?? '').trim(),
catalog_method_profile: normalizeCatalogMethodProfile(it.catalog_method_profile),
planning_method_profile: normalizePlanningMethodProfile(it.planning_method_profile),
...(smEx != null
? {
source_training_module_id: smEx,
source_module_title: (
it.source_module_title ||
it.source_training_module_title ||
''
).trim(),
}
: {}),
}
}),
}))
@ -106,23 +222,31 @@ export function normalizeUnitToForm(fullUnit) {
{
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 ?? '',
})),
items: fullUnit.exercises.map((ex) => {
const ek = String(ex.exercise_kind || 'simple').toLowerCase().trim()
const isCombo = ek === 'combination'
return {
item_type: 'exercise',
exercise_kind: ek,
exercise_id: ex.exercise_id,
exercise_variant_id: isCombo ? '' : (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 ?? '',
catalog_method_archetype: String(ex.catalog_method_archetype ?? '').trim(),
catalog_method_profile: normalizeCatalogMethodProfile(ex.catalog_method_profile),
planning_method_profile: normalizePlanningMethodProfile(ex.planning_method_profile),
}
}),
},
]
}
@ -144,26 +268,77 @@ export async function enrichSectionsWithVariants(sections) {
unique.map(async (id) => {
try {
const ex = await api.getExercise(id)
const ek = String(ex.exercise_kind || 'simple').toLowerCase().trim()
cache.set(id, {
title: ex.title || '',
exercise_kind: ek,
variants: Array.isArray(ex.variants) ? ex.variants : [],
visibility: ex.visibility || 'private',
club_id: ex.club_id ?? null,
created_by: ex.created_by ?? null,
status: ex.status || 'draft',
method_archetype: typeof ex.method_archetype === 'string' ? ex.method_archetype.trim() : '',
method_profile: normalizeCatalogMethodProfile(ex.method_profile),
combination_slots: ek === 'combination' && Array.isArray(ex.combination_slots) ? ex.combination_slots : [],
})
} catch {
cache.set(id, {
title: '',
exercise_kind: 'simple',
variants: [],
visibility: 'private',
club_id: null,
created_by: null,
status: 'draft',
method_archetype: '',
method_profile: {},
combination_slots: [],
})
}
})
)
const titleById = new Map()
for (const id of unique) {
const row = cache.get(id)
const t = (row?.title || '').trim()
if (t) titleById.set(Number(id), t)
}
const comboCandidateExtra = new Set()
for (const id of unique) {
const row = cache.get(id)
if (String(row?.exercise_kind || '').toLowerCase().trim() !== 'combination') continue
for (const slot of row.combination_slots || []) {
for (const raw of slot.candidate_exercise_ids || []) {
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
if (Number.isFinite(n) && !titleById.has(n)) comboCandidateExtra.add(n)
}
}
}
await Promise.all(
[...comboCandidateExtra].map(async (cid) => {
try {
const ex = await api.getExercise(cid)
titleById.set(cid, ((ex.title || '').trim() || `Übung #${cid}`))
} catch {
titleById.set(cid, `Übung #${cid}`)
}
}),
)
function comboMemberTitleByIdForSlots(slots) {
const o = {}
for (const slot of slots || []) {
for (const raw of slot.candidate_exercise_ids || []) {
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
if (!Number.isFinite(n)) continue
const key = String(n)
if (!o[key]) o[key] = titleById.get(n) || `Übung #${n}`
}
}
return o
}
return sections.map((sec) => ({
...sec,
items: (sec.items || []).map((it) => {
@ -171,20 +346,52 @@ export async function enrichSectionsWithVariants(sections) {
if (!it.exercise_id) return it
const c = cache.get(it.exercise_id)
if (!c) return it
const ek = String(c.exercise_kind || 'simple').toLowerCase().trim()
const isCombo = ek === 'combination'
const itemCatalog = normalizeCatalogMethodProfile(it.catalog_method_profile)
const catalog_method_profile =
Object.keys(itemCatalog).length > 0
? itemCatalog
: normalizeCatalogMethodProfile(c.method_profile)
const rowArche = String(it.catalog_method_archetype ?? '').trim()
const catalog_method_archetype = rowArche || String(c.method_archetype ?? '').trim()
return {
...it,
catalog_method_archetype,
catalog_method_profile,
planning_method_profile: normalizePlanningMethodProfile(it.planning_method_profile),
exercise_kind: isCombo ? 'combination' : 'simple',
exercise_title: it.exercise_title || c.title,
exercise_variant_id: isCombo ? '' : it.exercise_variant_id,
variants:
Array.isArray(it.variants) && it.variants.length > 0 ? it.variants : c.variants,
isCombo ? [] : Array.isArray(it.variants) && it.variants.length > 0 ? it.variants : c.variants,
exercise_visibility: c.visibility,
exercise_club_id: c.club_id,
exercise_created_by: c.created_by,
exercise_status: c.status,
...(isCombo ? { combination_slots: c.combination_slots || [], combo_member_title_by_id: comboMemberTitleByIdForSlots(c.combination_slots || []) } : {}),
}
}),
}))
}
/**
* Outline für CombinationMethodProfileEditor: proSlotZeiten nur sichtbar, wenn Stationen übergeben werden.
*/
export function comboSlotsOutlineForProfileEditor(combinationSlots) {
if (!Array.isArray(combinationSlots) || combinationSlots.length === 0) return null
const sorted = sortCombinationSlotsForDisplay(combinationSlots)
return sorted.map((s, i) => {
const rawIx = s.slot_index
const si =
rawIx === '' || rawIx == null ? null : typeof rawIx === 'number' ? rawIx : parseInt(String(rawIx), 10)
return {
slot_index: Number.isFinite(si) ? si : i,
title: (s.title != null ? String(s.title) : '').trim(),
}
})
}
export function parseMin(v) {
if (v === '' || v === null || v === undefined) return null
const n = parseInt(String(v), 10)
@ -199,17 +406,22 @@ export function buildSectionsPayload(sections) {
items: (sec.items || [])
.map((it, ii) => {
if (it.item_type === 'note') {
return {
const sm = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
const row = {
item_type: 'note',
order_index: ii,
note_body: it.note_body ?? '',
}
if (sm != null) row.source_training_module_id = sm
return row
}
if (it.exercise_id === '' || it.exercise_id == null || Number.isNaN(Number(it.exercise_id))) {
return null
}
const vid = it.exercise_variant_id
return {
const isCombo = String(it.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
const vid = isCombo ? null : it.exercise_variant_id
const smEx = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
const rowEx = {
item_type: 'exercise',
order_index: ii,
exercise_id: parseInt(it.exercise_id, 10),
@ -220,11 +432,94 @@ export function buildSectionsPayload(sections) {
notes: it.notes?.trim() ? it.notes.trim() : null,
modifications: it.modifications?.trim() ? it.modifications.trim() : null,
}
if (isCombo) {
const pmp = it.planning_method_profile
if (pmp != null && typeof pmp === 'object' && !Array.isArray(pmp)) {
const cleaned = cloneJsonSerializablePlanningProfile(pmp)
if (cleaned && Object.keys(cleaned).length > 0) {
rowEx.planning_method_profile = cleaned
}
}
}
if (smEx != null) rowEx.source_training_module_id = smEx
return rowEx
})
.filter(Boolean),
}))
}
/** Fügt die Positionen eines Moduls in lokale Abschnitte ein (wie eine Übung, ohne Zwischenspeichern der Einheit). */
export async function insertTrainingModuleIntoPlanningSections({
sections,
moduleDetail,
sectionIndex,
insertBeforeItemIndex,
}) {
const secIx = typeof sectionIndex === 'number' ? sectionIndex : parseInt(String(sectionIndex), 10)
if (
!Array.isArray(sections) ||
!Number.isFinite(secIx) ||
secIx < 0 ||
secIx >= sections.length ||
!moduleDetail ||
typeof moduleDetail !== 'object'
) {
return sections
}
const prev = [...(sections[secIx].items || [])]
let beforeIx
if (insertBeforeItemIndex === null || insertBeforeItemIndex === undefined || insertBeforeItemIndex === 'end') {
beforeIx = prev.length
} else if (insertBeforeItemIndex === 'start') {
beforeIx = 0
} else {
const n = typeof insertBeforeItemIndex === 'number' ? insertBeforeItemIndex : parseInt(String(insertBeforeItemIndex), 10)
beforeIx = Number.isFinite(n) ? Math.min(Math.max(n, 0), prev.length) : prev.length
}
const midRaw = moduleDetail.id
const midNum = typeof midRaw === 'number' ? midRaw : parseInt(String(midRaw), 10)
if (!Number.isFinite(midNum) || midNum < 1) return sections
const modTitle = (moduleDetail.title || '').trim() || `Modul #${midNum}`
const modItems = [...(moduleDetail.items || [])].sort(
(a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)
)
const appendRows = []
for (const mi of modItems) {
if (mi.item_type === 'note') {
appendRows.push({
item_type: 'note',
note_body: mi.note_body || '',
source_training_module_id: midNum,
source_module_title: modTitle,
})
continue
}
if (!mi.exercise_id) continue
const hydrated = await hydrateExercisePlanningRow({ id: mi.exercise_id })
if (!hydrated) continue
hydrated.source_training_module_id = midNum
hydrated.source_module_title = modTitle
if (
hydrated.exercise_kind !== 'combination' &&
mi.exercise_variant_id
) {
hydrated.exercise_variant_id = String(mi.exercise_variant_id)
}
hydrated.planned_duration_min =
mi.planned_duration_min !== null && mi.planned_duration_min !== undefined
? String(mi.planned_duration_min)
: ''
hydrated.notes = mi.notes ?? ''
appendRows.push(hydrated)
}
const mergedItems = [...prev.slice(0, beforeIx), ...appendRows, ...prev.slice(beforeIx)]
return sections.map((sec, idx) => (idx === secIx ? { ...sec, items: mergedItems } : sec))
}
export function sectionPlannedMinutes(sec) {
return (sec.items || []).reduce((sum, it) => {
if (it.item_type !== 'exercise') return sum

View File

@ -1,7 +1,7 @@
// Shinkan Jinkendo Frontend Version
export const APP_VERSION = "0.8.94"
export const BUILD_DATE = "2026-05-11"
export const APP_VERSION = "0.8.110"
export const BUILD_DATE = "2026-05-12"
export const PAGE_VERSIONS = {
LoginPage: "1.0.2",
@ -16,7 +16,7 @@ export const PAGE_VERSIONS = {
TrainingPlanningPage: "1.4.0",
TrainingFrameworkProgramsListPage: "1.1.0",
TrainingFrameworkProgramEditPage: "1.5.0",
TrainingUnitRunPage: "1.1.0",
TrainingUnitRunPage: "1.2.0",
TrainingCoachPage: "1.0.0",
AdminCatalogsPage: "2.2.0",
TrainerContextsPage: "1.0.0",